Compare commits

...

12 Commits

Author SHA1 Message Date
Jon Staab
a23f71d307 Update changelog 2024-06-17 16:00:14 -07:00
Jon Staab
1dccb3e95c fix some community/calendar bugs 2024-06-17 15:42:17 -07:00
Jon Staab
c15453b996 remove amber 2024-06-17 12:33:13 -07:00
Jon Staab
a94fabdffb Add wot score component 2024-06-17 12:28:38 -07:00
Jon Staab
0d8d832e87 Fix some group related things 2024-06-17 12:28:19 -07:00
Jon Staab
d87e8ca363 Add amber support 2024-06-13 17:58:30 -07:00
Jon Staab
223a1550f1 Fix a few bugs 2024-06-13 09:35:13 -07:00
Jon Staab
e9b24d5f1d Detect coracle links and open them inline 2024-06-12 16:27:17 -07:00
Jon Staab
b132867967 Add better support for nip 89 handlers 2024-06-12 15:42:32 -07:00
Jon Staab
dadf27d058 Bring back reports via tagr. Fixes #355 2024-06-12 13:39:25 -07:00
Jon Staab
2f0a5def6f Allow users to choose where to publish their profile #359 2024-06-12 12:37:55 -07:00
Jon Staab
5946ece673 Remove people store 2024-06-12 11:47:54 -07:00
42 changed files with 655 additions and 384 deletions

View File

@ -5,6 +5,11 @@
- [x] Show toast when offline
- [x] Use new indexeddb wrapper
- [x] Add `k` tag to deletions
- [x] Allow users to choose where to publish their profile when using a white-labeled instance
- [x] Add "open with" link populated by nip 89 handlers
- [x] Fix several community and calendar related bugs
- [x] Add reports using tagr-bot
- [x] Open links to coracle in same tab
# 0.4.6

View File

@ -72,7 +72,7 @@ Coracle is intended to be fully white-labeled by groups of various kinds. The fo
- `VITE_DUFFLEPUD_URL` is a [Dufflepud](https://github.com/coracle-social/dufflepud) instance url, which helps Coracle with things like link previews and image uploads.
- `VITE_PLATFORM_ZAP_SPLIT` is a decimal between 0 and 1 defining the default zap split percent.
- `VITE_PLATFORM_PUBKEY` is the pubkey of the platform owner. This gets zapped when using the platform zap split.
- `VITE_FORCE_GROUP` is an optional `kind:34550` or `kind:35834` address. If provided, the home page of Coracle will be the home page for the group, and most views will be filtered down to the group's scope.
- `VITE_FORCE_GROUP` is an optional `kind:34550` or `kind:35834` address. If provided, the home page of Coracle will be the home page for the group, and most views will be filtered down to the group's scope. For user privacy, `VITE_PLATFORM_RELAYS` should also be set when using `VITE_FORCE_GROUP`.
- `VITE_PLATFORM_RELAYS` is an optional comma-separated list of relay urls to use for feeds. If provided, most UI components related to relay selection will be hidden from the user.
- `VITE_ENABLE_ZAPS` can be set to `false` to disable zaps.
- `VITE_APP_NAME` is the app's name.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -56,10 +56,11 @@
"@getalby/bitcoin-connect": "^3.2.2",
"@scure/base": "^1.1.6",
"@welshman/content": "^0.0.5",
"@welshman/feeds": "^0.0.10",
"@welshman/feeds": "^0.0.11",
"@welshman/lib": "^0.0.9",
"@welshman/net": "^0.0.12",
"@welshman/util": "^0.0.13",
"@welshman/net": "^0.0.13",
"@welshman/util": "^0.0.14",
"bowser": "^2.11.0",
"classnames": "^2.5.1",
"compressorjs": "^1.2.1",
"date-picker-svelte": "^2.12.0",

View File

@ -221,6 +221,11 @@
entity: asNote,
},
})
router.register("/notes/:entity/report", import("src/app/views/ReportCreate.svelte"), {
serializers: {
entity: asNote,
},
})
router.register("/notes/:entity/thread", import("src/app/views/ThreadDetail.svelte"), {
serializers: {
entity: asNote,
@ -424,7 +429,11 @@
// App data boostrap and relay meta fetching
storage.ready.then(() => {
storage.ready.then(async () => {
// Our stores are throttled by 300, so wait until they're populated
// before loading app data
await lib.sleep(350)
loadAppData()
if ($session) {

View File

@ -79,7 +79,7 @@
</Anchor>
<MenuDesktopItem path="/notes" isActive={isFeedPage || isListPage}>Feeds</MenuDesktopItem>
{#if !$env.FORCE_GROUP && $env.PLATFORM_RELAYS.length === 0}
<MenuDesktopItem path="/settings/relays" isActive={$page.path.startsWith("/settings/relays")}>
<MenuDesktopItem path="/settings/relays" isActive={$page?.path.startsWith("/settings/relays")}>
<div class="relative inline-block">
Relays
{#if $slowConnections.length > 0}
@ -91,7 +91,7 @@
<MenuDesktopItem
path="/notifications"
disabled={!$canSign}
isActive={$page.path.startsWith("/notifications")}>
isActive={$page?.path.startsWith("/notifications")}>
<div class="relative inline-block">
Notifications
{#if $hasNewNotifications}
@ -102,7 +102,7 @@
<MenuDesktopItem
path="/channels"
disabled={!$canSign}
isActive={$page.path.startsWith("/channels")}>
isActive={$page?.path.startsWith("/channels")}>
<div class="relative inline-block">
Messages
{#if $hasNewMessages}
@ -110,14 +110,14 @@
{/if}
</div>
</MenuDesktopItem>
<MenuDesktopItem path="/events" isActive={$page.path.startsWith("/events")}
<MenuDesktopItem path="/events" isActive={$page?.path.startsWith("/events")}
>Calendar</MenuDesktopItem>
{#if $env.ENABLE_MARKET}
<MenuDesktopItem path="/listings" isActive={$page.path.startsWith("/listings")}
<MenuDesktopItem path="/listings" isActive={$page?.path.startsWith("/listings")}
>Market</MenuDesktopItem>
{/if}
{#if !$env.FORCE_GROUP}
<MenuDesktopItem path="/groups" isActive={$page.path.startsWith("/groups")}
<MenuDesktopItem path="/groups" isActive={$page?.path.startsWith("/groups")}
>Groups</MenuDesktopItem>
{/if}
<FlexColumn small class="absolute bottom-0 w-72">
@ -125,7 +125,7 @@
class="staatliches px-8 text-tinted-400 hover:text-tinted-100"
on:click={() => setSubMenu("settings")}>Settings</Anchor>
<div class="staatliches block flex h-8 gap-2 px-8 text-tinted-500">
<Anchor external class="hover:text-tinted-100" href="/about">About</Anchor> /
<Anchor class="hover:text-tinted-100" href="/about">About</Anchor> /
<Anchor external class="hover:text-tinted-100" href="/terms.html">Terms</Anchor> /
<Anchor external class="hover:text-tinted-100" href="/privacy.html">Privacy</Anchor>
</div>

View File

@ -1,31 +1,41 @@
<script lang="ts">
import {fromPairs} from "ramda"
import {batch} from "hurdak"
import {onMount, onDestroy} from "svelte"
import {writable} from "@welshman/lib"
import {onMount} from "svelte"
import {fromPairs} from "@welshman/lib"
import {getAddress, getReplyFilters} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {feedFromFilter} from "@welshman/feeds"
import Calendar from "@event-calendar/core"
import DayGrid from "@event-calendar/day-grid"
import Interaction from "@event-calendar/interaction"
import {secondsToDate} from "src/util/misc"
import {themeColors} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import {
hints,
load,
pubkey,
canSign,
repository,
subscribe,
feedLoader,
getFilterSelections,
} from "src/engine"
import {hints, load, pubkey, canSign, loadAll, deriveEventsMapped} from "src/engine"
import {router} from "src/app/util/router"
export let feed
export let filter
export let group = null
const calendarEvents = deriveEventsMapped({
filters: [filter],
itemToEvent: (item: any) => item.event,
eventToItem: (event: TrustedEvent) => {
const meta = fromPairs(event.tags)
const isOwn = event.pubkey === $pubkey
return {
event,
editable: isOwn,
id: getAddress(event),
title: meta.title || meta.name, // Backwards compat with a bug
start: secondsToDate(meta.start),
end: secondsToDate(meta.end),
backgroundColor: $themeColors[isOwn ? "accent" : "neutral-100"],
}
},
})
const createEvent = () => router.at("notes/create").qp({type: "calendar_event", group}).open()
const getEventContent = ({event}) => event.title
@ -45,55 +55,17 @@
const onEventClick = ({event: calendarEvent}) => router.at("events").of(calendarEvent.id).open()
const events = writable(new Map())
const onEvent = batch(300, (chunk: TrustedEvent[]) => {
events.update($events => {
for (const e of chunk) {
const addr = getAddress(e)
const dup = $events.get(addr)
// Make sure we have the latest version of every event
$events.set(addr, dup?.created_at > e.created_at ? dup : e)
}
return $events
})
// Load deletes for these events
load({
relays: hints.merge(chunk.map(e => hints.EventChildren(e))).getUrls(),
filters: getReplyFilters(chunk, {kinds: [5]}),
onMount(() => {
loadAll(feedFromFilter(filter), {
// Load deletes for these events
onEvent: batch(300, (chunk: TrustedEvent[]) => {
load({
relays: hints.merge(chunk.map(e => hints.EventChildren(e))).getUrls(),
filters: getReplyFilters(chunk, {kinds: [5]}),
})
}),
})
})
let subs = []
onMount(async () => {
const [{filters}] = await feedLoader.compiler.compile(feed)
subs = getFilterSelections(filters).map(({relay, filters}) =>
subscribe({relays: [relay], filters, onEvent}),
)
})
onDestroy(() => subs.map(sub => sub.close()))
$: calendarEvents = Array.from($events.values())
.filter(e => !repository.isDeleted(e))
.map(e => {
const meta = fromPairs(e.tags)
const isOwn = e.pubkey === $pubkey
return {
editable: isOwn,
id: getAddress(e),
title: meta.title || meta.name, // Backwards compat with a bug
start: secondsToDate(meta.start),
end: secondsToDate(meta.end),
backgroundColor: $themeColors[isOwn ? "accent" : "neutral-100"],
}
})
</script>
{#if $canSign}
@ -110,7 +82,7 @@
plugins={[Interaction, DayGrid]}
options={{
view: "dayGridMonth",
events: calendarEvents,
events: $calendarEvents,
dateClick: onDateClick,
eventClick: onEventClick,
eventContent: getEventContent,

View File

@ -1,5 +1,6 @@
<script lang="ts">
import {debounce} from "throttle-debounce"
import {equals} from "ramda"
import {isSearchFeed, makeSearchFeed, makeScopeFeed, Scope, getFeedArgs} from "@welshman/feeds"
import {toSpliced} from "src/util/misc"
import {boolCtrl} from "src/partials/utils"
@ -11,6 +12,7 @@
import MenuItem from "src/partials/MenuItem.svelte"
import FeedForm from "src/app/shared/FeedForm.svelte"
import {router} from "src/app/util"
import {globalFeed} from "src/app/state"
import {normalizeFeedDefinition, displayList, readFeed, makeFeed, displayFeed} from "src/domain"
import {userListFeeds, canSign, deleteEvent, userFeeds} from "src/engine"
@ -118,15 +120,27 @@
<Anchor modal href="/feeds/create"><i class="fa fa-plus" /></Anchor>
</MenuItem>
<div class="max-h-80 overflow-auto">
<MenuItem on:click={() => setFeed(followsFeed)}>Follows</MenuItem>
<MenuItem on:click={() => setFeed(networkFeed)}>Network</MenuItem>
<MenuItem
active={equals(followsFeed.definition, $globalFeed.definition)}
on:click={() => setFeed(followsFeed)}>
Follows
</MenuItem>
<MenuItem
active={equals(networkFeed.definition, $globalFeed.definition)}
on:click={() => setFeed(networkFeed)}>
Network
</MenuItem>
{#each $userFeeds as feed}
<MenuItem on:click={() => setFeed(feed)}>
<MenuItem
active={equals(feed.definition, $globalFeed.definition)}
on:click={() => setFeed(feed)}>
{displayFeed(feed)}
</MenuItem>
{/each}
{#each $userListFeeds as feed}
<MenuItem on:click={() => setFeed(feed)}>
<MenuItem
active={equals(feed.definition, $globalFeed.definition)}
on:click={() => setFeed(feed)}>
{displayList(feed.list)}
</MenuItem>
{/each}

View File

@ -1,8 +1,8 @@
<script lang="ts">
import {feedFromFilter} from "@welshman/feeds"
import {EVENT_TIME} from "@welshman/util"
import Calendar from "src/app/shared/Calendar.svelte"
export let address
</script>
<Calendar group={address} feed={feedFromFilter({kinds: [31923], "#a": [address]})} />
<Calendar group={address} filter={{kinds: [EVENT_TIME], "#a": [address]}} />

View File

@ -1,17 +1,25 @@
<script>
import {ellipsize} from "hurdak"
import {derived} from "svelte/store"
import {remove, intersection} from "@welshman/lib"
import Chip from "src/partials/Chip.svelte"
import Card from "src/partials/Card.svelte"
import GroupCircle from "src/app/shared/GroupCircle.svelte"
import PersonCircles from "src/app/shared/PersonCircles.svelte"
import {router} from "src/app/util/router"
import {displayGroup, deriveGroup, getWotGroupMembers} from "src/engine"
import {displayGroup, deriveGroup, userFollows, communityListsByAddress, pubkey} from "src/engine"
export let address
export let modal = false
const group = deriveGroup(address)
const members = $getWotGroupMembers(address)
const members = derived(communityListsByAddress, $m => {
const allMembers = $m.get(address)?.map(l => l.event.pubkey) || []
const otherMembers = remove($pubkey, allMembers)
const followMembers = intersection(otherMembers, Array.from($userFollows))
return followMembers
})
const enter = () => {
const route = router.at("groups").of(address).at("notes")
@ -45,9 +53,10 @@
{ellipsize($group.meta.about, 300)}
</p>
{/if}
{#if members.length > 0}
<p class="mt-4 text-lg text-neutral-300">Members:</p>
<PersonCircles pubkeys={members.slice(0, 20)} />
{#if $members.length > 0}
<div class="pt-1">
<PersonCircles class="h-6 w-6" pubkeys={$members.slice(0, 20)} />
</div>
{/if}
</div>
</Card>

View File

@ -1,4 +1,5 @@
<script lang="ts">
import {isGroupAddress} from "@welshman/util"
import Card from "src/partials/Card.svelte"
import Anchor from "src/partials/Anchor.svelte"
import PersonSummary from "src/app/shared/PersonSummary.svelte"
@ -24,7 +25,7 @@
<Card interactive on:click={() => openPerson(pubkey)}>
<PersonSummary inert {pubkey}>
<div slot="actions" on:click|stopPropagation>
{#if $adminKey && pubkey !== $session.pubkey}
{#if $adminKey && pubkey !== $session.pubkey && isGroupAddress(address)}
<Anchor on:click={remove} button accent>Remove</Anchor>
{/if}
</div>

View File

@ -1,15 +1,26 @@
<script lang="ts">
import {COMMUNITIES} from "@welshman/util"
import FlexColumn from "src/partials/FlexColumn.svelte"
import GroupMember from "src/app/shared/GroupMember.svelte"
import {deriveGroup} from 'src/engine'
import {load, hints, deriveGroup, communityListsByAddress} from "src/engine"
export let address
const group = deriveGroup(address)
const filters = [{kinds: [COMMUNITIES], "#a": [address]}]
$: members =
$group.members || $communityListsByAddress.get(address)?.map(l => l.event.pubkey) || []
load({
filters,
skipCache: true,
relays: hints.merge([hints.WithinContext(address), hints.User()]).getUrls(),
})
</script>
<FlexColumn>
{#each $group.members || [] as pubkey (pubkey)}
{#each members as pubkey (pubkey)}
<GroupMember {address} {pubkey} />
{:else}
<p class="text-center py-12">No members found.</p>

View File

@ -53,6 +53,7 @@
export let showLoading = false
export let showMuted = false
export let showGroup = false
export let showMedia = getSetting("show_media")
export let contextAddress = null
let zapper, unsubZapper
@ -63,7 +64,7 @@
let showMutedReplies = false
let actions = null
let collapsed = depth === 0
let context = repository.query([{'#e': [event.id]}]).filter(e => isChildOf(e, event))
let context = repository.query([{"#e": [event.id]}]).filter(e => isChildOf(e, event))
let showHiddenReplies = anchor === getIdOrAddress(event)
const showEntire = showHiddenReplies
@ -296,7 +297,7 @@
}}>Show</Anchor>
</p>
{:else}
<NoteContent note={event} {showEntire} />
<NoteContent note={event} {showEntire} {showMedia} />
{/if}
<div class="cy-note-click-target h-[2px]" />
<NoteActions

View File

@ -3,6 +3,7 @@
import {nip19} from "nostr-tools"
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {last, sortBy} from "@welshman/lib"
import type {TrustedEvent, SignedEvent} from "@welshman/util"
import {
LOCAL_RELAY_URL,
@ -18,9 +19,15 @@
import {fly} from "src/util/transition"
import {formatSats} from "src/util/misc"
import {quantify, pluralize} from "hurdak"
import {browser} from "src/partials/state"
import {showInfo} from "src/partials/Toast.svelte"
import Icon from "src/partials/Icon.svelte"
import Anchor from "src/partials/Anchor.svelte"
import WotScore from "src/partials/WotScore.svelte"
import Popover from "src/partials/Popover.svelte"
import ImageCircle from "src/partials/ImageCircle.svelte"
import Menu from "src/partials/Menu.svelte"
import MenuItem from "src/partials/MenuItem.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte"
import Card from "src/partials/Card.svelte"
import Heading from "src/partials/Heading.svelte"
@ -70,12 +77,12 @@
const addresses = [address].filter(identity)
const nevent = nip19.neventEncode({id: note.id, relays: hints.Event(note).getUrls()})
const muted = derived(isEventMuted, $isEventMuted => $isEventMuted(note, true))
const kindHandlers = deriveHandlersForKind(note.kind)
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const mentions = tags.values("p").valueOf()
const likesCount = tweened(0, {interpolate})
const zapsTotal = tweened(0, {interpolate})
const repliesCount = tweened(0, {interpolate})
const kindHandlers = deriveHandlersForKind(note.kind)
const handlerId = tags.get("client")?.nth(2)
const handlerEvent = handlerId ? repository.getEvent(handlerId) : null
const seenOn = tracker.data.derived(m =>
@ -94,10 +101,14 @@
handlersShown = false
}
const os = browser.os.name.toLowerCase()
const createLabel = () => router.at("notes").of(note.id).at("label").open()
const quote = () => router.at("notes/create").cx({quote: note}).open()
const report = () => router.at("notes").of(note.id).at("report").open()
const react = async content => {
if (isSignedEvent(note)) {
publish({event: note, relays: hints.PublishEvent(note).getUrls()})
@ -160,6 +171,26 @@
showInfo("Note has been re-published!")
}
const openWithHandler = handler => {
const [templateTag] = sortBy((t: string[]) => {
if (t[0] === "web" && last(t) === "nevent") return -6
if (t[0] === "web" && last(t) === "note") return -5
if (t[0] === "web" && t.length === 2) return -4
if (t[0] === os && last(t) === "nevent") return -3
if (t[0] === os && last(t) === "note") return -2
if (t[0] === os && t.length === 2) return -1
return 0
}, handler.event.tags)
const entity =
last(templateTag) === "note"
? nip19.noteEncode(note.id)
: nip19.neventEncode({id: note.id, relays: hints.Event(note).getUrls()})
window.open(templateTag[1].replace("<bech32>", entity))
}
const groupOptions = session.derived($session => {
const options = []
@ -190,6 +221,13 @@
$: canZap = zapper && note.pubkey !== $session?.pubkey
$: reply = replies.find(e => e.pubkey === $session?.pubkey)
$: $repliesCount = replies.length
$: handlers = $kindHandlers.filter(
h =>
h.name.toLowerCase() !== "coracle" &&
h.event.tags.some(
t => ["web", os].includes(t[0]) && (t.length == 2 || ["note", "nevent"].includes(last(t))),
),
)
$: {
actions = []
@ -208,6 +246,8 @@
} else {
actions.push({label: "Mute", icon: "microphone-slash", onClick: muteNote})
}
actions.push({label: "Report", icon: "triangle-exclamation", onClick: report})
}
if (!$env.FORCE_GROUP && $env.PLATFORM_RELAYS.length === 0 && isSignedEvent(note)) {
@ -268,6 +308,33 @@
{/if}
</button>
{/if}
{#if handlers.length > 0}
<Popover theme="transparent" opts={{hideOnClick: true}}>
<button
slot="trigger"
class="relative flex h-6 items-center gap-1 pt-1 transition-all hover:pb-1 hover:pt-0">
<i class="fa fa-up-right-from-square fa-sm" />
</button>
<div slot="tooltip" class="max-h-[300px] min-w-[180px] overflow-auto">
<Menu>
<MenuItem inert class="bg-neutral-900">Open with:</MenuItem>
{#each handlers as handler}
<MenuItem
class="flex h-12 items-center justify-between gap-2"
on:click={() => openWithHandler(handler)}>
<div class="flex gap-2">
<ImageCircle class="h-5 w-5" src={handler.image} />
{handler.name}
</div>
{#if handler.recommendations.length > 0}
<WotScore accent score={handler.recommendations.length} />
{/if}
</MenuItem>
{/each}
</Menu>
</div>
</Popover>
{/if}
</div>
<div class="flex scale-90 items-center gap-2">
{#if note.wrap}
@ -330,7 +397,7 @@
{/each}
</div>
{/if}
{#if $kindHandlers.length > 0 || handlerEvent}
{#if handlers.length > 0 || handlerEvent}
<h1 class="staatliches text-2xl">Apps</h1>
{#if handlerEvent}
{@const [handler] = readHandlers(handlerEvent)}
@ -339,13 +406,10 @@
<HandlerCard {handler} />
{/if}
{/if}
{#if $kindHandlers.length > 0}
{#if handlers.length > 0}
<div class="flex justify-between">
<p>
This note can also be viewed using {quantify(
$kindHandlers.length,
"other nostr app",
)}.
This note can also be viewed using {quantify(handlers.length, "other nostr app")}.
</p>
{#if handlersShown}
<Anchor underline on:click={hideHandlers}>Hide apps</Anchor>
@ -356,7 +420,7 @@
{#if handlersShown}
<div in:fly={{y: 20}}>
<FlexColumn>
{#each $kindHandlers as handler (getHandlerKey(handler))}
{#each handlers as handler (getHandlerKey(handler))}
<HandlerCard {handler} />
{/each}
</FlexColumn>

View File

@ -21,7 +21,7 @@
const tags = Tags.fromEvent(note)
const images = tags.values("image").valueOf()
const {title, summary, location, status} = tags.asObject()
const [price = 0, code = "SAT"] = tags.get("price")?.drop(1).valueOf() || []
const [price, code = "SAT"] = tags.get("price")?.drop(1).valueOf() || []
const address = Address.fromEvent(note, hints.Event(note).redundancy(3).getUrls())
const editLink = router.at("listings").of(address.toString()).at("edit").toString()
const deleteLink = router.at("listings").of(address.toString()).at("delete").toString()
@ -58,7 +58,7 @@
{/if}
</div>
<span class="whitespace-nowrap">
<CurrencySymbol {code} />{commaFormat(price)}
<CurrencySymbol {code} />{commaFormat(price || 0)}
{code}
</span>
</div>

View File

@ -17,7 +17,15 @@
let hidden = false
</script>
{#if showMedia && value.isMedia && !hidden}
{#if url.includes('coracle.social/')}
<Anchor
modal
stopPropagation
class="overflow-hidden text-ellipsis whitespace-nowrap underline"
href={url.replace(/(https?:\/\/)?(app\.)?coracle.social/, '')}>
{displayUrl(url)}
</Anchor>
{:else if showMedia && value.isMedia && !hidden}
<div class="py-2">
<Media url={url} onClose={close} />
</div>

View File

@ -97,7 +97,7 @@
<form on:submit|preventDefault={() => onSubmit()}>
<AltColor background class="z-feature flex gap-4 overflow-hidden rounded p-3 text-neutral-100">
<PersonCircle class="h-10 w-10" pubkey={$pubkey} />
<div class="w-full">
<div class="w-full min-w-0">
<Compose placeholder="What's up?" bind:this={compose} {onSubmit} style="min-height: 3em;" />
<div class="p-2">
<NoteImages bind:this={images} bind:compose includeInContent />

View File

@ -15,9 +15,9 @@
<script lang="ts">
import cx from "classnames"
import {derived} from "svelte/store"
import {themeColors} from "src/partials/state"
import Popover from "src/partials/Popover.svelte"
import Anchor from "src/partials/Anchor.svelte"
import WotScore from "src/partials/WotScore.svelte"
import {displayPubkey} from "src/domain"
import {userFollows, deriveProfileDisplay, session, maxWot, getWotScore} from "src/engine"
@ -27,11 +27,7 @@
const wotScore = getWotScore($session?.pubkey, pubkey)
const npubDisplay = displayPubkey(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
$: superMaxWot = $maxWot * 1.5
$: dashOffset = 100 - (Math.max(superMaxWot / 20, wotScore) / superMaxWot) * 100
$: style = `transform: rotate(${dashOffset * 1.8 - 50}deg)`
$: stroke = $themeColors[$following || pubkey === $session?.pubkey ? "accent" : "neutral-200"]
const accent = $following || pubkey === $session?.pubkey
</script>
<div class={cx("flex gap-1", $$props.class)}>
@ -44,22 +40,9 @@
{#if $session}
<div class="flex gap-1 font-normal">
<Popover triggerType="mouseenter">
<span
slot="trigger"
class="relative flex h-10 w-10 items-center justify-center whitespace-nowrap px-4 text-xs">
<svg height="32" width="32" class="absolute">
<circle class="wot-background" cx="16" cy="16" r="15" />
<circle
cx="16"
cy="16"
r="15"
class="wot-highlight"
stroke-dashoffset={dashOffset}
{style}
{stroke} />
</svg>
{wotScore}
</span>
<div slot="trigger">
<WotScore score={wotScore} max={$maxWot} {accent} />
</div>
<Anchor modal slot="tooltip" class="flex items-center gap-1" href="/help/web-of-trust">
<i class="fa fa-info-circle" />
WoT Score

View File

@ -44,101 +44,103 @@
{#await promise}
<!-- pass -->
{:then event}
<div in:fly|local={{y: 20}}>
<Card>
<FlexColumn>
<div class="flex justify-between">
<span>Kind {event.kind}, published {formatTimestamp(pub.created_at)}</span>
<Anchor underline modal class="text-sm" on:click={() => open(event)}>View Note</Anchor>
</div>
<div class="flex justify-between text-sm">
<div class="hidden gap-4 sm:flex">
<span class="flex items-center gap-2">
<i class="fa fa-check" />
{success.length} succeeded
</span>
{#if pending.length > 0}
{#if event}
<div in:fly|local={{y: 20}}>
<Card>
<FlexColumn>
<div class="flex justify-between">
<span>Kind {event.kind}, published {formatTimestamp(pub.created_at)}</span>
<Anchor underline modal class="text-sm" on:click={() => open(event)}>View Note</Anchor>
</div>
<div class="flex justify-between text-sm">
<div class="hidden gap-4 sm:flex">
<span class="flex items-center gap-2">
<i class="fa fa-circle-notch fa-spin" />
{pending.length} pending
</span>
{/if}
{#if failure.length > 0}
<span class="flex items-center gap-2">
<i class="fa fa-triangle-exclamation" />
{failure.length} failed
</span>
{/if}
{#if timeout.length > 0}
<span class="flex items-center gap-2">
<i class="fa fa-stopwatch" />
{timeout.length} timed out
<i class="fa fa-check" />
{success.length} succeeded
</span>
{#if pending.length > 0}
<span class="flex items-center gap-2">
<i class="fa fa-circle-notch fa-spin" />
{pending.length} pending
</span>
{/if}
{#if failure.length > 0}
<span class="flex items-center gap-2">
<i class="fa fa-triangle-exclamation" />
{failure.length} failed
</span>
{/if}
{#if timeout.length > 0}
<span class="flex items-center gap-2">
<i class="fa fa-stopwatch" />
{timeout.length} timed out
</span>
{/if}
</div>
{#if expanded}
<Anchor class="flex items-center gap-2" on:click={collapse}>
<i class="fa fa-caret-up" />
<span class="text-underline">Hide Details</span>
</Anchor>
{:else}
<Anchor class="flex items-center gap-2" on:click={expand}>
<i class="fa fa-caret-down" />
<span class="text-underline">Show Details</span>
</Anchor>
{/if}
</div>
{#if expanded}
<Anchor class="flex items-center gap-2" on:click={collapse}>
<i class="fa fa-caret-up" />
<span class="text-underline">Hide Details</span>
</Anchor>
{:else}
<Anchor class="flex items-center gap-2" on:click={expand}>
<i class="fa fa-caret-down" />
<span class="text-underline">Show Details</span>
</Anchor>
<div transition:slide|local>
<FlexColumn>
{#if pending.length > 0}
<p class="mt-4 text-lg">The following relays are still pending:</p>
<div class="grid gap-2 sm:grid-cols-2">
{#each pending as url}
<RelayCard hideActions {url} />
{/each}
</div>
{/if}
{#if success.length > 0}
<p class="mt-4 text-lg">The following relays accepted your note:</p>
<div class="grid gap-2 sm:grid-cols-2">
{#each success as url}
<RelayCard hideActions {url} />
{/each}
</div>
{/if}
{#if failure.length > 0}
<p class="mt-4 text-lg">The following relays rejected your note:</p>
{#each failure as url}
<RelayCard {url}>
<div slot="actions">
<Anchor
on:click={() => retry(url, event)}
class="flex items-center gap-2 text-sm">
<i class="fa fa-rotate" /> Retry
</Anchor>
</div>
</RelayCard>
{/each}
{/if}
{#if timeout.length > 0}
<p class="mt-4 text-lg">The following relays did not respond:</p>
{#each timeout as url}
<RelayCard {url}>
<div slot="actions">
<Anchor
on:click={() => retry(url, event)}
class="flex items-center gap-2 text-sm">
<i class="fa fa-rotate" /> Retry
</Anchor>
</div>
</RelayCard>
{/each}
{/if}
</FlexColumn>
</div>
{/if}
</div>
{#if expanded}
<div transition:slide|local>
<FlexColumn>
{#if pending.length > 0}
<p class="mt-4 text-lg">The following relays are still pending:</p>
<div class="grid gap-2 sm:grid-cols-2">
{#each pending as url}
<RelayCard hideActions {url} />
{/each}
</div>
{/if}
{#if success.length > 0}
<p class="mt-4 text-lg">The following relays accepted your note:</p>
<div class="grid gap-2 sm:grid-cols-2">
{#each success as url}
<RelayCard hideActions {url} />
{/each}
</div>
{/if}
{#if failure.length > 0}
<p class="mt-4 text-lg">The following relays rejected your note:</p>
{#each failure as url}
<RelayCard {url}>
<div slot="actions">
<Anchor
on:click={() => retry(url, event)}
class="flex items-center gap-2 text-sm">
<i class="fa fa-rotate" /> Retry
</Anchor>
</div>
</RelayCard>
{/each}
{/if}
{#if timeout.length > 0}
<p class="mt-4 text-lg">The following relays did not respond:</p>
{#each timeout as url}
<RelayCard {url}>
<div slot="actions">
<Anchor
on:click={() => retry(url, event)}
class="flex items-center gap-2 text-sm">
<i class="fa fa-rotate" /> Retry
</Anchor>
</div>
</RelayCard>
{/each}
{/if}
</FlexColumn>
</div>
{/if}
</FlexColumn>
</Card>
</div>
</FlexColumn>
</Card>
</div>
{/if}
{/await}

View File

@ -0,0 +1 @@

View File

@ -22,8 +22,6 @@ import {
getSetting,
} from "src/engine"
// Global state
export const drafts = new Map<string, string>()
export const menuIsOpen = writable(false)

View File

@ -1,7 +1,7 @@
import {partition, prop, uniqBy} from "ramda"
import {batch, tryFunc, seconds} from "hurdak"
import {writable, derived} from "svelte/store"
import {inc, pushToMapKey, sortBy, now} from "@welshman/lib"
import {inc, assoc, pushToMapKey, sortBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
Tags,
@ -14,7 +14,7 @@ import {
REACTION,
} from "@welshman/util"
import {Tracker} from "@welshman/net"
import type {Feed} from "@welshman/feeds"
import type {Feed, RequestItem} from "@welshman/feeds"
import {walkFeed, FeedLoader as CoreFeedLoader} from "@welshman/feeds"
import {noteKinds, isLike, reactionKinds, repostKinds} from "src/util/nostr"
import {withGetter} from "src/util/misc"
@ -59,7 +59,7 @@ const prepFilters = (filters, opts: FeedOpts) => {
return filters
}
function* getRequestItems({relays, filters}, opts: FeedOpts) {
function* getRequestItems({relays, filters}: RequestItem, opts: FeedOpts) {
filters = prepFilters(filters, opts)
// Use relays specified in feeds
@ -127,14 +127,15 @@ export const createFeed = (opts: FeedOpts) => {
if (reqs && opts.shouldListen) {
const tracker = new Tracker()
for (const {relays, filters} of reqs) {
for (const request of Array.from(getRequestItems({relays, filters}, opts))) {
for (const request of reqs) {
for (const {relays, filters} of Array.from(getRequestItems(request, opts))) {
subscribe({
...request,
relays,
tracker,
skipCache: true,
onEvent: prependEvent,
signal: controller.signal,
filters: filters.map(assoc("since", now())),
forcePlatform: opts.forcePlatform && (relays?.length || 0) === 0,
})
}

View File

@ -1,21 +1,15 @@
<script lang="ts">
import {
Scope,
feedFromFilter,
makeIntersectionFeed,
makeKindFeed,
makeScopeFeed,
} from "@welshman/feeds"
import {identity} from "ramda"
import Calendar from "src/app/shared/Calendar.svelte"
import {env, loadGroupMessages} from "src/engine"
import {env, loadGroupMessages, pubkey, userFollows} from "src/engine"
const feed = $env.FORCE_GROUP
? feedFromFilter({kinds: [31923], "#a": [$env.FORCE_GROUP]})
: makeIntersectionFeed(makeKindFeed(31923), makeScopeFeed(Scope.Self, Scope.Follows))
const filter = $env.FORCE_GROUP
? {kinds: [31923], "#a": [$env.FORCE_GROUP]}
: {kinds: [31923], authors: [$pubkey, ...$userFollows].filter(identity)}
if ($env.FORCE_GROUP) {
loadGroupMessages([$env.FORCE_GROUP])
}
</script>
<Calendar {feed} />
<Calendar {filter} />

View File

@ -71,7 +71,7 @@
tabs.push("market")
}
if ($sharedKey) {
if ($sharedKey || address.startsWith("34550")) {
tabs.push("members")
} else if (activeTab === "members") {
activeTab = "notes"

View File

@ -1,7 +1,8 @@
<script>
import {onMount} from "svelte"
import {filter, assoc} from "ramda"
import {now} from "@welshman/lib"
import {now, shuffle} from "@welshman/lib"
import {GROUP, COMMUNITY, getIdFilters} from "@welshman/util"
import {createScroller} from "src/util/misc"
import Anchor from "src/partials/Anchor.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte"
@ -12,11 +13,11 @@
hints,
groups,
repository,
getGroupReqInfo,
loadGiftWraps,
loadGroupMessages,
deriveIsGroupMember,
updateCurrentSession,
communityListsByAddress,
searchGroups,
} from "src/engine"
@ -41,9 +42,10 @@
document.title = "Groups"
onMount(() => {
const {admins} = getGroupReqInfo()
const scroller = createScroller(loadMore, {element})
const loader = loadGiftWraps()
const scroller = createScroller(loadMore, {element})
const communityAddrs = Array.from($communityListsByAddress.keys())
.filter(a => !groups.key(a).get()?.meta)
updateCurrentSession(assoc("groups_last_synced", now()))
@ -51,15 +53,15 @@
load({
skipCache: true,
forcePlatform: false,
relays: hints.User().getUrls(),
filters: [{kinds: [35834, 34550], authors: admins}],
filters: [{kinds: [GROUP, COMMUNITY], limit: 1000 - communityAddrs.length}],
})
load({
skipCache: true,
forcePlatform: false,
relays: hints.User().getUrls(),
filters: [{kinds: [35834, 34550], limit: 500}],
filters: getIdFilters(shuffle(communityAddrs).slice(0, 1000)),
})
return () => {

View File

@ -2,7 +2,7 @@
import cx from "classnames"
import {onMount} from "svelte"
import {last, prop, objOf} from "ramda"
import {Handlerinformation, NostrConnect} from "nostr-tools/kinds"
import {HANDLER_INFORMATION, NOSTR_CONNECT} from "@welshman/util"
import {tryJson} from "src/util/misc"
import {showWarning} from "src/partials/Toast.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -53,7 +53,6 @@
loading = true
try {
// Fill in pubkey and relays if they entered a custom doain
if (!handler.pubkey) {
const handle = await loadHandle(`_@${handler.domain}`)
@ -105,8 +104,8 @@
relays: hints.ReadRelays().getUrls(),
filters: [
{
kinds: [Handlerinformation],
"#k": [NostrConnect.toString()],
kinds: [HANDLER_INFORMATION],
"#k": [NOSTR_CONNECT.toString()],
},
],
onEvent: async e => {

View File

@ -1,60 +1,67 @@
<script lang="ts">
import {identity} from "ramda"
import {seconds} from "hurdak"
import {now} from "@welshman/lib"
import {fuzzy} from "src/util/misc"
import {asSignedEvent, createEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {generatePrivateKey} from "src/util/nostr"
import {showInfo} from "src/partials/Toast.svelte"
import Heading from "src/partials/Heading.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Field from "src/partials/Field.svelte"
import SearchSelect from "src/partials/SearchSelect.svelte"
import Textarea from "src/partials/Textarea.svelte"
import PersonLink from "src/app/shared/PersonLink.svelte"
import Note from "src/app/shared/Note.svelte"
import {router} from "src/app/util/router"
import {createAndPublish, hints} from "src/engine"
import {repository, nip59, loadPubkeys, publish, hints} from "src/engine"
export let eid
export let pubkey
const searchContentWarnings = fuzzy(["nudity", "profanity", "illegal", "spam", "impersonation"])
const event = repository.getEvent(eid)
const submit = () => {
const tags = [
["p", pubkey],
["expiration", String(now() + seconds(7, "day"))],
]
const tagr = "56d4b3d6310fadb7294b7f041aab469c5ffc8991b1b1b331981b96a246f6ae65"
if (flags.length > 0) {
for (const flag of flags) {
tags.push(["e", eid, flag])
}
}
const submit = async () => {
const content = JSON.stringify({
reporterText: message,
reportedEvent: asSignedEvent(event as SignedEvent),
})
const template = createEvent(14, {content})
const rumor = await $nip59.wrap(template, {
author: generatePrivateKey(),
wrap: {
author: generatePrivateKey(),
recipient: tagr,
},
})
publish({
event: rumor.wrap,
relays: hints
.merge([hints.fromRelays(["wss://relay.nos.social"]), hints.PublishMessage(tagr)])
.getUrls(),
forcePlatform: false,
})
createAndPublish({kind: 1984, tags, relays: hints.WriteRelays().getUrls()})
showInfo("Your report has been sent!")
router.pop()
}
let flags = []
let message = ""
loadPubkeys([tagr])
</script>
<form on:submit|preventDefault={submit}>
<FlexColumn>
<Heading class="text-center">File a Report</Heading>
<div class="flex w-full flex-col gap-8">
<Field label="Content Warnings">
<SearchSelect
multiple
autofocus
search={searchContentWarnings}
bind:value={flags}
termToItem={identity}>
<div slot="item" let:item>
<strong>{item}</strong>
</div>
</SearchSelect>
<div slot="info">Flag this content as sensitive so other people can avoid it.</div>
</Field>
<Anchor button tag="button" type="submit">Save</Anchor>
</div>
<Field label="Why are you reporting this content?">
<Textarea bind:value={message} />
<div slot="info">
Reports are sent to <PersonLink pubkey={tagr} /> for review. No identifying information is included
with the report.
</div>
</Field>
<Note note={event} showMedia={false} />
<Anchor button tag="button" type="submit">Save</Anchor>
</FlexColumn>
</form>

View File

@ -1,12 +1,15 @@
<script lang="ts">
import Input from "src/partials/Input.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte"
import Card from "src/partials/Card.svelte"
import ImageInput from "src/partials/ImageInput.svelte"
import Textarea from "src/partials/Textarea.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Footer from "src/partials/Footer.svelte"
import Heading from "src/partials/Heading.svelte"
import Modal from "src/partials/Modal.svelte"
import Field from "src/partials/Field.svelte"
import {pubkey, getProfile, publishProfile} from "src/engine"
import {env, pubkey, getProfile, publishProfile} from "src/engine"
import {router} from "src/app/util/router"
const nip05Url = "https://github.com/nostr-protocol/nips/blob/master/05.md"
@ -14,13 +17,34 @@
const pseudUrl =
"https://www.coindesk.com/markets/2020/06/29/many-bitcoin-developers-are-choosing-to-use-pseudonyms-for-good-reason/"
const submit = () => {
publishProfile(values)
const closeModal = () => {
modal = null
}
const publishToPlatform = () => {
publishProfile(values, {forcePlatform: true})
router.pop()
router.pop()
}
const publishToNetwork = () => {
publishProfile(values, {forcePlatform: false})
router.pop()
router.pop()
}
const submit = () => {
if ($env.PLATFORM_RELAYS.length === 0) {
publishToNetwork()
} else {
modal = 'select-scope'
}
}
const values = {...getProfile($pubkey)}
let modal
document.title = "Profile"
</script>
@ -85,3 +109,42 @@
<Anchor grow button tag="button" type="submit">Save</Anchor>
</Footer>
</form>
{#if modal === 'select-scope'}
<Modal onEscape={closeModal}>
<div class="mb-4 flex flex-col items-center justify-center">
<Heading>Update Profile</Heading>
<p>Where would you like to publish your profile?</p>
</div>
<Card interactive on:click={publishToNetwork}>
<FlexColumn>
<div class="flex items-center justify-between">
<p class="flex items-center gap-4 text-xl">
<i class="fa fa-share-nodes text-neutral-600" />
<strong>The wider nostr network</strong>
</p>
<i class="fa fa-arrow-right" />
</div>
<p>
Publishing your profile to the wider nostr network will allow anyone to see it.
Use this if you plan to use other clients or relay selections.
</p>
</FlexColumn>
</Card>
<Card interactive on:click={publishToPlatform}>
<FlexColumn>
<div class="flex items-center justify-between">
<p class="flex items-center gap-4 text-xl">
<i class="fa fa-thumbtack text-neutral-600" />
<strong>Just this instance</strong>
</p>
<i class="fa fa-arrow-right" />
</div>
<p>
Publish your profile just to the relays configured on this instance if you prefer.
Be aware that how private this is depends on how the instance operator has set things up.
</p>
</FlexColumn>
</Card>
</Modal>
{/if}

View File

@ -66,7 +66,6 @@ import {
nip04,
nip44,
nip59,
people,
pubkey,
publish,
session,
@ -662,13 +661,13 @@ export const deleteEventByAddress = address =>
// Profile
export const publishProfile = profile =>
export const publishProfile = (profile, {forcePlatform = false} = {}) =>
createAndPublish({
kind: 0,
tags: getClientTags(),
content: JSON.stringify(profile),
relays: withIndexers(hints.WriteRelays().getUrls()),
forcePlatform: false,
forcePlatform,
})
// Singletons
@ -910,7 +909,6 @@ export const markChannelRead = (pubkey: string) => {
const addSession = (s: Session) => {
sessions.update(assoc(s.pubkey, s))
people.key(s.pubkey).update($p => ({...$p, pubkey: s.pubkey}))
pubkey.set(s.pubkey)
}

View File

@ -73,12 +73,6 @@ export type Zapper = WelshmanZapper & {
pubkey: string
}
export type Person = {
pubkey: string
communities_updated_at?: number
communities?: string[][]
}
export type PublishInfo = Omit<Publish, "emitter" | "result">
export type Notification = {

View File

@ -11,6 +11,7 @@ import {
MUTES,
FOLLOWS,
RELAYS,
COMMUNITIES,
} from "@welshman/util"
import {tryJson} from "src/util/misc"
import {appDataKeys, giftWrapKinds, getPublicKey} from "src/util/nostr"
@ -33,11 +34,9 @@ import {
groups,
load,
nip04,
people,
projections,
sessions,
hints,
ensureMessagePlaintext,
ensurePlaintext,
} from "src/engine/state"
import {
@ -197,9 +196,9 @@ projections.addHandler(27, (e: TrustedEvent) => {
}
})
// Membership access/exit requests
// Membership
projections.addHandler(10004, (e: TrustedEvent) => {
projections.addHandler(COMMUNITIES, (e: TrustedEvent) => {
let session = getSession(e.pubkey)
if (!session) {
@ -250,12 +249,6 @@ projections.addHandler(0, e => {
})
})
projections.addHandler(10004, e => {
updateStore(people.key(e.pubkey), e.created_at, {
communities: Tags.fromEvent(e).whereKey("a").unwrap(),
})
})
// Relays
projections.addHandler(RELAYS, (e: TrustedEvent) => {
@ -377,7 +370,6 @@ projections.addHandler(14, handleChannelMessage)
// Decrypt encrypted events eagerly
projections.addHandler(4, ensureMessagePlaintext)
projections.addHandler(FOLLOWS, ensurePlaintext)
projections.addHandler(MUTES, ensurePlaintext)

View File

@ -52,7 +52,6 @@ import {
loadOne,
maxWot,
getNetwork,
people,
primeWotCaches,
pubkey,
publish,
@ -318,11 +317,11 @@ export const feedLoader = new FeedLoader<TrustedEvent>({
primeWotCaches($pubkey)
for (const person of people.get()) {
const score = getWotScore($pubkey, person.pubkey)
for (const tpk of repository.eventsByAuthor.keys()) {
const score = getWotScore($pubkey, tpk)
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(person.pubkey)
pubkeys.push(tpk)
}
}
@ -538,7 +537,7 @@ export const loadHandlers = () =>
load({
skipCache: true,
forcePlatform: false,
relays: hints.ReadRelays().getUrls(),
relays: hints.ReadRelays().getUrls().concat("wss://relay.nostr.band/"),
filters: [
addSinceToFilter({
kinds: [HANDLER_RECOMMENDATION],

View File

@ -6,7 +6,9 @@ import {
PROFILE,
HANDLER_INFORMATION,
NAMED_BOOKMARKS,
COMMUNITIES,
FEED,
MUTES,
FOLLOWS,
APP_DATA,
} from "@welshman/util"
@ -50,10 +52,10 @@ const getFiltersForKey = (key: string, authors: string[]) => {
case "pubkey/relays":
return [{authors, kinds: [RELAYS]}]
case "pubkey/profile":
return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION]}]
return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION, COMMUNITIES]}]
case "pubkey/user":
return [
{authors, kinds: [PROFILE, RELAYS, FOLLOWS, APP_DATA]},
{authors, kinds: [PROFILE, RELAYS, MUTES, FOLLOWS, COMMUNITIES, APP_DATA]},
{authors, kinds: [APP_DATA], "#d": Object.values(appDataKeys)},
]
}

View File

@ -35,13 +35,16 @@ import {
uniq,
uniqBy,
now,
intersection,
sort,
groupBy,
indexBy,
pushToMapKey,
} from "@welshman/lib"
import {
WRAP,
WRAP_NIP04,
COMMUNITIES,
READ_RECEIPT,
NAMED_BOOKMARKS,
HANDLER_RECOMMENDATION,
@ -59,6 +62,7 @@ import {
getFilterId,
isContextAddress,
unionFilters,
getAddress,
getIdAndAddress,
getIdOrAddress,
getIdFilters,
@ -134,7 +138,6 @@ import type {
GroupKey,
GroupRequest,
GroupStatus,
Person,
PublishInfo,
Session,
Topic,
@ -182,7 +185,6 @@ export const groupAdminKeys = new CollectionStore<GroupKey>("pubkey")
export const groupSharedKeys = new CollectionStore<GroupKey>("pubkey")
export const groupRequests = new CollectionStore<GroupRequest>("id")
export const groupAlerts = new CollectionStore<GroupAlert>("id")
export const people = new CollectionStore<Person>("pubkey")
export const publishes = new CollectionStore<PublishInfo>("id", 1000)
export const topics = new CollectionStore<Topic>("name")
export const channels = new CollectionStore<Channel>("id")
@ -296,6 +298,10 @@ export const ensureMessagePlaintext = async (e: TrustedEvent) => {
return getPlaintext(e)
}
export const canUnwrap = (event: TrustedEvent) =>
isGiftWrap(event) &&
(getSession(Tags.fromEvent(event).get("p")?.value()) || getRecipientKey(event))
export const ensureUnwrapped = async (event: TrustedEvent) => {
if (!isGiftWrap(event)) {
return event
@ -669,6 +675,48 @@ export const unreadChannels = channels.derived(filter(channelHasNewMessages))
export const hasNewMessages = unreadChannels.derived(any((c: Channel) => Boolean(c.last_sent)))
// Communities
export const communityLists = withGetter(
deriveEventsMapped<PublishedSingleton>({
filters: [{kinds: [COMMUNITIES]}],
itemToEvent: prop("event"),
eventToItem: event =>
readSingleton(
asDecryptedEvent(event, {
content: getPlaintext(event),
}),
),
}),
)
export const communityListsByPubkey = withGetter(
derived(communityLists, $ls => indexBy($l => $l.event.pubkey, $ls)),
)
export const communityListsByAddress = derived(communityLists, $communityLists => {
const m = new Map<string, PublishedSingleton[]>()
for (const list of $communityLists) {
for (const a of getSingletonValues("a", list)) {
pushToMapKey(m, a, list)
}
}
return m
})
export const getCommunityList = (pk: string) =>
communityListsByPubkey.get().get(pk) as PublishedSingleton | undefined
export const deriveCommunityList = (pk: string) =>
derived(communityListsByPubkey, m => m.get(pk) as PublishedSingleton | undefined)
export const getCommunities = (pk: string) => getSingletonValues("a", getCommunityList(pk))
export const deriveCommunities = (pk: string) =>
derived(communityListsByPubkey, m => getSingletonValues("a", m.get(pk)))
// Groups
export const deriveGroup = address => {
@ -677,41 +725,39 @@ export const deriveGroup = address => {
return groups.key(address).derived(defaultTo({id, pubkey, address}))
}
export const getWotGroupMembers = withGetter(
derived(
[userFollows, people.mapStore],
([$userFollows, $people]) =>
address =>
Array.from($userFollows).filter(pk =>
$people.get(pk)?.communities?.some(t => t[1] === address),
),
),
)
export const searchGroups = derived(
[groups.throttle(300), communityListsByAddress, userFollows],
([$groups, $communityListsByAddress, $userFollows]) => {
const options = $groups
.filter(group => !repository.deletes.has(group.address))
.map(group => {
const lists = $communityListsByAddress.get(group.address) || []
const members = lists.map(l => l.event.pubkey)
const followedMembers = intersection(members, $userFollows)
export const searchGroups = groups.throttle(300).derived($groups => {
const options = $groups
.filter(group => !repository.deletes.has(group.address))
.map(group => ({group, score: getWotGroupMembers.get()(group.address).length}))
return {group, score: followedMembers.length}
})
const fuse = new Fuse(options, {
keys: [{name: "group.id", weight: 0.2}, "group.meta.name", "group.meta.about"],
threshold: 0.3,
shouldSort: false,
includeScore: true,
})
const fuse = new Fuse(options, {
keys: [{name: "group.id", weight: 0.2}, "group.meta.name", "group.meta.about"],
threshold: 0.3,
shouldSort: false,
includeScore: true,
})
return (term: string) => {
if (!term) {
return sortBy(item => -item.score, options).map(item => item.group)
return (term: string) => {
if (!term) {
return sortBy(item => -item.score, options).map(item => item.group)
}
return doPipe(fuse.search(term), [
$results =>
sortBy((r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100), $results),
$results => $results.map((r: any) => r.item.group),
])
}
return doPipe(fuse.search(term), [
$results =>
sortBy((r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100), $results),
$results => $results.map((r: any) => r.item.group),
])
}
})
},
)
export const getRecipientKey = wrap => {
const pubkey = Tags.fromEvent(wrap).values("p").first()
@ -1258,7 +1304,14 @@ export const recommendationsByHandlerAddress = derived(recommendations, $events
)
export const deriveHandlersForKind = simpleCache(([kind]: [number]) =>
derived(handlers, $handlers => $handlers.filter(h => h.kind === kind)),
derived([handlers, recommendationsByHandlerAddress], ([$handlers, $recs]) =>
sortBy(
h => -h.recommendations.length,
$handlers
.filter(h => h.kind === kind)
.map(h => ({...h, recommendations: $recs.get(getAddress(h.event)) || []})),
),
),
)
// Collections
@ -1426,7 +1479,6 @@ export const onAuth = async (url, challenge) => {
seenChallenges.add(challenge)
console.log(url, challenge)
const event = await signer.get().signAsUser(
createEvent(22242, {
tags: [
@ -1559,10 +1611,14 @@ export const publish = ({forcePlatform = true, ...request}: MyPublishRequest) =>
const pub = basePublish(request)
// Add the event to projections
ensureUnwrapped(request.event).then(projections.push)
if (canUnwrap(request.event)) {
ensureUnwrapped(request.event).then(projections.push)
} else {
projections.push(request.event)
}
// Listen to updates and update our publish queue
if (isGiftWrap(request.event) || request.event.pubkey === pubkey.get()) {
if (canUnwrap(request.event) || request.event.pubkey === pubkey.get()) {
const pubInfo = omit(["emitter", "result"], pub)
pub.emitter.on("*", t => publishes.key(pubInfo.id).set(pubInfo))
@ -1571,7 +1627,7 @@ export const publish = ({forcePlatform = true, ...request}: MyPublishRequest) =>
return pub
}
export const sign = (template, opts: {anonymous?: boolean; sk?: string}) => {
export const sign = (template, opts: {anonymous?: boolean; sk?: string} = {}) => {
if (opts.anonymous) {
return signer.get().signWithKey(template, generatePrivateKey())
}
@ -2006,9 +2062,7 @@ class Storage {
},
})
await Promise.all(
this.adapters.map(adapter => adapter.initialize(this))
)
await Promise.all(this.adapters.map(adapter => adapter.initialize(this)))
}
this.ready.resolve()
@ -2070,10 +2124,6 @@ export const storage = new Storage(15, [
objectAdapter("plaintext", "key", plaintext, {limit: 100000}),
collectionAdapter("publishes", "id", publishes, {sort: sortBy(prop("created_at"))}),
collectionAdapter("topics", "name", topics, {limit: 1000, sort: sortBy(prop("last_seen"))}),
collectionAdapter("people", "pubkey", people, {
limit: 100000,
sort: sortBy(prop("last_fetched")),
}),
collectionAdapter("relays", "url", relays, {limit: 1000, sort: sortBy(prop("count"))}),
collectionAdapter("channels", "id", channels, {limit: 1000, sort: sortBy(prop("last_checked"))}),
collectionAdapter("groups", "address", groups, {limit: 1000, sort: sortBy(prop("count"))}),

View File

@ -1,7 +1,6 @@
import {switcherFn} from "hurdak"
import type {EventTemplate, UnsignedEvent} from "nostr-tools"
import {getEventHash} from "nostr-tools"
import type {TrustedEvent} from "@welshman/util"
import type {EventTemplate, OwnedEvent, HashedEvent} from "@welshman/util"
import {getPublicKey, getSignature} from "src/util/nostr"
import type {Session} from "src/engine/model"
import type {Connect} from "./connect"
@ -20,10 +19,10 @@ export class Signer {
prepWithKey(event: EventTemplate, sk: string) {
// Copy the event since we're mutating it
event = {...event}
;(event as UnsignedEvent).pubkey = getPublicKey(sk)
;(event as TrustedEvent).id = getEventHash(event as UnsignedEvent)
;(event as OwnedEvent).pubkey = getPublicKey(sk)
;(event as HashedEvent).id = getEventHash(event as OwnedEvent)
return event as TrustedEvent
return event as HashedEvent
}
prepAsUser(event: EventTemplate) {
@ -31,10 +30,10 @@ export class Signer {
// Copy the event since we're mutating it
event = {...event}
;(event as UnsignedEvent).pubkey = pubkey
;(event as TrustedEvent).id = getEventHash(event as UnsignedEvent)
;(event as OwnedEvent).pubkey = pubkey
;(event as HashedEvent).id = getEventHash(event as OwnedEvent)
return event as TrustedEvent
return event as HashedEvent
}
signWithKey(template: EventTemplate, sk: string) {

View File

@ -7,7 +7,8 @@
<div class="text-sm">
{#each items as item, i}
<Chip pad onRemove={() => remove?.(i)}>
{@const onRemove = remove ? () => remove(i) : null}
<Chip pad {onRemove}>
<slot name="item" context="value" {item}>
{item}
</slot>

View File

@ -90,7 +90,7 @@
</div>
<div
style={$$props.style || "min-height: 6rem"}
class={cx($$props.class, "w-full min-w-0 outline-0")}
class={cx($$props.class, "w-full min-w-0 whitespace-pre-line outline-0")}
{autofocus}
contenteditable
bind:this={input}

View File

@ -3,12 +3,14 @@
import Anchor from "src/partials/Anchor.svelte"
export let inert = false
export let active = false
</script>
<Anchor
{...$$props}
class={cx($$props.class, "block p-3 px-4", {
"transition-all hover:bg-accent hover:text-white": !inert,
"bg-accent text-neutral-100": active,
"transition-all hover:bg-accent hover:text-neutral-100": !inert,
})}
on:click>
<slot />

View File

@ -0,0 +1,41 @@
<style>
.wot-background {
fill: transparent;
stroke: var(--neutral-600);
}
.wot-highlight {
fill: transparent;
stroke-width: 1.5;
stroke-dasharray: 100 100;
transform-origin: center;
}
</style>
<script lang="ts">
import {themeColors} from "src/partials/state"
export let score
export let max = 100
export let accent = false
$: superMaxWot = max * 1.5
$: dashOffset = 100 - (Math.max(superMaxWot / 20, score) / superMaxWot) * 100
$: style = `transform: rotate(${dashOffset * 1.8 - 50}deg)`
$: stroke = $themeColors[accent ? 'accent' : 'neutral-200']
</script>
<span class="relative flex h-10 w-10 items-center justify-center whitespace-nowrap px-4 text-xs">
<svg height="32" width="32" class="absolute">
<circle class="wot-background" cx="16" cy="16" r="15" />
<circle
cx="16"
cy="16"
r="15"
class="wot-highlight"
stroke-dashoffset={dashOffset}
{style}
{stroke} />
</svg>
{score}
</span>

View File

@ -1,9 +1,14 @@
import Bowser from "bowser"
import {fromPairs} from "ramda"
import {derived} from "svelte/store"
import {writable} from "@welshman/lib"
import {parseHex} from "src/util/html"
import {synced} from "src/util/misc"
// Browser
export const browser = Bowser.parse(window.navigator.userAgent)
// Settings
export const appName = import.meta.env.VITE_APP_NAME

View File

@ -157,6 +157,13 @@ export const parseAnythingSync = entity => {
}
}
export const parsePubkey = async entity => {
const result = await parseAnything(entity)
if (result.type === 'npub') return result.data
if (result.type === 'nprofile') return result.data.pubkey
}
export const getTags =
(types: string[], testValue = null) =>
(tags: string[][]) =>

View File

@ -1,24 +1,24 @@
import fs from 'fs'
import dotenv from 'dotenv'
import fs from "fs"
import dotenv from "dotenv"
import * as path from "path"
import {defineConfig} from "vite"
import {VitePWA} from "vite-plugin-pwa"
import mkcert from "vite-plugin-mkcert"
import {favicons} from 'favicons'
import {favicons} from "favicons"
import htmlPlugin from "vite-plugin-html-config"
import sveltePreprocess from "svelte-preprocess"
import {svelte} from "@sveltejs/vite-plugin-svelte"
import {nodePolyfills} from "vite-plugin-node-polyfills"
dotenv.config({path: '.env.local'})
dotenv.config({path: '.env'})
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"})
const accentColor = process.env.VITE_LIGHT_THEME.match(/accent:(#\w+)/)[1]
export default defineConfig(async () => {
const icons = await favicons('public' + process.env.VITE_APP_LOGO)
const icons = await favicons("public" + process.env.VITE_APP_LOGO)
if (!fs.existsSync('public/icons')) fs.mkdirSync('public/icons')
if (!fs.existsSync("public/icons")) fs.mkdirSync("public/icons")
for (const {name, contents} of icons.images) {
fs.writeFileSync(`public/icons/${name}`, contents, "binary")
@ -72,19 +72,49 @@ export default defineConfig(async () => {
{rel: "apple-touch-icon", sizes: "144x144", href: "/icons/apple-touch-icon-144x144.png"},
{rel: "apple-touch-icon", sizes: "152x152", href: "/icons/apple-touch-icon-152x152.png"},
{rel: "apple-touch-icon", sizes: "180x180", href: "/icons/apple-touch-icon-180x180.png"},
{rel: "icon", type: "image/png", sizes: "192x192", href: "/icons/android-icon-192x192.png"},
{
rel: "icon",
type: "image/png",
sizes: "192x192",
href: "/icons/android-icon-192x192.png",
},
{rel: "icon", type: "image/png", sizes: "32x32", href: "/icons/favicon-32x32.png"},
{rel: "icon", type: "image/png", sizes: "96x96", href: "/icons/favicon-96x96.png"},
{rel: "icon", type: "image/png", sizes: "16x16", href: "/icons/favicon-16x16.png"},
{rel: "mask-icon", href: "/images/logo.svg", color: "#FFFFFF"},
{rel: "icon", type: "image/png", sizes: "144x144", href: "/icons/android-chrome-144x144.png"},
{rel: "icon", type: "image/png", sizes: "192x192", href: "/icons/android-chrome-192x192.png"},
{rel: "icon", type: "image/png", sizes: "256x256", href: "/icons/android-chrome-256x256.png"},
{
rel: "icon",
type: "image/png",
sizes: "144x144",
href: "/icons/android-chrome-144x144.png",
},
{
rel: "icon",
type: "image/png",
sizes: "192x192",
href: "/icons/android-chrome-192x192.png",
},
{
rel: "icon",
type: "image/png",
sizes: "256x256",
href: "/icons/android-chrome-256x256.png",
},
{rel: "icon", type: "image/png", sizes: "36x36", href: "/icons/android-chrome-36x36.png"},
{rel: "icon", type: "image/png", sizes: "384x384", href: "/icons/android-chrome-384x384.png"},
{
rel: "icon",
type: "image/png",
sizes: "384x384",
href: "/icons/android-chrome-384x384.png",
},
{rel: "icon", type: "image/png", sizes: "48x48", href: "/icons/android-chrome-48x48.png"},
{rel: "icon", type: "image/png", sizes: "512x512", href: "/icons/android-chrome-512x512.png"},
{
rel: "icon",
type: "image/png",
sizes: "512x512",
href: "/icons/android-chrome-512x512.png",
},
{rel: "icon", type: "image/png", sizes: "72x72", href: "/icons/android-chrome-72x72.png"},
{rel: "icon", type: "image/png", sizes: "96x96", href: "/icons/android-chrome-96x96.png"},
{rel: "apple-touch-icon", sizes: "1024x1024", href: "apple-touch-icon-1024x1024.png"},
@ -109,11 +139,17 @@ export default defineConfig(async () => {
description: process.env.VITE_APP_DESCRIPTION,
theme_color: accentColor,
protocol_handlers: [{protocol: "web+nostr", url: "/%s"}],
permissions: ["clipboardRead", "clipboardWrite", "unlimitedStorage"],
icons: [
{src: "images/pwa-64x64.png", sizes: "64x64", type: "image/png"},
{src: "images/pwa-192x192.png", sizes: "192x192", type: "image/png"},
{src: "images/pwa-512x512.png", sizes: "512x512", type: "image/png", purpose: "any"},
{src: "images/maskable-icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable"},
{
src: "images/maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
}),