diff --git a/.agents/settings.json b/.agents/settings.json deleted file mode 100644 index b7cc7ae5..00000000 --- a/.agents/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "statusMessage": "Checking added comments", - "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && git diff HEAD -U0 -- src | grep -E '^\\+' | grep -v '^+++' | grep -E '//|/\\*|\\*/| + + {/if} + {#if pin.topics().length > 0} +
+ {#each pin.topics() as topic (topic)} + #{topic} + {/each} +
+ {/if} + +{/await} diff --git a/src/app/components/NoteContentThread.svelte b/src/app/components/NoteContentThread.svelte index ca6ec43b..939c5390 100644 --- a/src/app/components/NoteContentThread.svelte +++ b/src/app/components/NoteContentThread.svelte @@ -1,11 +1,11 @@
diff --git a/src/app/components/NoteItem.svelte b/src/app/components/NoteItem.svelte index e98c2c4d..dc80d5d3 100644 --- a/src/app/components/NoteItem.svelte +++ b/src/app/components/NoteItem.svelte @@ -2,16 +2,15 @@ import type {Snippet} from "svelte" import type {NativeEmoji} from "emoji-picker-element/shared" import type {TrustedEvent, EventContent} from "@welshman/util" - import {Router} from "@welshman/router" + import {seen} from "@welshman/util" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte" import NoteContent from "@app/components/NoteContent.svelte" + import {publishReaction, retractReaction} from "@app/reactions" import NoteCard from "@app/components/NoteCard.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte" - import {publishDelete} from "@app/deletes" - import {publishReaction} from "@app/reactions" - import {canEnforceNip70} from "@app/relays" + import {router} from "@app/core" type Props = { event: TrustedEvent @@ -21,23 +20,15 @@ const {url, event, children}: Props = $props() - const relays = url ? [url] : Router.get().Event(event).getUrls() + const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)])) - const shouldProtect = url ? canEnforceNip70(url) : false + const deleteReaction = async (reaction: TrustedEvent) => + retractReaction(reaction, {url, urls: await getRelays()}) - const deleteReaction = async (event: TrustedEvent) => - publishDelete({relays, event, protect: await shouldProtect}) + const createReaction = async (values: EventContent) => + publishReaction(event, values, {url, urls: await getRelays()}) - const createReaction = async (template: EventContent) => - publishReaction({...template, event, relays, protect: await shouldProtect}) - - const onEmoji = async (emoji: NativeEmoji) => - publishReaction({ - event, - relays, - content: emoji.unicode, - protect: await shouldProtect, - }) + const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []}) diff --git a/src/app/components/PasswordReset.svelte b/src/app/components/PasswordReset.svelte index 3f06e3b3..1224febc 100644 --- a/src/app/components/PasswordReset.svelte +++ b/src/app/components/PasswordReset.svelte @@ -1,7 +1,5 @@ diff --git a/src/app/components/PinEdit.svelte b/src/app/components/PinEdit.svelte index a3c17e5f..5822de3d 100644 --- a/src/app/components/PinEdit.svelte +++ b/src/app/components/PinEdit.svelte @@ -1,28 +1,30 @@ @@ -32,9 +34,9 @@ action="Save changes" successMessage="Link updated!" values={{ - title: pin.title, - topics: pin.topics, + title: pin.title(), + topics: pin.topics(), value: pinToReference(pin), - content: pin.description, + content: pin.content(), }} {submit} /> diff --git a/src/app/components/PinItem.svelte b/src/app/components/PinItem.svelte index cce58999..87e575c0 100644 --- a/src/app/components/PinItem.svelte +++ b/src/app/components/PinItem.svelte @@ -2,16 +2,17 @@ import * as nip19 from "nostr-tools/nip19" import {removeUndefined} from "@welshman/lib" import {Address} from "@welshman/util" + import type {PinReader} from "@welshman/domain" import Badge from "@lib/components/Badge.svelte" import MenuButton from "@lib/components/MenuButton.svelte" import Content from "@app/components/Content.svelte" import PinContentEvent from "@app/components/PinContentEvent.svelte" import PinMenu from "@app/components/PinMenu.svelte" - import {type PublishedPin} from "@app/pinboards" + import {pinToReference} from "@app/pinboards" type Props = { url: string - pin: PublishedPin + pin: PinReader minimal?: boolean class?: string } @@ -20,20 +21,20 @@ // Encode nostr references as nostr: entities (i tags are already urls) so // Content parses them into rich embeds rather than rendering raw ids/coords. + const reference = $derived(pin.reference()) + const content = $derived.by(() => { - const [type, data = ""] = pin.value + if (reference?.type === "event") { + return "nostr:" + nip19.neventEncode({id: reference.id, relays: [url]}) + } - if (type === "e") return "nostr:" + nip19.neventEncode({id: data, relays: [url]}) - - if (type === "p") return "nostr:" + nip19.nprofileEncode({pubkey: data, relays: [url]}) - - if (type === "a") { - const {kind, pubkey, identifier} = Address.from(data) + if (reference?.type === "address") { + const {kind, pubkey, identifier} = Address.from(reference.address) return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]}) } - return data + return pinToReference(pin) }) @@ -48,20 +49,23 @@ componentProps={{url, pin}} />
{/if} - {#if pin.title} - {pin.title} + {#if pin.title()} + {pin.title()} {/if} - {#if pin.description} - + {#if pin.content()} + {/if} - {#if pin.value[0] === "e" || pin.value[0] === "a"} - + {#if reference?.type === "event" || reference?.type === "address"} + {:else} {/if} - {#if !minimal && pin.topics.length > 0} + {#if !minimal && pin.topics().length > 0}
- {#each pin.topics as topic (topic)} + {#each pin.topics() as topic (topic)} #{topic} {/each}
diff --git a/src/app/components/PinMenu.svelte b/src/app/components/PinMenu.svelte index 9798a867..bfa69ea3 100644 --- a/src/app/components/PinMenu.svelte +++ b/src/app/components/PinMenu.svelte @@ -1,30 +1,31 @@ - {isNew ? "Create Shelf" : "Edit Shelf"} + {board ? "Edit Shelf" : "Create Shelf"} on diff --git a/src/app/components/PinboardItem.svelte b/src/app/components/PinboardItem.svelte index 0f1052b9..aa84961d 100644 --- a/src/app/components/PinboardItem.svelte +++ b/src/app/components/PinboardItem.svelte @@ -1,22 +1,24 @@
@@ -29,12 +31,12 @@ componentProps={{url, board}} />
- {board.title || "Untitled shelf"} - {#if board.description} - {board.description} + {board.title() || "Untitled shelf"} + {#if board.description()} + {board.description()} {/if}
- {#each $pins.slice(0, 1) as pin (pin.id)} + {#each $pins.slice(0, 1) as pin (pin.id())} {/each} diff --git a/src/app/components/PinboardSelect.svelte b/src/app/components/PinboardSelect.svelte index d13e6ea2..90ed42d9 100644 --- a/src/app/components/PinboardSelect.svelte +++ b/src/app/components/PinboardSelect.svelte @@ -1,5 +1,7 @@ @@ -43,14 +49,14 @@ {:else} - {#each $boards as board (board.address)} + {#each $boards as board (board.address())} {/if} - {#if spaceUrls.length > 0} + {#if $spaceUrls.length > 0} {/if} diff --git a/src/app/components/ProfileCircle.svelte b/src/app/components/ProfileCircle.svelte index d1f7f56f..94377701 100644 --- a/src/app/components/ProfileCircle.svelte +++ b/src/app/components/ProfileCircle.svelte @@ -1,9 +1,9 @@ + src={$profile?.picture() || UserRounded} /> diff --git a/src/app/components/ProfileCircles.svelte b/src/app/components/ProfileCircles.svelte index bab5c875..49a5799b 100644 --- a/src/app/components/ProfileCircles.svelte +++ b/src/app/components/ProfileCircles.svelte @@ -1,8 +1,8 @@ diff --git a/src/app/components/ProfileEdit.svelte b/src/app/components/ProfileEdit.svelte index 07c23819..bfceff9d 100644 --- a/src/app/components/ProfileEdit.svelte +++ b/src/app/components/ProfileEdit.svelte @@ -1,27 +1,25 @@ + @@ -98,9 +104,9 @@ {/snippet} {#snippet input()}
- {#each seenOn as url, i (url)} + {#each seenOn as seenUrl (seenUrl)} - {displayRelayUrl(url)} + {displayRelayUrl(seenUrl)} {/each}
diff --git a/src/app/components/ProfileLatest.svelte b/src/app/components/ProfileLatest.svelte index 94c568df..0e42c9f6 100644 --- a/src/app/components/ProfileLatest.svelte +++ b/src/app/components/ProfileLatest.svelte @@ -1,9 +1,9 @@ @@ -23,7 +23,7 @@ - {restProps.title} + {title} {subtitle} {#each pubkeys as pubkey (pubkey)} diff --git a/src/app/components/ProfileMenuList.svelte b/src/app/components/ProfileMenuList.svelte index 11f0cf97..a099281f 100644 --- a/src/app/components/ProfileMenuList.svelte +++ b/src/app/components/ProfileMenuList.svelte @@ -1,13 +1,13 @@ {$profileDisplay} diff --git a/src/app/components/ProfilePage.svelte b/src/app/components/ProfilePage.svelte index 2b308846..ab0fcfa1 100644 --- a/src/app/components/ProfilePage.svelte +++ b/src/app/components/ProfilePage.svelte @@ -1,17 +1,7 @@ diff --git a/src/app/components/ProfileSharedSpaces.svelte b/src/app/components/ProfileSharedSpaces.svelte index d6e29bb0..5b5ce938 100644 --- a/src/app/components/ProfileSharedSpaces.svelte +++ b/src/app/components/ProfileSharedSpaces.svelte @@ -6,12 +6,8 @@ import Link from "@lib/components/Link.svelte" import RelayIcon from "@app/components/RelayIcon.svelte" import RelayName from "@app/components/RelayName.svelte" - import { - groupListPubkeysByUrl, - userSpaceUrls, - deriveGroupList, - getSpaceUrlsFromGroupList, - } from "@app/groups" + import {userSpaceUrls} from "@app/rooms" + import {roomLists} from "@app/core" import {makeSpacePath} from "@app/routes" type Props = { @@ -22,8 +18,8 @@ const {pubkey, isSelf = false, ...props}: Props = $props() - const groupList = deriveGroupList(pubkey) - const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList)) + const roomList = $roomLists.one(pubkey) + const spaceUrls = $derived($roomList?.urls() ?? [])
@@ -37,7 +33,7 @@ {#if spaceUrls.length > 0}
{#each spaceUrls as url (url)} - {@const count = $groupListPubkeysByUrl.get(url)?.size || 0} + {@const count = $roomLists.pubkeysForUrl(url).get().length} {@const isMember = !isSelf && $userSpaceUrls.includes(url)} history.back()
- {#each spaceUrls as url (url)} + {#each $spaceUrls as url (url)}
diff --git a/src/app/components/ProfileTrust.svelte b/src/app/components/ProfileTrust.svelte index ac3cb6df..e5506ada 100644 --- a/src/app/components/ProfileTrust.svelte +++ b/src/app/components/ProfileTrust.svelte @@ -1,19 +1,10 @@
@@ -107,13 +77,13 @@ {/if}

{trustMessage}

- {#if followsWhoFollow.length > 0} + {#if $followsWhoFollow.length > 0}

People who follow {$profileDisplay}

- +

- {followsWhoFollow.length} - {followsWhoFollow.length === 1 ? "person" : "people"} you follow also follow + {$followsWhoFollow.length} + {$followsWhoFollow.length === 1 ? "person" : "people"} you follow also follow {$profileDisplay}.

diff --git a/src/app/components/Reaction.svelte b/src/app/components/Reaction.svelte index 55b9d522..f57b10c6 100644 --- a/src/app/components/Reaction.svelte +++ b/src/app/components/Reaction.svelte @@ -1,11 +1,16 @@ {#if event.content === "+" || event.content === ""} diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 484caec9..86090edd 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -1,34 +1,47 @@ diff --git a/src/app/components/RelayAdd.svelte b/src/app/components/RelayAdd.svelte index 02b0a3af..26a47714 100644 --- a/src/app/components/RelayAdd.svelte +++ b/src/app/components/RelayAdd.svelte @@ -5,7 +5,7 @@ import {tryCatch} from "@welshman/lib" import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util" import type {Thunk} from "@welshman/app" - import {waitForThunkError, relaySearch} from "@welshman/app" + import {Relays} from "@welshman/app" import {createScroller} from "@lib/html" import {errorMessage} from "@lib/util" import Magnifier from "@assets/icons/magnifier.svg?dataurl" @@ -18,6 +18,7 @@ import ModalFooter from "@lib/components/ModalFooter.svelte" import RelayItem from "@app/components/RelayItem.svelte" import {pushToast} from "@app/toast" + import {app} from "@app/core" interface Props { relays: Readable @@ -35,7 +36,8 @@ loading.add(url) try { - const error = await waitForThunkError(await addRelay(url)) + const thunk = await addRelay(url) + const error = await thunk.waitForError() if (error) { pushToast({ @@ -53,11 +55,12 @@ let element: Element | undefined = $state() const loading = $state(new SvelteSet()) + const relaySearch = $app.use(Relays).relaySearch const searchResults = $derived( $relaySearch .searchValues(term) - .filter(url => { + .filter((url: string) => { if (matchRelay?.(url) === false) return false if ($relays.includes(url)) return false if (isIPAddress(url)) return false diff --git a/src/app/components/RelayDescription.svelte b/src/app/components/RelayDescription.svelte index 82d4789a..1639f8a2 100644 --- a/src/app/components/RelayDescription.svelte +++ b/src/app/components/RelayDescription.svelte @@ -1,6 +1,6 @@ {#if $relay?.description} diff --git a/src/app/components/RelayIcon.svelte b/src/app/components/RelayIcon.svelte index 630921cf..fcdefced 100644 --- a/src/app/components/RelayIcon.svelte +++ b/src/app/components/RelayIcon.svelte @@ -1,7 +1,7 @@ {#if $relay?.icon} diff --git a/src/app/components/RelayItem.svelte b/src/app/components/RelayItem.svelte index 4d8ecc95..c1692f42 100644 --- a/src/app/components/RelayItem.svelte +++ b/src/app/components/RelayItem.svelte @@ -1,17 +1,18 @@ diff --git a/src/app/components/RelayList.svelte b/src/app/components/RelayList.svelte index b1d90b3a..59e7dfc6 100644 --- a/src/app/components/RelayList.svelte +++ b/src/app/components/RelayList.svelte @@ -1,7 +1,6 @@ diff --git a/src/app/components/RelaySettingsHealthCheck.svelte b/src/app/components/RelaySettingsHealthCheck.svelte index f80b8d4b..627e03b3 100644 --- a/src/app/components/RelaySettingsHealthCheck.svelte +++ b/src/app/components/RelaySettingsHealthCheck.svelte @@ -4,7 +4,7 @@ import Button from "@lib/components/Button.svelte" import Card from "@lib/components/Card.svelte" import type {HealthCheck} from "@app/healthChecks" - import {applyHealthCheck} from "@app/healthChecks" + import {healthChecks} from "@app/healthChecks" type Props = { healthCheck: HealthCheck @@ -12,7 +12,7 @@ const {healthCheck}: Props = $props() - const apply = () => applyHealthCheck(healthCheck) + const apply = () => $healthChecks.apply(healthCheck) diff --git a/src/app/components/RelaySettingsHealthChecks.svelte b/src/app/components/RelaySettingsHealthChecks.svelte index 912ba9cf..5e2e2e3c 100644 --- a/src/app/components/RelaySettingsHealthChecks.svelte +++ b/src/app/components/RelaySettingsHealthChecks.svelte @@ -7,11 +7,13 @@ import Button from "@lib/components/Button.svelte" import RelaySettingsHealthCheck from "@app/components/RelaySettingsHealthCheck.svelte" import {PLATFORM_NAME} from "@app/env" - import {pendingHealthChecks, applyHealthCheck} from "@app/healthChecks" + import {healthChecks} from "@app/healthChecks" + + const pending = $healthChecks.pending.$ const applyAll = () => { - for (const healthCheck of $pendingHealthChecks) { - applyHealthCheck(healthCheck) + for (const healthCheck of $pending) { + $healthChecks.apply(healthCheck) } } @@ -23,18 +25,18 @@ Health Check - - {$pendingHealthChecks.length} Issue{$pendingHealthChecks.length === 1 ? "" : "s"} Detected + + {$pending.length} Issue{$pending.length === 1 ? "" : "s"} Detected

{PLATFORM_NAME} actively checks your connection to the network in the background to discover relays that are offline, that you don't have access to, or are otherwise causing trouble.

- {#each $pendingHealthChecks as healthCheck} + {#each $pending as healthCheck} {/each} - {#if $pendingHealthChecks.length > 0} + {#if $pending.length > 0} -{:else if $room.isPrivate} +{:else if $room?.meta?.isPrivate()} -{:else if $room.isRestricted} +{:else if $room?.meta?.isRestricted()} {/if} - {#if event.pubkey === $pubkey} + {#if event.pubkey === $user.pubkey}
  • diff --git a/src/app/components/SpaceInvite.svelte b/src/app/components/SpaceInvite.svelte index f69b6bb1..c8d6c85b 100644 --- a/src/app/components/SpaceInvite.svelte +++ b/src/app/components/SpaceInvite.svelte @@ -1,7 +1,7 @@ @@ -32,7 +31,7 @@
    @@ -40,7 +39,7 @@ {#if roles.length > 0}
    - {#each roles as role (role.id)} + {#each roles as role (role.identifier())} {/each}
    diff --git a/src/app/components/SpaceMemberBannedMenu.svelte b/src/app/components/SpaceMemberBannedMenu.svelte index c21f5dec..f051e514 100644 --- a/src/app/components/SpaceMemberBannedMenu.svelte +++ b/src/app/components/SpaceMemberBannedMenu.svelte @@ -1,14 +1,12 @@ @@ -82,21 +89,21 @@ Edit Member - Manage roles for @{displayProfileByPubkey(pubkey)} + Manage roles for @{$profileDisplay} - {#if $roles.length === 0} + {#if roles.length === 0}
    This space has no roles yet.
    {:else}
    - {#each $roles as role (role.id)} + {#each roles as role (role.identifier())} {/each}
    diff --git a/src/app/components/SpaceMembersBanned.svelte b/src/app/components/SpaceMembersBanned.svelte index 4cfff339..f2076f8d 100644 --- a/src/app/components/SpaceMembersBanned.svelte +++ b/src/app/components/SpaceMembersBanned.svelte @@ -1,5 +1,5 @@ diff --git a/src/app/components/SpaceMembersSummary.svelte b/src/app/components/SpaceMembersSummary.svelte index 3104f432..92eadc9b 100644 --- a/src/app/components/SpaceMembersSummary.svelte +++ b/src/app/components/SpaceMembersSummary.svelte @@ -1,13 +1,12 @@ {#if roomType === RoomType.Voice} diff --git a/src/app/components/SpaceRedirect.svelte b/src/app/components/SpaceRedirect.svelte index 827c0c70..a8141a1d 100644 --- a/src/app/components/SpaceRedirect.svelte +++ b/src/app/components/SpaceRedirect.svelte @@ -12,7 +12,7 @@ import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte" - import {moveSpace} from "@app/groups" + import {roomLists} from "@app/core" import {goToMovedSpace} from "@app/routes" type Props = { @@ -28,7 +28,7 @@ loading = true try { - await moveSpace(url, newUrl) + await $roomLists.migrateRelay(url, newUrl).then(command => command?.publish()) await goToMovedSpace(url, newUrl) } finally { loading = false diff --git a/src/app/components/SpaceRoleMenu.svelte b/src/app/components/SpaceRoleMenu.svelte index ed445b11..2932b566 100644 --- a/src/app/components/SpaceRoleMenu.svelte +++ b/src/app/components/SpaceRoleMenu.svelte @@ -1,5 +1,6 @@
    diff --git a/src/app/components/ThreadBoardItem.svelte b/src/app/components/ThreadBoardItem.svelte index 6b8e9fce..74265a5b 100644 --- a/src/app/components/ThreadBoardItem.svelte +++ b/src/app/components/ThreadBoardItem.svelte @@ -1,8 +1,8 @@ import {formatTimestamp} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {COMMENT} from "@welshman/util" - import {deriveHandleForPubkey, deriveProfileDisplay, displayHandle} from "@welshman/app" + import {COMMENT, displayHandle} from "@welshman/util" import Reply from "@assets/icons/reply-2.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl" import Icon from "@lib/components/Icon.svelte" @@ -14,6 +13,7 @@ import Content from "@app/components/Content.svelte" import CommentActions from "@app/components/CommentActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte" + import {handles, profiles} from "@app/core" import {makeEventPermalink} from "@app/routes" import {pushModal} from "@app/modal" import {clip} from "@app/toast" @@ -27,8 +27,8 @@ const {url, event, threadPubkey, onReply}: Props = $props() - const profileDisplay = deriveProfileDisplay(event.pubkey, [url]) - const handle = deriveHandleForPubkey(event.pubkey) + const profileDisplay = $profiles.display(event.pubkey, [url]).$ + const handle = $handles.forPubkey(event.pubkey).$ const isOp = event.pubkey === threadPubkey const isComment = event.kind === COMMENT diff --git a/src/app/components/ThunkFailure.svelte b/src/app/components/ThunkFailure.svelte index 2e2a744b..ee0e7cea 100644 --- a/src/app/components/ThunkFailure.svelte +++ b/src/app/components/ThunkFailure.svelte @@ -1,44 +1,42 @@ diff --git a/src/app/components/ThunkPending.svelte b/src/app/components/ThunkPending.svelte index e83d56a1..b3ae2e3a 100644 --- a/src/app/components/ThunkPending.svelte +++ b/src/app/components/ThunkPending.svelte @@ -1,19 +1,18 @@
    diff --git a/src/app/components/ThunkStatus.svelte b/src/app/components/ThunkStatus.svelte index 81c4474e..4deb2dc9 100644 --- a/src/app/components/ThunkStatus.svelte +++ b/src/app/components/ThunkStatus.svelte @@ -1,19 +1,18 @@ {#if showFailure} diff --git a/src/app/components/ThunkStatusDetail.svelte b/src/app/components/ThunkStatusDetail.svelte index b8a6dc68..25619488 100644 --- a/src/app/components/ThunkStatusDetail.svelte +++ b/src/app/components/ThunkStatusDetail.svelte @@ -1,9 +1,8 @@ {#if $deleted}
    Deleted
    -{:else if thunk.thunks.length > 0 && !thunkHasStatus(PublishStatus.Success, thunk)} +{:else if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)} {:else if children} {@render children?.()} diff --git a/src/app/components/ThunkToast.svelte b/src/app/components/ThunkToast.svelte index d8fd74d9..2af0e9ad 100644 --- a/src/app/components/ThunkToast.svelte +++ b/src/app/components/ThunkToast.svelte @@ -1,6 +1,5 @@ @@ -98,7 +98,7 @@
    - {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"} + {p.pubkey ? $profiles.display(p.pubkey).get() : "Unknown"} + import {spec} from "@welshman/lib" import {displayRelayUrl} from "@welshman/util" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import Volume from "@assets/icons/volume.svg?dataurl" @@ -13,7 +14,7 @@ import ModalTitle from "@lib/components/ModalTitle.svelte" import {AbortError, TimeoutError} from "$lib/util" import ProfileCircles from "@app/components/ProfileCircles.svelte" - import {displayRoom} from "@app/groups" + import {displayRoom} from "@app/rooms" import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call" import {popModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -37,7 +38,7 @@ if (!navigator.mediaDevices?.enumerateDevices) return try { const devices = await navigator.mediaDevices.enumerateDevices() - audioInputs = devices.filter(d => d.kind === "audioinput") + audioInputs = devices.filter(spec({kind: "audioinput"})) } catch { audioInputs = [] } diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index abb49039..88072da8 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -4,7 +4,9 @@ import {goto} from "$app/navigation" import {page} from "$app/stores" import cx from "classnames" + import {Capacitor} from "@capacitor/core" import {displayRelayUrl} from "@welshman/util" + import type {Room} from "@welshman/app" import Microphone from "@assets/icons/microphone.svg?dataurl" import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl" import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl" @@ -15,15 +17,14 @@ import ChatRound from "@assets/icons/chat-round.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl" - import {Capacitor} from "@capacitor/core" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import Spinner from "@lib/components/Spinner.svelte" import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" + import {rooms} from "@app/core" import {decodeRelay} from "@app/relays" - import {deriveRoom, displayRoom, getRoomType, RoomType} from "@app/groups" - import type {Room} from "@app/groups" + import {RoomType, displayRoom, getRoomType} from "@app/rooms" import {pushModal} from "@app/modal" import {notifications} from "@app/notifications" import {makeRoomPath} from "@app/routes" @@ -46,7 +47,7 @@ const {relay, h} = $derived($page.params) const url = $derived(relay ? decodeRelay(relay) : undefined) const displayedRoomStore = $derived( - url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined), + url && h && typeof h === "string" ? $rooms.forRoom(url, h) : readable(undefined), ) const routeDisplayedRoom = $derived($displayedRoomStore) diff --git a/src/app/components/WalletAsReceivingAddress.svelte b/src/app/components/WalletAsReceivingAddress.svelte index 0bf97548..1c4c01fe 100644 --- a/src/app/components/WalletAsReceivingAddress.svelte +++ b/src/app/components/WalletAsReceivingAddress.svelte @@ -1,7 +1,5 @@ diff --git a/src/app/components/hosting/RelayListItem.svelte b/src/app/components/hosting/RelayListItem.svelte index 83c758c5..0dc00c14 100644 --- a/src/app/components/hosting/RelayListItem.svelte +++ b/src/app/components/hosting/RelayListItem.svelte @@ -4,11 +4,11 @@ import Badge from "@lib/components/Badge.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte" import Link from "@lib/components/Link.svelte" - import {canonicalRelayHost, getHostedRelayUrl, type Relay} from "@app/hosting" + import {canonicalRelayHost, getHostedRelayUrl, type HostedRelay} from "@app/hosting" import {makeSpacePath} from "@app/routes" type Props = { - relay: Relay + relay: HostedRelay } const {relay}: Props = $props() diff --git a/src/app/content.ts b/src/app/content.ts index cfcbbabc..39194ad5 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -12,7 +12,7 @@ import { REPOST, THREAD, ZAP_GOAL, - ZAP_RESPONSE, + ZAP_RECEIPT, } from "@welshman/util" import type {Filter} from "@welshman/util" import {ENABLE_ZAPS} from "@app/env" @@ -37,7 +37,7 @@ export const REPOST_KINDS = [REPOST, GENERIC_REPOST] export const REACTION_KINDS = [REPORT, DELETE, REACTION] if (ENABLE_ZAPS) { - REACTION_KINDS.push(ZAP_RESPONSE) + REACTION_KINDS.push(ZAP_RECEIPT) } export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL] diff --git a/src/app/core.ts b/src/app/core.ts new file mode 100644 index 00000000..15c42aa4 --- /dev/null +++ b/src/app/core.ts @@ -0,0 +1,177 @@ +import {derived, writable} from "svelte/store" +import type {Readable} from "svelte/store" +import {always} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {withGetter} from "@welshman/store" +import type {ItemsByKey, ReadableWithGetter} from "@welshman/store" +import { + App, + BlockedRelayLists, + BlossomServerLists, + Deletes, + Domain, + FollowLists, + Handles, + MessagingRelayLists, + MuteLists, + Network, + Profiles, + Reactions, + RelayLists, + RelayManagement, + RelayMemberLists, + RelayRoles, + RelayStats, + Relays, + RoomLists, + RoomPinLists, + Rooms, + Router, + SearchRelayLists, + Thunks, + User, + Wot, + Wraps, + appPolicyCacheDecrypt, + appPolicyLogSignerMethods, + appPolicyRelayStats, + appPolicyWraps, +} from "@welshman/app" +import type {AppPolicy, IApp, Projection, Session} from "@welshman/app" +import type {BaseEventReader, EventWriter, KindFactory} from "@welshman/domain" +import {maybeMakeRelayMockAdapter} from "@lib/test/relayMocks" +import {DEFAULT_RELAYS, DEFAULT_SEARCH_RELAYS, DUFFLEPUD_URL, INDEXER_RELAYS} from "@app/env" + +// Flotilla's own policies (ingest, sockets, storage) can't be imported here — they depend on +// this module — so they push themselves in on import, and the first app is built lazily so +// they're all registered by the time it exists. +export const appPolicies: AppPolicy[] = [ + appPolicyWraps, + appPolicyRelayStats, + appPolicyCacheDecrypt, + appPolicyLogSignerMethods, +] + +const makeApp = (user?: User) => { + const instance: App = new App({ + user, + // Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory + // fixtures instead of the network. No-op for real users; stripped from production builds. + getAdapter: import.meta.env.DEV ? maybeMakeRelayMockAdapter() : undefined, + config: { + dufflepudUrl: DUFFLEPUD_URL, + getDefaultRelays: always(DEFAULT_RELAYS), + getIndexerRelays: always(INDEXER_RELAYS), + getSearchRelays: () => { + const urls = user ? instance.use(SearchRelayLists).urls(user.pubkey).get() : [] + + return urls.length > 0 ? urls : DEFAULT_SEARCH_RELAYS + }, + }, + policies: appPolicies, + }) + + return instance +} + +const appStore = withGetter(writable>(undefined)) + +const getApp = () => appStore.get() ?? setApp(makeApp()) + +const setApp = (instance: App) => { + appStore.set(instance) + + return instance +} + +// An app is scoped to a single identity, so logging in replaces it wholesale rather than +// attaching a user to the anonymous one. +export const app: ReadableWithGetter = { + get: getApp, + subscribe: run => { + getApp() + + return appStore.subscribe($app => run($app!)) + }, +} + +export const session = withGetter(writable>(undefined)) + +// The signed-in user, for the paths that require one — reading it while signed out throws, so +// use `$app.user` where absence is a legitimate state. +export const user = withGetter(derived(app, $app => User.require($app))) + +// Read a store off the current app, re-subscribing when login swaps in a new one. Anything +// bound at module load has to go through this or it will keep reading a discarded app. +export const fromApp = (read: ($app: App) => Readable): Readable => + derived(app, ($app, set: (value: T) => void) => read($app).subscribe(set)) + +// The signed-in user's entry in a keyed collection, e.g. +// deriveUserItem($app => $app.use(Profiles)). +export const deriveUserItem = (getPlugin: ($app: App) => {index: Projection>}) => + derived( + [app, fromApp($app => getPlugin($app).index.$)], + ([$app, $index]: [App, ItemsByKey]) => { + const $pubkey = $app.user?.pubkey + + return $pubkey ? $index.get($pubkey) : undefined + }, + ) + +export const login = async ($session: Session) => { + const $user = await User.fromSession($session) + + if (!$user) { + throw new Error(`Unable to log in using ${$session.method}`) + } + + // Read the store directly, so restoring a session at startup doesn't build an anonymous app + // just to tear it down. + appStore.get()?.cleanup() + setApp(makeApp($user)) + session.set($session) +} + +// Plugins bound to the current app, so `$profiles` in a component and `profiles.get()` in a +// module both stay pointed at the right one after login swaps it. Flotilla's own plugins expose +// themselves the same way. +export const usePlugin = (Ctor: new (app: IApp) => T) => + withGetter(derived(app, $app => $app.use(Ctor))) + +export const blockedRelayLists = usePlugin(BlockedRelayLists) +export const blossomServerLists = usePlugin(BlossomServerLists) +export const deletes = usePlugin(Deletes) +export const domain = usePlugin(Domain) +export const followLists = usePlugin(FollowLists) +export const handles = usePlugin(Handles) +export const messagingRelayLists = usePlugin(MessagingRelayLists) +export const muteLists = usePlugin(MuteLists) +export const network = usePlugin(Network) +export const profiles = usePlugin(Profiles) +export const reactions = usePlugin(Reactions) +export const relayLists = usePlugin(RelayLists) +export const relayManagement = usePlugin(RelayManagement) +export const relayMemberLists = usePlugin(RelayMemberLists) +export const relayRoles = usePlugin(RelayRoles) +export const relayStats = usePlugin(RelayStats) +export const relays = usePlugin(Relays) +export const roomLists = usePlugin(RoomLists) +export const roomPinLists = usePlugin(RoomPinLists) +export const rooms = usePlugin(Rooms) +export const router = usePlugin(Router) +export const searchRelayLists = usePlugin(SearchRelayLists) +export const thunks = usePlugin(Thunks) +export const wot = usePlugin(Wot) +export const wraps = usePlugin(Wraps) + +// The domain entry points, since almost every read or write goes through one of them. +export const reader = >( + factory: KindFactory, +) => domain.get().reader(factory) + +export const writer = >( + factory: KindFactory, + seed?: R, +) => domain.get().writer(factory, seed) + +export const command = (eventWriter: EventWriter) => domain.get().command(eventWriter) diff --git a/src/app/deletes.ts b/src/app/deletes.ts deleted file mode 100644 index ad369714..00000000 --- a/src/app/deletes.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type {TrustedEvent} from "@welshman/util" -import {DELETE, getTag, makeEvent} from "@welshman/util" -import {publishThunk, tagEvent} from "@welshman/app" -import {PROTECTED} from "@app/groups" - -export type DeleteParams = { - protect: boolean - event: TrustedEvent - tags?: string[][] -} - -export const makeDelete = ({protect, event, tags = []}: DeleteParams) => { - const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags] - const groupTag = getTag("h", event.tags) - - if (groupTag) { - thisTags.push(groupTag) - } - - if (protect) { - thisTags.push(PROTECTED) - } - - return makeEvent(DELETE, {tags: thisTags}) -} - -export const publishDelete = ({relays, ...params}: DeleteParams & {relays: string[]}) => - publishThunk({event: makeDelete(params), relays}) diff --git a/src/app/editor/MentionNodeView.ts b/src/app/editor/MentionNodeView.ts index 7daf06e6..1dbc4ce5 100644 --- a/src/app/editor/MentionNodeView.ts +++ b/src/app/editor/MentionNodeView.ts @@ -1,29 +1,26 @@ import type {NodeViewProps} from "@tiptap/core" -import {removeUndefined} from "@welshman/lib" -import {deriveProfileDisplay} from "@welshman/app" +import {profiles} from "@app/core" -export const makeMentionNodeView = - (url?: string) => - ({node}: NodeViewProps) => { - const dom = document.createElement("span") - const display = deriveProfileDisplay(node.attrs.pubkey, removeUndefined([url])) +export const MentionNodeView = ({node}: NodeViewProps) => { + const dom = document.createElement("span") + const display = profiles.get().display(node.attrs.pubkey).$ - dom.classList.add("tiptap-object") + dom.classList.add("tiptap-object") - const unsubDisplay = display.subscribe($display => { - dom.textContent = "@" + $display - }) + const unsubDisplay = display.subscribe($display => { + dom.textContent = "@" + $display + }) - return { - dom, - destroy: () => { - unsubDisplay() - }, - selectNode() { - dom.classList.add("tiptap-active") - }, - deselectNode() { - dom.classList.remove("tiptap-active") - }, - } + return { + dom, + destroy: () => { + unsubDisplay() + }, + selectNode() { + dom.classList.add("tiptap-active") + }, + deselectNode() { + dom.classList.remove("tiptap-active") + }, } +} diff --git a/src/app/editor/ProfileSuggestion.svelte b/src/app/editor/ProfileSuggestion.svelte index c827eef4..00e8f016 100644 --- a/src/app/editor/ProfileSuggestion.svelte +++ b/src/app/editor/ProfileSuggestion.svelte @@ -1,7 +1,7 @@
    diff --git a/src/app/editor/RoomReferenceNodeView.ts b/src/app/editor/RoomReferenceNodeView.ts index 5c25bf58..6b03e9ad 100644 --- a/src/app/editor/RoomReferenceNodeView.ts +++ b/src/app/editor/RoomReferenceNodeView.ts @@ -1,17 +1,17 @@ import type {NodeViewRendererProps} from "@tiptap/core" import {displayRelayUrl} from "@welshman/util" -import {deriveRoom} from "@app/groups" +import {rooms} from "@app/core" export const RoomReferenceNodeView = ({node}: NodeViewRendererProps) => { const dom = document.createElement("span") const url = typeof node.attrs.url === "string" ? node.attrs.url : "" const h = typeof node.attrs.h === "string" ? node.attrs.h : "" - const room = deriveRoom(url, h) + const room = rooms.get().forRoom(url, h) dom.classList.add("tiptap-object") const unsubRoom = room.subscribe($room => { - dom.textContent = `~${displayRelayUrl(url)} / ${$room.name || h}` + dom.textContent = `~${displayRelayUrl(url)} / ${$room?.meta?.name() || h}` }) return { diff --git a/src/app/editor/RoomSuggestion.svelte b/src/app/editor/RoomSuggestion.svelte index b0182a4b..352442a9 100644 --- a/src/app/editor/RoomSuggestion.svelte +++ b/src/app/editor/RoomSuggestion.svelte @@ -1,14 +1,14 @@
    diff --git a/src/app/editor/clipboard.ts b/src/app/editor/clipboard.ts index fa3e8ed5..06cf6a62 100644 --- a/src/app/editor/clipboard.ts +++ b/src/app/editor/clipboard.ts @@ -1,3 +1,4 @@ +import {spec} from "@welshman/lib" import {Clipboard} from "@capacitor/clipboard" import {Capacitor} from "@capacitor/core" import {Extension} from "@tiptap/core" @@ -13,7 +14,7 @@ const hasStandardPastePayload = (event: ClipboardEvent) => { return false } - if (Array.from(clipboardData.items).some(item => item.kind === "file")) { + if (Array.from(clipboardData.items).some(spec({kind: "file"}))) { return true } diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 15148e27..71093bcd 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -1,18 +1,10 @@ import {mount} from "svelte" import type {Writable} from "svelte/store" import {get, derived} from "svelte/store" -import {Router} from "@welshman/router" -import {dec, inc, uniq} from "@welshman/lib" +import {sortBy, uniq} from "@welshman/lib" import {throttled} from "@welshman/store" -import type {PublishedProfile, RoomMeta} from "@welshman/util" -import { - createSearch, - profiles, - searchProfiles, - handlesByNip05, - getMaxWot, - getWotGraph, -} from "@welshman/app" +import {createSearch, splitRoomKey} from "@welshman/app" +import type {Room} from "@welshman/app" import type {FileAttributes} from "@welshman/editor" import { Editor, @@ -22,14 +14,14 @@ import { editorProps, } from "@welshman/editor" import {escapeHtml} from "@lib/html" -import {makeMentionNodeView} from "@app/editor/MentionNodeView" +import {profiles, relayLists, relayMemberLists, rooms} from "@app/core" +import {MentionNodeView} from "@app/editor/MentionNodeView" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension" import RoomSuggestion from "@app/editor/RoomSuggestion.svelte" import {NativeClipboardPasteExtension} from "@app/editor/clipboard" import {compressFileForUpload, uploadFile} from "@app/uploads" -import {deriveSpaceMembers} from "@app/members" -import {makeRoomId, splitRoomId, userSpaceUrls, roomsByUrl} from "@app/groups" +import {userSpaceUrls} from "@app/rooms" import {PLATFORM_RELAYS} from "@app/env" import {pushToast} from "@app/toast" @@ -56,49 +48,19 @@ export const makeEditor = async ({ uploading?: Writable wordCount?: Writable }) => { - const profileSearch = derived( - [ - throttled(800, profiles), - throttled(800, handlesByNip05), - throttled(800, deriveSpaceMembers(url || "")), - ], - ([$profiles, $handlesByNip05, $spaceMembers]) => { - // Remove invalid nip05's from profiles - const options = $profiles.map(p => { - const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey + const searchProfiles = derived( + [profiles.get().profileSearch, throttled(800, relayMemberLists.get().forUrl(url ?? ""))], + ([$profileSearch, $spaceMembers]) => { + const memberPubkeys = new Set($spaceMembers?.pubkeys()) - return isNip05Valid ? p : {...p, nip05: ""} - }) - - return createSearch(options, { - onSearch: searchProfiles, - getValue: (profile: PublishedProfile) => profile.event.pubkey, - sortFn: ({score = 1, item}) => { - const wotScore = getWotGraph().get(item.event.pubkey) || 0 - const membershipScale = $spaceMembers?.includes(item.event.pubkey) ? 2 : 1 - - return dec(score) * inc(wotScore / getMaxWot()) * membershipScale - }, - fuseOptions: { - keys: [ - "nip05", - {name: "name", weight: 0.8}, - {name: "display_name", weight: 0.5}, - {name: "about", weight: 0.3}, - ], - threshold: 0.3, - shouldSort: false, - }, - }) + return (term: string) => + sortBy(pubkey => (memberPubkeys.has(pubkey) ? 0 : 1), $profileSearch.searchValues(term)) }, ) const roomReferenceSearch = derived( - [throttled(800, userSpaceUrls), throttled(800, roomsByUrl)], + [throttled(800, userSpaceUrls), throttled(800, rooms.get().byUrl.$)], ([$userSpaceUrls, $roomsByUrl]) => { - const roomIdByMeta = new WeakMap() - const options: RoomMeta[] = [] - // When platform relays are configured, restrict suggestions to those spaces. // Otherwise suggest rooms from the user's joined spaces plus the current one. const spaceUrls = @@ -106,15 +68,16 @@ export const makeEditor = async ({ ? PLATFORM_RELAYS : uniq(url ? [url, ...$userSpaceUrls] : $userSpaceUrls) - for (const roomUrl of spaceUrls) { - for (const room of $roomsByUrl.get(roomUrl) || []) { - roomIdByMeta.set(room, makeRoomId(roomUrl, room.h)) - options.push(room) - } - } + const options = spaceUrls.flatMap(spaceUrl => + ($roomsByUrl.get(spaceUrl) ?? []).map((room: Room) => ({ + id: room.id, + h: room.h, + name: room.meta?.name() ?? "", + })), + ) return createSearch(options, { - getValue: item => roomIdByMeta.get(item) || item.h, + getValue: option => option.id, fuseOptions: { keys: ["name", "h"], threshold: 0.3, @@ -158,14 +121,14 @@ export const makeEditor = async ({ }, nprofile: { extend: { - addNodeView: () => makeMentionNodeView(url), + addNodeView: () => MentionNodeView, addProseMirrorPlugins() { return [ MentionSuggestion({ editor: (this as any).editor, - search: (term: string) => get(profileSearch).searchValues(term), - getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(), - updateSignal: profileSearch, + search: (term: string) => get(searchProfiles)(term), + getRelays: (pubkey: string) => relayLists.get().writeUrls(pubkey).get(), + updateSignal: searchProfiles, createSuggestion: (value: string) => { const target = document.createElement("div") @@ -181,13 +144,11 @@ export const makeEditor = async ({ search: (term: string) => get(roomReferenceSearch).searchValues(term), updateSignal: roomReferenceSearch, select: (id: string, props) => { - const [roomUrl, h] = splitRoomId(id) + const [roomUrl, h] = splitRoomKey(id) - if (!roomUrl || !h) { - return + if (roomUrl && h) { + return props.command({url: roomUrl, h}) } - - return props.command({url: roomUrl, h}) }, createSuggestion: (value: string) => { const target = document.createElement("div") diff --git a/src/app/env.ts b/src/app/env.ts index ccdcd593..313d4daf 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -1,8 +1,7 @@ -import {normalizeRelayUrl} from "@welshman/util" import {Capacitor} from "@capacitor/core" -import {Router} from "@welshman/router" import * as nip19 from "nostr-tools/nip19" import {identity} from "@welshman/lib" +import {normalizeRelayUrl} from "@welshman/util" const fromCsv = (s: string) => (s || "").split(",").filter(identity) @@ -20,6 +19,10 @@ export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(n export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl) +export const DEFAULT_SEARCH_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_SEARCH_RELAYS).map( + normalizeRelayUrl, +) + export const DEFAULT_MESSAGING_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_MESSAGING_RELAYS).map( normalizeRelayUrl, ) @@ -67,5 +70,5 @@ export const dufflepud = (path: string) => DUFFLEPUD_URL + "/" + path export const entityLink = (entity: string) => `https://coracle.social/${entity}` -export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) => +export const pubkeyLink = (pubkey: string, relays: string[] = []) => entityLink(nip19.nprofileEncode({pubkey, relays})) diff --git a/src/app/featured.ts b/src/app/featured.ts index c7f50d5d..88507f71 100644 --- a/src/app/featured.ts +++ b/src/app/featured.ts @@ -1,8 +1,9 @@ import {derived} from "svelte/store" -import {now} from "@welshman/lib" -import {APP_DATA, getTagValues} from "@welshman/util" +import {APP_DATA, tagSpec, tagValues} from "@welshman/util" +import {AppData} from "@welshman/domain" +import {Domain} from "@welshman/app" +import {app} from "@app/core" import {deriveRelaySignedEvents} from "@app/repository" -import {signAsRelay} from "@app/relays" // NIP-78 app data published by the relay's self key. Each featured entry is a // ["content", ] tag (freeform text, intended to be a url or nevent). @@ -11,21 +12,26 @@ export const FEATURED_CONTENT_D = "flotilla/featured-content" export const deriveFeaturedContent = (url: string) => derived( deriveRelaySignedEvents(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}]), - ([event]) => getTagValues("content", event?.tags ?? []), + ([event]) => tagValues(tagSpec("content"), event?.tags ?? []), ) // Publish the featured content list by asking the relay to sign it with its self // key (the unofficial NIP-86 "signevent" method). -export const setFeaturedContent = (url: string, content: string[]): Promise => - signAsRelay(url, { - kind: APP_DATA, - created_at: now(), - content: "", - tags: [ - ["d", FEATURED_CONTENT_D], - ...content - .map(value => value.trim()) - .filter(Boolean) - .map(value => ["content", value]), - ], - }) +export const setFeaturedContent = async (url: string, content: string[]) => { + const tags = content + .map(value => value.trim()) + .filter(Boolean) + .map(value => ["content", value]) + + const writer = app + .get() + .use(Domain) + .writer(AppData) + .forceRelays(url) + .setIdentifier(FEATURED_CONTENT_D) + .addTags(...tags) + + const command = await app.get().use(Domain).command(writer) + + return command.publishAsRelay(url) +} diff --git a/src/app/feeds.ts b/src/app/feeds.ts index 2937ee10..53444734 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -1,12 +1,12 @@ -import {repository, tracker} from "@welshman/app" import {writable} from "svelte/store" import {batch, between, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib" -import {EVENT_TIME, getAddress, getTagValue, matchFilters} from "@welshman/util" +import {EVENT_TIME, getAddress, matchFilters, tagSpec, tagValue} from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" -import {mergeRepositoryUpdates, request} from "@welshman/net" +import {mergeRepositoryUpdates} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net" import {createScroller} from "@lib/html" import {daysBetween} from "@lib/util" +import {app, network} from "@app/core" import {getEventsForUrl} from "@app/repository" export const makeFeed = ({ @@ -101,7 +101,7 @@ export const makeFeed = ({ const matching: TrustedEvent[] = [] for (const id of new Set(ids)) { - const event = repository.getEvent(id) + const event = app.get().repository.getEvent(id) if (event && matchFilters(filters, event)) { matching.push(event) @@ -115,7 +115,7 @@ export const makeFeed = ({ const unsubscribers = [ on( - repository, + app.get().repository, "update", batch(150, (updates: RepositoryUpdate[]) => { const {added, removed} = mergeRepositoryUpdates(updates) @@ -132,7 +132,7 @@ export const makeFeed = ({ const matching = added.filter( event => matchFilters(filters, event) && - relays.some(url => tracker.getRelays(event.id).has(url)), + relays.some(url => app.get().tracker.getRelays(event.id).has(url)), ) if (matching.length > 0) { @@ -140,7 +140,7 @@ export const makeFeed = ({ } }), ), - on(tracker, "add", (id: string, url: string) => { + on(app.get().tracker, "add", (id: string, url: string) => { if (relays.includes(url)) { onTrackedId(id) } @@ -148,7 +148,7 @@ export const makeFeed = ({ ] const loadTimeframe = async (since: number, until: number) => { - const events = await request({ + const events = await network.get().request({ relays, autoClose: true, signal: controller.signal, @@ -238,9 +238,9 @@ export const makeCalendarFeed = ({ let backwardWindow = [now() - interval, now()] let forwardWindow = [now(), now() + interval] - const getStart = (event: TrustedEvent) => parseInt(getTagValue("start", event.tags) || "") + const getStart = (event: TrustedEvent) => parseInt(tagValue(tagSpec("start"), event.tags) || "") - const getEnd = (event: TrustedEvent) => parseInt(getTagValue("end", event.tags) || "") + const getEnd = (event: TrustedEvent) => parseInt(tagValue(tagSpec("end"), event.tags) || "") const events = writable( sortBy( @@ -291,7 +291,7 @@ export const makeCalendarFeed = ({ const matching: TrustedEvent[] = [] for (const id of new Set(ids)) { - const event = repository.getEvent(id) + const event = app.get().repository.getEvent(id) if (event && matchFilters(filters, event)) { matching.push(event) @@ -305,7 +305,7 @@ export const makeCalendarFeed = ({ const unsubscribers = [ on( - repository, + app.get().repository, "update", batch(150, (updates: RepositoryUpdate[]) => { const {added, removed} = mergeRepositoryUpdates(updates) @@ -325,7 +325,7 @@ export const makeCalendarFeed = ({ } }), ), - on(tracker, "add", (id: string, url: string) => { + on(app.get().tracker, "add", (id: string, url: string) => { if (relays.includes(url)) { onTrackedId(id) } @@ -335,7 +335,7 @@ export const makeCalendarFeed = ({ const loadTimeframe = (since: number, until: number) => { const hashes = daysBetween(since, until).map(String) - request({ + network.get().request({ relays, autoClose: true, signal: controller.signal, diff --git a/src/app/groups.ts b/src/app/groups.ts deleted file mode 100644 index 24a62139..00000000 --- a/src/app/groups.ts +++ /dev/null @@ -1,433 +0,0 @@ -import * as nip19 from "nostr-tools/nip19" -import { - MESSAGE, - ROOMS, - ROOM_DELETE, - ROOM_META, - addToListPublicly, - asDecryptedEvent, - getGroupTags, - getListTags, - getRelayTagValues, - getTagValues, - isRelayUrl, - makeEvent, - makeList, - makeRoomMeta, - normalizeRelayUrl, - readList, - readRoomMeta, - removeFromListByPredicate, - toNostrURI, - updateList, - RelayMode, -} from "@welshman/util" -import type {EventContent, List, PublishedRoomMeta, RoomMeta, TrustedEvent} from "@welshman/util" -import { - deriveEventsByIdByUrl, - deriveItemsByKey, - getter, - makeDeriveItem, - makeLoadItem, -} from "@welshman/store" -import { - getPubkeyRelays, - makeOutboxLoader, - makeUserData, - makeUserLoader, - nip44EncryptToSelf, - publishThunk, - repository, - tagEventForQuote, - tagPubkey, - tracker, -} from "@welshman/app" -import {derived, get} from "svelte/store" -import { - addToMapKey, - call, - equals, - gte, - identity, - indexBy, - max, - partition, - sortBy, - spec, - tryCatch, - uniq, -} from "@welshman/lib" -import {load} from "@welshman/net" -import {Router} from "@welshman/router" -import {INDEXER_RELAYS} from "@app/env" - -export const ROOM = "h" - -export const PROTECTED = ["-"] - -export enum RoomType { - Text = "text", - Voice = "voice", -} - -export type Room = PublishedRoomMeta & { - id: string - url: string -} - -export const getRoomType = (room: RoomMeta): RoomType => - room.livekit ? RoomType.Voice : RoomType.Text - -export const makeRoomId = (url: string, h: string) => `${url}'${h}` - -export const isRoomId = (id: string) => id.includes("'") - -export const splitRoomId = (id: string) => id.split("'") - -export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({ - tracker, - repository, - filters: [{kinds: [ROOM_META, ROOM_DELETE]}], -}) - -export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => { - const result = new Map() - - for (const [url, events] of roomMetaEventsByIdByUrl.entries()) { - const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values()) - const deletedByH = new Map() - - for (const event of deleteEvents) { - for (const h of getTagValues("h", event.tags)) { - deletedByH.set(h, max([deletedByH.get(h), event.created_at])) - } - } - - const metaById = new Map() - - for (const event of metaEvents) { - const meta = tryCatch(() => readRoomMeta(event)) - - if (!meta || gte(deletedByH.get(meta.h), meta.event.created_at)) { - continue - } - - const id = makeRoomId(url, meta.h) - - metaById.set(id, {...meta, url, id}) - } - - if (metaById.size > 0) { - result.set(url, Array.from(metaById.values())) - } - } - - return result -}) - -export const roomsById = derived(roomsByUrl, roomsByUrl => - indexBy(room => room.id, Array.from(roomsByUrl.values()).flatMap(identity)), -) - -export const getRoomsById = getter(roomsById) - -export const getRoom = (id: string) => getRoomsById().get(id) - -export const loadRoom = call(() => { - const _fetchRoom = async (id: string) => { - const [url, h] = splitRoomId(id) - - await load({ - relays: [url], - filters: [ - {kinds: [ROOM_META], "#d": [h]}, - {kinds: [ROOM_DELETE], "#h": [h]}, - ], - }) - } - - const _loadRoom = makeLoadItem(_fetchRoom, getRoom) - - return (url: string, h: string) => _loadRoom(makeRoomId(url, h)) -}) - -export const deriveRoom = call(() => { - const _deriveRoom = makeDeriveItem(roomsById, loadRoom) - - return (url: string, h: string) => - derived( - _deriveRoom(makeRoomId(url, h)), - room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room, - ) -}) - -export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h))?.name || h - -export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() - -export const deriveVoiceRooms = (url: string) => - derived(roomsById, $roomsById => { - const set = new Set() - for (const room of $roomsById.values()) { - if (room.url === url && room.livekit) { - set.add(room.h) - } - } - return set - }) - -export const deriveOtherVoiceRooms = (url: string) => - derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => { - const rooms: string[] = [] - - for (const h of $roomsWithLivekit) { - if (!$userRooms.includes(h)) { - rooms.push(h) - } - } - - return sortBy(roomComparator(url), uniq(rooms)) - }) - -// User space/room lists - -export const groupListsByPubkey = deriveItemsByKey({ - repository, - filters: [{kinds: [ROOMS]}], - getKey: list => list.event.pubkey, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), -}) - -export const getGroupListsByPubkey = getter(groupListsByPubkey) - -export const getGroupList = (pubkey: string) => getGroupListsByPubkey().get(pubkey) - -export const loadGroupList = makeLoadItem(makeOutboxLoader(ROOMS), getGroupList) - -export const deriveGroupList = makeDeriveItem(groupListsByPubkey, loadGroupList) - -export const groupListPubkeysByUrl = derived(groupListsByPubkey, $groupListsByPubkey => { - const result = new Map>() - - for (const list of $groupListsByPubkey.values()) { - const tags = getListTags(list) - - for (const url of getRelayTagValues(tags)) { - addToMapKey(result, normalizeRelayUrl(url), list.event.pubkey) - } - - for (const tag of getGroupTags(tags)) { - const url = tag[2] || "" - - if (isRelayUrl(url)) { - addToMapKey(result, normalizeRelayUrl(url), list.event.pubkey) - } - } - } - - return result -}) - -export const deriveGroupListPubkeys = (url: string) => - derived(groupListPubkeysByUrl, $groupListPubkeysByUrl => new Set($groupListPubkeysByUrl.get(url))) - -export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => { - const tags = getListTags(groupList) - const urls = getRelayTagValues(tags) - - for (const tag of getGroupTags(tags)) { - const url = tag[2] || "" - - if (isRelayUrl(url)) { - urls.push(url) - } - } - - return uniq(urls.map(normalizeRelayUrl)) -} - -export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => { - const rooms: string[] = [] - const normalizedUrl = normalizeRelayUrl(url) - - for (const [_, h, relay] of getGroupTags(getListTags(groupList))) { - if (normalizedUrl === normalizeRelayUrl(relay)) { - rooms.push(h) - } - } - - return sortBy(roomComparator(url), uniq(rooms)) -} - -export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList) - -export const loadUserGroupList = makeUserLoader(loadGroupList) - -export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList) - -export const deriveUserRooms = (url: string) => - derived([userGroupList, roomsById], ([$userGroupList, $roomsById]) => { - const rooms: string[] = [] - - for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) { - if ($roomsById.has(makeRoomId(url, h))) { - rooms.push(h) - } - } - - return sortBy(roomComparator(url), rooms) - }) - -export const deriveOtherRooms = (url: string) => - derived( - [deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl], - ([$userRooms, voiceRooms, $roomsByUrl]) => { - const rooms: string[] = [] - - for (const {h} of $roomsByUrl.get(url) || []) { - if (!$userRooms.includes(h) && !voiceRooms.has(h)) { - rooms.push(h) - } - } - - return sortBy(roomComparator(url), uniq(rooms)) - }, - ) - -export const addSpace = async (url: string) => { - const list = get(userGroupList) || makeList({kind: ROOMS}) - const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) - const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) - - return publishThunk({event, relays}) -} - -export const removeSpace = async (url: string) => { - const list = get(userGroupList) || makeList({kind: ROOMS}) - const pred = (t: string[]) => normalizeRelayUrl(t[t[0] === "r" ? 1 : 2]) === url - const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) - const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) - - return publishThunk({event, relays}) -} - -export const setSpaces = async (urls: string[]) => { - const list = get(userGroupList) || makeList({kind: ROOMS}) - const orderedUrls = uniq(urls.map(normalizeRelayUrl)) - const relayTags = list.publicTags.filter(t => t[0] === "r") - const otherPublicTags = list.publicTags.filter(t => t[0] !== "r") - const relayTagByUrl = new Map(relayTags.map(t => [normalizeRelayUrl(t[1]), t])) - const orderedRelayTags = orderedUrls.map(url => relayTagByUrl.get(url) || ["r", url]) - const publicTags = [...orderedRelayTags, ...otherPublicTags] - const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf) - const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) - - return publishThunk({event, relays}) -} - -export const moveSpace = async (oldUrl: string, newUrl: string) => { - const from = normalizeRelayUrl(oldUrl) - const to = normalizeRelayUrl(newUrl) - const list = get(userGroupList) - - if (!list || from === to) return - - const rewrite = (tags: string[][]) => - tags.map(t => { - if (t[0] === "r" && normalizeRelayUrl(t[1]) === from) return ["r", to] - if (t[0] === "group" && normalizeRelayUrl(t[2] || "") === from) return ["group", t[1], to] - return t - }) - - const publicTags = rewrite(list.publicTags) - const privateTags = rewrite(list.privateTags) - const publicChanged = !equals(publicTags, list.publicTags) - const privateChanged = !equals(privateTags, list.privateTags) - - if (!publicChanged && !privateChanged) return - - const event = await updateList(list, { - publicTags: publicChanged ? publicTags : undefined, - privateTags: privateChanged ? privateTags : undefined, - }).reconcile(nip44EncryptToSelf) - const relays = uniq([ - oldUrl, - newUrl, - ...Router.get().FromUser().getUrls(), - ...getRelayTagValues(event.tags), - ]) - - return publishThunk({event, relays}) -} - -export const addRoom = async (url: string, h: string) => { - const list = get(userGroupList) || makeList({kind: ROOMS}) - const newTags = [ - ["r", url], - ["group", h, url], - ] - const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf) - const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) - - return publishThunk({event, relays}) -} - -export const removeRoom = async (url: string, h: string) => { - const list = get(userGroupList) || makeList({kind: ROOMS}) - const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3)) - const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) - const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) - - return publishThunk({event, relays}) -} - -export const getPubkeyHints = (pubkey: string) => { - const relays = getPubkeyRelays(pubkey, RelayMode.Write) - const hints = relays.length ? relays : INDEXER_RELAYS - - return hints -} - -export const prependParent = ( - parent: TrustedEvent | undefined, - {content, tags}: EventContent, - url?: string, -) => { - if (parent) { - const relays = url ? [url] : Router.get().Event(parent).limit(3).getUrls() - const nevent = nip19.neventEncode({...parent, relays}) - - tags = [...tags, tagEventForQuote(parent, url), tagPubkey(parent.pubkey)] - content = toNostrURI(nevent) + "\n\n" + content - } - - return {content, tags} -} - -export const publishRoomQuote = ({ - url, - h, - parent, - protect, - delay, -}: { - url: string - h?: string - parent: TrustedEvent - protect: boolean - delay?: number -}) => { - const tags: string[][] = [] - - if (h) { - tags.push(["h", h]) - } - - if (protect) { - tags.push(PROTECTED) - } - - const event = makeEvent(MESSAGE, prependParent(parent, {content: "", tags}, url)) - - return publishThunk({relays: [url], event, delay}) -} diff --git a/src/app/healthChecks.ts b/src/app/healthChecks.ts index 27e7eff2..40f97cbf 100644 --- a/src/app/healthChecks.ts +++ b/src/app/healthChecks.ts @@ -1,17 +1,16 @@ -import {derived, get} from "svelte/store" -import {not, ifLet, sample} from "@welshman/lib" -import {getRelaysFromList, RelayMode} from "@welshman/util" +import {derived} from "svelte/store" +import {sample} from "@welshman/lib" import { - getRelay, - setWriteRelays, - setReadRelays, - setSearchRelays, - setMessagingRelays, - userRelayList, - userSearchRelayList, - userMessagingRelayList, + MessagingRelayLists, + RelayLists, + Relays, + SearchRelayLists, + projectFrom, + projection, + publish, } from "@welshman/app" -import {hasNip50} from "@app/relays" +import type {IApp, Projection} from "@welshman/app" +import {usePlugin} from "@app/core" import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env" export type HealthCheckContext = { @@ -29,80 +28,106 @@ export type HealthCheck = { apply: (context: HealthCheckContext) => unknown } -export const healthCheckContext = derived( - [userRelayList, userSearchRelayList, userMessagingRelayList], - ([$userRelayList, $userSearchRelayList, $userMessagingRelayList]) => { - return { - readRelays: getRelaysFromList($userRelayList, RelayMode.Read), - writeRelays: getRelaysFromList($userRelayList, RelayMode.Write), - searchRelays: getRelaysFromList($userSearchRelayList), - messagingRelays: getRelaysFromList($userMessagingRelayList), - } - }, -) +export class HealthChecks { + context: Projection + pending: Projection -const healthChecks: HealthCheck[] = [ - { - title: "Missing Inbox Relays", - description: "Other people aren't currently able to reliably tag you in public notes.", - action: "Update", - isPending: context => context.readRelays.length <= 1, - apply: () => setReadRelays(DEFAULT_RELAYS), - }, - { - title: "Missing Outbox Relays", - description: "Other people aren't currently able to reliably find your public notes.", - action: "Update", - isPending: context => context.writeRelays.length <= 1, - apply: () => setWriteRelays(DEFAULT_RELAYS), - }, - { - title: "Missing DM Relays", - description: "You aren't currently able to reliably send or receive direct messages.", - action: "Update", - isPending: context => context.messagingRelays.length <= 1, - apply: () => setMessagingRelays(DEFAULT_MESSAGING_RELAYS), - }, - { - title: "Too Many Inbox Relays", - description: - "You have more inbox relays than is really necessary, which can affect resource usage.", - action: "Prune Selections", - isPending: context => context.readRelays.length > 8, - apply: context => setReadRelays(sample(5, context.readRelays)), - }, - { - title: "Too Many Outbox Relays", - description: - "You have more outbox relays than is really necessary, which can affect resource usage.", - action: "Prune Selections", - isPending: context => context.writeRelays.length > 8, - apply: context => setWriteRelays(sample(5, context.writeRelays)), - }, - { - title: "Too Many DM Relays", - description: - "You have more DM relays than is really necessary, which can affect resource usage.", - action: "Prune Selections", - isPending: context => context.messagingRelays.length > 8, - apply: context => setMessagingRelays(sample(5, context.messagingRelays)), - }, - { - title: "Invalid Search Relays", - description: "Some of your search relays don't support search.", - action: "Remove Invalid", - isPending: context => context.searchRelays.some(url => not(ifLet(getRelay(url), hasNip50))), - apply: context => - setSearchRelays(context.searchRelays.filter(url => ifLet(getRelay(url), hasNip50))), - }, -] + constructor(private readonly app: IApp) { + const pubkey = app.user?.pubkey ?? "" -export const isHealthCheckPending = (healthCheck: HealthCheck) => - healthCheck.isPending(get(healthCheckContext)) + this.context = projection( + derived( + [ + app.use(RelayLists).index.$, + app.use(SearchRelayLists).index.$, + app.use(MessagingRelayLists).index.$, + ], + ([$relayLists, $searchRelayLists, $messagingRelayLists]) => { + const relayList = $relayLists.get(pubkey) -export const applyHealthCheck = (healthCheck: HealthCheck) => - healthCheck.apply(get(healthCheckContext)) + return { + readRelays: relayList?.readUrls() ?? [], + writeRelays: relayList?.writeUrls() ?? [], + searchRelays: $searchRelayLists.get(pubkey)?.urls() ?? [], + messagingRelays: $messagingRelayLists.get(pubkey)?.urls() ?? [], + } + }, + ), + ) -export const pendingHealthChecks = derived(healthCheckContext, ctx => - healthChecks.filter(hc => hc.isPending(ctx)), -) + this.pending = projectFrom(this.context, $context => + this.checks.filter(check => check.isPending($context)), + ) + } + + private supportsSearch = (url: string) => this.app.use(Relays).get(url)?.hasNip(50) + + private checks: HealthCheck[] = [ + { + title: "Missing Inbox Relays", + description: "Other people aren't currently able to reliably tag you in public notes.", + action: "Update", + isPending: context => context.readRelays.length <= 1, + apply: () => this.app.use(RelayLists).setReadUrls(DEFAULT_RELAYS).then(publish), + }, + { + title: "Missing Outbox Relays", + description: "Other people aren't currently able to reliably find your public notes.", + action: "Update", + isPending: context => context.writeRelays.length <= 1, + apply: () => this.app.use(RelayLists).setWriteUrls(DEFAULT_RELAYS).then(publish), + }, + { + title: "Missing DM Relays", + description: "You aren't currently able to reliably send or receive direct messages.", + action: "Update", + isPending: context => context.messagingRelays.length <= 1, + apply: () => + this.app.use(MessagingRelayLists).setUrls(DEFAULT_MESSAGING_RELAYS).then(publish), + }, + { + title: "Too Many Inbox Relays", + description: + "You have more inbox relays than is really necessary, which can affect resource usage.", + action: "Prune Selections", + isPending: context => context.readRelays.length > 8, + apply: context => + this.app.use(RelayLists).setReadUrls(sample(5, context.readRelays)).then(publish), + }, + { + title: "Too Many Outbox Relays", + description: + "You have more outbox relays than is really necessary, which can affect resource usage.", + action: "Prune Selections", + isPending: context => context.writeRelays.length > 8, + apply: context => + this.app.use(RelayLists).setWriteUrls(sample(5, context.writeRelays)).then(publish), + }, + { + title: "Too Many DM Relays", + description: + "You have more DM relays than is really necessary, which can affect resource usage.", + action: "Prune Selections", + isPending: context => context.messagingRelays.length > 8, + apply: context => + this.app.use(MessagingRelayLists).setUrls(sample(5, context.messagingRelays)).then(publish), + }, + { + title: "Invalid Search Relays", + description: "Some of your search relays don't support search.", + action: "Remove Invalid", + isPending: context => context.searchRelays.some(url => !this.supportsSearch(url)), + apply: context => + this.app + .use(SearchRelayLists) + .setUrls(context.searchRelays.filter(this.supportsSearch)) + .then(publish), + }, + ] + + isPending = (healthCheck: HealthCheck) => healthCheck.isPending(this.context.get()) + + apply = (healthCheck: HealthCheck) => healthCheck.apply(this.context.get()) +} + +export const healthChecks = usePlugin(HealthChecks) diff --git a/src/app/hosting.ts b/src/app/hosting.ts index 6f937ed0..5266a14d 100644 --- a/src/app/hosting.ts +++ b/src/app/hosting.ts @@ -4,7 +4,7 @@ import {Capacitor} from "@capacitor/core" import type {Maybe} from "@welshman/lib" import {sortBy, int, MINUTE, ms} from "@welshman/lib" import {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util" -import {pubkey, signer} from "@welshman/app" +import {user} from "@app/core" import {HOSTING_BACKEND_URL, PLATFORM_URL} from "@app/env" // Apple doesn't allow selling hosting outside their payment system, so on iOS we @@ -21,7 +21,7 @@ export type Plan = { livekit: boolean } -export type Relay = { +export type HostedRelay = { id: string tenant_pubkey: string subdomain: string @@ -180,15 +180,12 @@ const AUTH_TTL = ms(int(10, MINUTE)) // switching accounts doesn't keep signing as the old one. let cachedAuth: Maybe<{pubkey: string; expiresAt: number; header: Promise}> -const getAuthHeader = (): Maybe> => { - const $pubkey = pubkey.get() - const $signer = signer.get() +const getAuthHeader = (): Promise => { + const {pubkey, signer} = user.get() - if (!$pubkey || !$signer) return undefined - - if (cachedAuth?.pubkey !== $pubkey || cachedAuth.expiresAt < Date.now()) { + if (cachedAuth?.pubkey !== pubkey || cachedAuth.expiresAt < Date.now()) { const header = makeHttpAuth(HOSTING_BACKEND_URL, "GET") - .then(template => $signer.sign(template)) + .then(template => signer.sign(template)) .then(makeHttpAuthHeader) // A declined signature shouldn't be cached, or the user can't retry. @@ -196,7 +193,7 @@ const getAuthHeader = (): Maybe> => { if (cachedAuth?.header === header) cachedAuth = undefined }) - cachedAuth = {pubkey: $pubkey, expiresAt: Date.now() + AUTH_TTL, header} + cachedAuth = {pubkey, expiresAt: Date.now() + AUTH_TTL, header} } return cachedAuth.header @@ -205,10 +202,11 @@ const getAuthHeader = (): Maybe> => { type HostingResponse = {data: T; error?: string} export const hostingFetch = async (method: string, path: string, body?: unknown): Promise => { - const auth = await getAuthHeader() - const headers: Record = {Accept: "application/json"} + const headers: Record = { + Accept: "application/json", + Authorization: await getAuthHeader(), + } - if (auth) headers["Authorization"] = auth if (body !== undefined) headers["Content-Type"] = "application/json" const response = await fetch(new URL(path, HOSTING_BACKEND_URL).toString(), { @@ -237,7 +235,7 @@ export const updateTenant = (pubkey: string, input: UpdateTenantInput) => hostingFetch("PUT", `/tenants/${pubkey}`, input) export const listTenantRelays = (pubkey: string) => - hostingFetch("GET", `/tenants/${pubkey}/relays`) + hostingFetch("GET", `/tenants/${pubkey}/relays`) export const listTenantInvoices = (pubkey: string) => hostingFetch("GET", `/tenants/${pubkey}/invoices`) @@ -256,12 +254,12 @@ export const createPortalSession = (pubkey: string, returnUrl: string = hostingR ) export const createRelay = (input: CreateRelayInput) => - hostingFetch("POST", "/relays", input) + hostingFetch("POST", "/relays", input) -export const getRelay = (id: string) => hostingFetch("GET", `/relays/${id}`) +export const getRelay = (id: string) => hostingFetch("GET", `/relays/${id}`) export const updateRelay = (id: string, input: UpdateRelayInput) => - hostingFetch("PUT", `/relays/${id}`, input) + hostingFetch("PUT", `/relays/${id}`, input) export const deactivateRelay = (id: string) => hostingFetch("POST", `/relays/${id}/deactivate`) @@ -322,16 +320,16 @@ export const formatPeriod = (startSecs?: number, endSecs?: number) => : `${new Date(ms(startSecs)).toLocaleDateString()} – ${new Date(ms(endSecs)).toLocaleDateString()}` // A verified custom domain when present, otherwise the platform subdomain. -export const relayHost = (relay: Relay): string => +export const relayHost = (relay: HostedRelay): string => relay.custom_domain_verified && relay.custom_domain ? relay.custom_domain : canonicalRelayHost(relay) // The host a custom domain must CNAME to. -export const canonicalRelayHost = (relay: Relay): string => +export const canonicalRelayHost = (relay: HostedRelay): string => `${relay.subdomain}.${relay.zooid_domain}` -export const getHostedRelayUrl = (relay: Relay): string => +export const getHostedRelayUrl = (relay: HostedRelay): string => normalizeRelayUrl("wss://" + relayHost(relay)) // On native, redirects can't round-trip through location.origin; use the @@ -345,9 +343,7 @@ export const hostingReturnUrl = (path = "/settings/hosting"): string => let tenantPromise: Maybe<{pubkey: string; promise: Promise}> export const ensureSessionTenant = async () => { - const $pubkey = pubkey.get() - - if (!$pubkey) return + const $pubkey = user.get().pubkey if (tenantPromise?.pubkey !== $pubkey) { const promise = createTenant(hostingReturnUrl()) @@ -399,17 +395,15 @@ export const deriveRelayActivity = (id: Readable>) => // Loading is distinct from "not hosted here" — both leave `relay` undefined, but // only one of them should keep the caller waiting. -export type HostedRelayState = {loading: boolean; relay: Maybe} +export type HostedRelayState = {loading: boolean; relay: Maybe} export const deriveHostedRelay = (url: string) => - derived( - pubkey, - ($pubkey, set) => { - if (!$pubkey) return set({loading: false, relay: undefined}) - + derived( + user, + ($user, set) => { set({loading: true, relay: undefined}) - listTenantRelays($pubkey).then( + listTenantRelays($user.pubkey).then( relays => set({loading: false, relay: relays.find(r => getHostedRelayUrl(r) === url)}), () => set({loading: false, relay: undefined}), ) diff --git a/src/app/lightning.ts b/src/app/lightning.ts index d7f7fae2..fd7f4c86 100644 --- a/src/app/lightning.ts +++ b/src/app/lightning.ts @@ -1,16 +1,22 @@ +import {writable} from "svelte/store" import {nwc} from "@getalby/sdk" -import {session} from "@welshman/app" +import type {Maybe} from "@welshman/lib" +import {isNWCWallet, isWebLNWallet} from "@welshman/util" +import type {Wallet} from "@welshman/util" +import {withGetter} from "@welshman/store" + +export const wallet = withGetter(writable>(undefined)) export const getWebLn = () => (window as any).webln export const getNwcClient = () => { - const $session = session.get() + const $wallet = wallet.get() - if (!$session?.wallet || $session.wallet.type !== "nwc") { + if (!$wallet || !isNWCWallet($wallet)) { throw new Error("No NWC wallet is connected") } - const {info} = $session.wallet + const {info} = $wallet if (info.nostrWalletConnectUrl) { return new nwc.NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl}) @@ -20,17 +26,17 @@ export const getNwcClient = () => { } export const payInvoice = async (invoice: string, msats?: number) => { - const $session = session.get() + const $wallet = wallet.get() - if (!$session?.wallet) { + if (!$wallet) { throw new Error("No wallet is connected") } - if ($session.wallet.type === "nwc") { + if (isNWCWallet($wallet)) { const params: {invoice: string; amount?: number} = {invoice} if (msats) params.amount = msats return getNwcClient().payInvoice(params) - } else if ($session.wallet.type === "webln") { + } else if (isWebLNWallet($wallet)) { if (msats) throw new Error("Unable to pay zero invoices with webln") return getWebLn() .enable() @@ -47,9 +53,9 @@ export const createInvoice = async ({ sats, description = "Receive via lightning", }: CreateInvoiceParams) => { - const $session = session.get() + const $wallet = wallet.get() - if (!$session?.wallet) { + if (!$wallet) { throw new Error("No wallet is connected") } @@ -59,7 +65,7 @@ export const createInvoice = async ({ throw new Error("Invalid satoshi amount") } - if ($session.wallet.type === "nwc") { + if (isNWCWallet($wallet)) { const createdInvoice = await getNwcClient().makeInvoice({ amount: satAmount * 1000, description, @@ -72,7 +78,7 @@ export const createInvoice = async ({ return createdInvoice.invoice } - if ($session.wallet.type === "webln") { + if (isWebLNWallet($wallet)) { const webLn = getWebLn() if (!webLn) { diff --git a/src/app/management.ts b/src/app/management.ts new file mode 100644 index 00000000..a63c0c62 --- /dev/null +++ b/src/app/management.ts @@ -0,0 +1,61 @@ +import {derived, readable, writable} from "svelte/store" +import {ago, MINUTE, now, simpleCache} from "@welshman/lib" +import {ROOM_CREATE_PERMISSION, hexTags, tagValues} from "@welshman/util" +import {relayManagement, user} from "@app/core" +import {deriveEventsForUrl} from "@app/repository" + +export type BannedPubkeyItem = { + pubkey: string + reason: string +} + +export const deriveSpaceBannedPubkeyItems = (url: string) => { + const store = writable([]) + + relayManagement + .get() + .forUrl(url) + .listBannedPubkeys() + .then(({result = []}) => store.set(result)) + + return store +} + +// A relay only answers NIP-86 for its admins, so the methods it reports double as the user's +// permissions. This costs a signature and an http round trip, so share one store per url and +// only re-check when the answer has had time to go stale (or when a check failed). +export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefined]) => { + let checkedAt = 0 + + return readable([], set => { + if (url && checkedAt < ago(5, MINUTE)) { + checkedAt = now() + + relayManagement + .get() + .forUrl(url) + .supportedMethods() + .then(({result = []}) => set(result)) + .catch(error => { + checkedAt = 0 + console.error(error) + }) + } + }) +}) + +// User + +export const deriveUserIsSpaceAdmin = (url?: string) => + derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) + +export const deriveUserCanCreateRoom = (url: string) => + derived( + [ + user, + deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}]), + deriveUserIsSpaceAdmin(url), + ], + ([$user, $events, $isAdmin]) => + $isAdmin || $events.some(event => tagValues(hexTags("p"), event.tags).includes($user.pubkey)), + ) diff --git a/src/app/members.ts b/src/app/members.ts deleted file mode 100644 index 1f9aea82..00000000 --- a/src/app/members.ts +++ /dev/null @@ -1,505 +0,0 @@ -import {derived, readable, writable} from "svelte/store" -import { - ManagementMethod, - RELAY_ADD_MEMBER, - RELAY_JOIN, - RELAY_LEAVE, - RELAY_MEMBERS, - RELAY_REMOVE_MEMBER, - ROOM_ADD_MEMBER, - ROOM_ADMINS, - ROOM_CREATE_PERMISSION, - ROOM_JOIN, - ROOM_LEAVE, - ROOM_MEMBERS, - ROOM_REMOVE_MEMBER, - getPubkeyTagValues, - getTags, - getTagValue, - getTagValues, - sortEventsAsc, -} from "@welshman/util" -import type {Filter, ManagementRequest, PublishedRoomMeta, TrustedEvent} from "@welshman/util" -import {ago, first, MINUTE, now, simpleCache, sortBy, spec, uniq} from "@welshman/lib" -import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app" -import {load} from "@welshman/net" -import {get} from "svelte/store" -import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository" - -export const deriveSpaceMembers = (url: string) => - derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) => - uniq(getTagValues("member", event?.tags ?? [])), - ) - -export const RELAY_ROLE = 33534 - -export type SpaceRole = { - id: string - label: string - description: string - color: number // hue, 0 to 360 - order: number -} - -// Defaults for the components the client supplies, chosen to read on both themes. -const DEFAULT_SATURATION = 0.7 -const DEFAULT_LIGHTNESS = 0.5 - -// Parse the hue from a ["color", hue] tag, falling back to 0. -export const parseRoleColor = (tags: string[][]): number => { - const hue = parseFloat(getTagValue("color", tags) ?? "") - - return isNaN(hue) ? 0 : hue -} - -// Build an hsl() string from a role's hue, using our default saturation and lightness. -export const roleColor = (hue: number) => - `hsl(${hue}, ${DEFAULT_SATURATION * 100}%, ${DEFAULT_LIGHTNESS * 100}%)` - -// A translucent tint of the role color for use as a background fill. -export const roleColorSoft = (hue: number) => - `hsl(${hue}, ${DEFAULT_SATURATION * 100}%, ${DEFAULT_LIGHTNESS * 100}%, 0.15)` - -export const deriveSpaceRoles = (url: string) => - derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_ROLE]}]), $events => { - const roles: SpaceRole[] = [] - - for (const event of $events) { - const id = getTagValue("d", event.tags) - - if (id) { - roles.push({ - id, - label: getTagValue("label", event.tags) ?? "", - description: getTagValue("description", event.tags) ?? "", - color: parseRoleColor(event.tags), - order: Math.max(1, parseInt(getTagValue("order", event.tags) ?? "1", 10) || 1), - }) - } - } - - return sortBy(r => [r.order, r.label] as [number, string], roles) - }) - -// Map parsed from EXTRA values on ["member", pubkey, ...roleIds] tags -export const deriveSpaceMemberRoles = (url: string) => - derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) => { - const memberRoles = new Map() - - if (event) { - for (const tag of getTags("member", event.tags)) { - const pubkey = tag[1] - const roleIds = tag.slice(2) - - if (pubkey) { - memberRoles.set(pubkey, roleIds) - } - } - } - - return memberRoles - }) - -// Pull the relay's freshly-signed role event so the UI reflects the change immediately, -// rather than waiting for the live subscription to catch up. -const reloadRole = (url: string, id: string) => - load({relays: [url], filters: [{kinds: [RELAY_ROLE], "#d": [id]}]}) - -// Roles extend NIP-86: these methods aren't in welshman's ManagementMethod, and hue and -// order go over the wire as JSON numbers, not strings, per the relay's implementation. -const manageRole = (url: string, method: string, params: (string | number)[]) => - manageRelay(url, {method, params} as ManagementRequest) - -export const createRole = async ( - url: string, - id: string, - label: string, - description: string, - color: number, - order: number, -): Promise => { - const {error} = await manageRole(url, "createrole", [id, label, description, color, order]) - - if (!error) { - await reloadRole(url, id) - } - - return error -} - -export const editRole = async ( - url: string, - id: string, - label: string, - description: string, - color: number, - order: number, -): Promise => { - const {error} = await manageRole(url, "editrole", [id, label, description, color, order]) - - if (!error) { - await reloadRole(url, id) - } - - return error -} - -export const deleteRole = async (url: string, id: string): Promise => { - const {error} = await manageRole(url, "deleterole", [id]) - - return error -} - -export const assignRole = async ( - url: string, - pubkey: string, - roleId: string, -): Promise => { - const {error} = await manageRole(url, "assignrole", [pubkey, roleId]) - - return error -} - -export const unassignRole = async ( - url: string, - pubkey: string, - roleId: string, -): Promise => { - const {error} = await manageRole(url, "unassignrole", [pubkey, roleId]) - - return error -} - -export const deriveRoomMembers = (url: string, h: string) => { - const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}] - - return derived(deriveEventsForUrl(url, filters), ([event]) => - uniq(getPubkeyTagValues(event?.tags ?? [])), - ) -} - -export type BannedPubkeyItem = { - pubkey: string - reason: string -} - -export const spaceBannedPubkeyItems = new Map() - -export const deriveSpaceBannedPubkeyItems = (url: string) => { - const store = writable(spaceBannedPubkeyItems.get(url) || []) - - manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => { - spaceBannedPubkeyItems.set(url, res.result) - store.set(res.result) - }) - - return store -} - -export const deriveRoomAdmins = (url: string, h: string) => { - const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}] - - return derived(deriveEventsForUrl(url, filters), $events => { - const adminsEvent = first($events) - - if (adminsEvent) { - return getPubkeyTagValues(adminsEvent.tags) - } - - return [] - }) -} - -export const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => { - const members = new Set() - - for (const event of sortEventsAsc(events)) { - if (event.kind === ROOM_MEMBERS && getTagValue("d", event.tags) === h) { - members.clear() - - for (const pubkey of uniq(getPubkeyTagValues(event.tags))) { - members.add(pubkey) - } - - continue - } - - if (getTagValue("h", event.tags) !== h) { - continue - } - - const pubkeys = getPubkeyTagValues(event.tags) - - if (event.kind === ROOM_ADD_MEMBER) { - for (const pubkey of pubkeys) { - members.add(pubkey) - } - } - - if (event.kind === ROOM_REMOVE_MEMBER) { - for (const pubkey of pubkeys) { - members.delete(pubkey) - } - } - } - - return Array.from(members) -} - -// User membership status - -export enum MembershipStatus { - Initial, - Pending, - Granted, -} - -// This costs a signature and an http round trip, so share one store per url and only -// re-check when the answer has had time to go stale (or when a check failed). -export const deriveUserIsSpaceAdmin = simpleCache(([url]: [string | undefined]) => { - let checkedAt = 0 - - return readable(false, set => { - if (url && checkedAt < ago(5, MINUTE)) { - checkedAt = now() - - manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}) - .then(({result}) => set(Boolean(result?.length))) - .catch(error => { - checkedAt = 0 - console.error(error) - }) - } - }) -}) - -export const deriveUserSpaceMembershipStatus = (url: string) => { - // Fetch member list and user add/remove events directly in this derivation. - const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}] - const userEventFilters: Filter[] = [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}] - - return derived( - [ - pubkey, - deriveRelaySignedEvents(url, memberListFilters), - deriveRelaySignedEvents(url, userEventFilters), - deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]), - deriveUserIsSpaceAdmin(url), - ], - ([$pubkey, $memberListEvents, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => { - // If admin, always granted. - if ($isAdmin) { - return MembershipStatus.Granted - } - - const membersEvent = $memberListEvents.find(spec({kind: RELAY_MEMBERS})) - const memberList = membersEvent ? uniq(getTagValues("member", membersEvent.tags)) : undefined - - let isMember = false - - if (memberList) { - // Member list exists - check if user is in it. - isMember = memberList.includes($pubkey!) - } else { - // No member list available - replay the user's add/remove history. - for (const event of sortBy(e => e.created_at, $userAddRemoveEvents)) { - if (event.pubkey !== $pubkey) { - continue - } - - if (event.kind === RELAY_ADD_MEMBER) { - isMember = true - } else if (event.kind === RELAY_REMOVE_MEMBER) { - isMember = false - } - } - } - - for (const event of $joinLeaveEvents) { - // Join events indicate pending or granted status, leave resets to initial. - if (event.pubkey !== $pubkey) { - continue - } - - if (event.kind === RELAY_JOIN) { - return isMember ? MembershipStatus.Granted : MembershipStatus.Pending - } - - if (event.kind === RELAY_LEAVE) { - return MembershipStatus.Initial - } - } - - return isMember ? MembershipStatus.Granted : MembershipStatus.Initial - }, - ) -} - -export const deriveUserIsRoomAdmin = (url: string, h: string) => - derived( - [pubkey, deriveRoomAdmins(url, h), deriveUserIsSpaceAdmin(url)], - ([$pubkey, $admins, $isSpaceAdmin]) => $isSpaceAdmin || $admins.includes($pubkey!), - ) - -export const deriveUserRoomMembershipStatus = (url: string, h: string) => { - // Fetch the room member list and the current user's add/remove events. - const userEventFilters: Filter[] = [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}] - const joinLeaveFilters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}] - - return derived( - [ - pubkey, - deriveRoomMembers(url, h), - deriveEventsForUrl(url, userEventFilters), - deriveEventsForUrl(url, joinLeaveFilters), - deriveUserIsRoomAdmin(url, h), - ], - ([$pubkey, $memberList, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => { - // If admin of this room's space, always granted. - if ($isAdmin) { - return MembershipStatus.Granted - } - - let isMember = false - - if ($memberList) { - // Member list exists - check if user is in it. - isMember = $memberList.includes($pubkey!) - } else { - // No member list available - replay the user's add/remove history. - for (const event of sortEventsAsc($userAddRemoveEvents)) { - if (event.pubkey !== $pubkey) { - continue - } - - if (event.kind === ROOM_ADD_MEMBER) { - isMember = true - } else if (event.kind === ROOM_REMOVE_MEMBER) { - isMember = false - } - } - } - - for (const event of $joinLeaveEvents) { - // Join events indicate pending or granted status, leave resets to initial. - if (event.pubkey !== $pubkey) { - continue - } - - if (event.kind === ROOM_JOIN) { - return isMember ? MembershipStatus.Granted : MembershipStatus.Pending - } - - if (event.kind === ROOM_LEAVE) { - return MembershipStatus.Initial - } - } - - return isMember ? MembershipStatus.Granted : MembershipStatus.Initial - }, - ) -} - -export const deriveUserCanCreateRoom = (url: string) => { - const filters: Filter[] = [{kinds: [ROOM_CREATE_PERMISSION]}] - - return derived( - [pubkey, deriveEventsForUrl(url, filters), deriveUserIsSpaceAdmin(url)], - ([$pubkey, $events, $isAdmin]) => { - for (const event of $events) { - if (getPubkeyTagValues(event.tags).includes($pubkey!)) { - return true - } - } - - return $isAdmin - }, - ) -} - -export const addSpaceMembers = async ( - url: string, - pubkeys: string[], -): Promise => { - const spaceMembers = get(deriveSpaceMembers(url)) - - const results = await Promise.all( - pubkeys - .filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey)) - .map(pubkey => - manageRelay(url, { - method: ManagementMethod.AllowPubkey, - params: [pubkey], - }), - ), - ) - - for (const {error} of results) { - if (error) { - return error - } - } -} - -export const removeSpaceMembers = async ( - url: string, - pubkeys: string[], -): Promise => { - const results = await Promise.all( - pubkeys.map(pubkey => - manageRelay(url, { - method: ManagementMethod.UnallowPubkey, - params: [pubkey], - }), - ), - ) - - for (const {error} of results) { - if (error) { - return error - } - } -} - -export const banSpaceMembers = async ( - url: string, - pubkeys: string[], - reason = "", -): Promise => { - const results = await Promise.all( - pubkeys.map(pubkey => - manageRelay(url, { - method: ManagementMethod.BanPubkey, - params: reason ? [pubkey, reason] : [pubkey], - }), - ), - ) - - for (const {error} of results) { - if (error) { - return error - } - } -} - -export const addRoomMembers = async ( - url: string, - room: PublishedRoomMeta, - pubkeys: string[], -): Promise => { - const error = await addSpaceMembers(url, pubkeys) - - if (error) { - return error - } - - const errors = await Promise.all( - pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, room, pubkey))), - ) - - for (const error of errors) { - if (error) { - return error - } - } -} diff --git a/src/app/nip46.ts b/src/app/nip46.ts index 5388c0dd..76c9d044 100644 --- a/src/app/nip46.ts +++ b/src/app/nip46.ts @@ -1,3 +1,4 @@ +import {get, writable} from "svelte/store" import { CLASSIFIED, CLIENT_AUTH, @@ -10,7 +11,6 @@ import { WRAP, ZAP_REQUEST, } from "@welshman/util" -import {get, writable} from "svelte/store" import type {Nip46ResponseWithResult} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer" import {makeSecret} from "@welshman/util" diff --git a/src/app/notifications.ts b/src/app/notifications.ts index 5c0e7231..5aa847e7 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -1,26 +1,26 @@ import {derived, get, writable} from "svelte/store" import {Badge} from "@capawesome/capacitor-badge" -import {synced, throttled, withGetter} from "@welshman/store" -import {pubkey, signer, tracker, repository, relaysByUrl} from "@welshman/app" +import {page} from "$app/stores" import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib" import type {SignedEvent, TrustedEvent} from "@welshman/util" -import {deriveEventsByIdByUrl} from "@welshman/store" import { sortEventsDesc, - getTagValue, + tagSpec, + tagValue, MESSAGE, makeHttpAuth, makeHttpAuthHeader, } from "@welshman/util" +import {synced, throttled, withGetter} from "@welshman/store" +import {Relays, RoomLists} from "@welshman/app" +import {deriveEventsByIdByUrl} from "@app/repository" +import {app, fromApp} from "@app/core" import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/routes" import {CONTENT_KINDS, makeCommentFilter} from "@app/content" import {notificationSettings} from "@app/settings" import {chatsById} from "@app/chats" -import {userGroupList, getSpaceUrlsFromGroupList} from "@app/groups" -import {hasNip29} from "@app/relays" import {dufflepud, DUFFLEPUD_URL, PLATFORM_RELAYS} from "@app/env" import {kv} from "@app/storage" -import {page} from "$app/stores" // Checked state @@ -84,7 +84,7 @@ const NIP98_MAX_AGE = 23 * 60 * 60 let nip98Auth: SignedEvent | undefined const nip98Header = async () => { - const $signer = signer.get() + const $signer = app.get().user?.signer if (!$signer) { return undefined @@ -148,11 +148,11 @@ const pushCheckedRemote = throttle(3000, async () => { export const syncCheckedRemote = () => { let ready = false - const unsubscribePubkey = pubkey.subscribe($pubkey => { + const unsubscribeUser = app.subscribe($app => { ready = false nip98Auth = undefined - if ($pubkey) { + if ($app.user) { pullCheckedRemote().then(() => { ready = true pushCheckedRemote() @@ -161,13 +161,13 @@ export const syncCheckedRemote = () => { }) const unsubscribeChecked = checked.subscribe(() => { - if (ready && pubkey.get()) { + if (ready && app.get().user?.pubkey) { pushCheckedRemote() } }) return () => { - unsubscribePubkey() + unsubscribeUser() unsubscribeChecked() } } @@ -179,23 +179,22 @@ export const allNotifications = derived( 1000, derived( [ - pubkey, + app, checked, chatsById, - relaysByUrl, - userGroupList, - deriveEventsByIdByUrl({ - tracker, - repository, - filters: [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)], - }), + fromApp($app => $app.use(Relays).index.$), + fromApp($app => $app.use(RoomLists).index.$), + deriveEventsByIdByUrl([ + {kinds: [MESSAGE, ...CONTENT_KINDS]}, + makeCommentFilter(CONTENT_KINDS), + ]), ], identity, ), ), - ([$pubkey, $checked, $chatsById, $relaysByUrl, $userGroupList, eventsByIdByUrl]) => { + ([$app, $checked, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { const hasNotification = (path: string, latestEvent?: TrustedEvent) => { - if (!latestEvent || latestEvent.pubkey === $pubkey) { + if (!latestEvent || latestEvent.pubkey === $app.user?.pubkey) { return false } @@ -224,15 +223,15 @@ export const allNotifications = derived( } } - const urls = - PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : getSpaceUrlsFromGroupList($userGroupList) + const roomList = $app.user?.pubkey ? $roomLists.get($app.user?.pubkey) : undefined + const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? []) for (const url of urls) { const spacePath = makeSpacePath(url) const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) - if (hasNip29($relaysByUrl.get(url))) { - for (const [h, [latestEvent]] of groupBy(e => getTagValue("h", e.tags), events)) { + if ($relays.get(url)?.hasNip(29)) { + for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { if (h) { const roomPath = makeRoomPath(url, h) diff --git a/src/app/pinboards.ts b/src/app/pinboards.ts index c9ce4b10..0dbc344a 100644 --- a/src/app/pinboards.ts +++ b/src/app/pinboards.ts @@ -1,236 +1,83 @@ -import {derived, readable} from "svelte/store" -import type {Readable} from "svelte/store" import * as nip19 from "nostr-tools/nip19" -import {now, randomId} from "@welshman/lib" -import { - Address, - DELETE, - asDecryptedEvent, - fromNostrURI, - getAddress, - getListTags, - getTagValue, - getTagValues, - isReplaceableKind, - readList, - sortEventsDesc, -} from "@welshman/util" +import {Address, fromNostrURI, getAddress, isReplaceable} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" +import type {PinReader, PinReference, PinWriter} from "@welshman/domain" import {isLink, parse} from "@welshman/content" -import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository" -import {signAsRelay} from "@app/relays" -export const BOARD = 30067 - -export const PIN = 39067 - -export type Board = { - event?: TrustedEvent - address: string - identifier: string - title: string - description: string - image: string - topics: string[] - collaborative: boolean -} - -export type PublishedBoard = Board & { - event: TrustedEvent -} - -export type Pin = { - event?: TrustedEvent - id: string - pubkey: string - identifier: string - boards: string[] - description: string - title: string - topics: string[] - // The pin's content reference: the first e/a/p/i tag found, e.g. ["e", ], - // ["a", ], ["p", ], or ["i", ]. Empty when the pin has no - // reference. - value: string[] -} - -export type PublishedPin = Pin & { - event: TrustedEvent -} - -export const readBoard = (event: TrustedEvent): PublishedBoard => { - const tags = getListTags(readList(asDecryptedEvent(event))) - - return { - event, - address: getAddress(event), - identifier: getTagValue("d", tags) ?? "", - title: getTagValue("title", tags) ?? "", - description: getTagValue("description", tags) ?? "", - image: getTagValue("image", tags) ?? "", - topics: getTagValues("t", tags), - collaborative: tags.some(tag => tag[0] === "collaborative"), - } -} - -export const readPin = (event: TrustedEvent): PublishedPin => ({ - event, - id: event.id, - pubkey: event.pubkey, - identifier: getTagValue("d", event.tags) ?? "", - boards: getTagValues("A", event.tags), - description: event.content, - title: getTagValue("title", event.tags) ?? "", - topics: getTagValues("t", event.tags), - value: event.tags.find(tag => ["e", "a", "p", "i"].includes(tag[0])) ?? [], -}) - -export const deriveBoards = (url: string) => - derived(deriveEventsForUrl(url, [{kinds: [BOARD]}]), $events => $events.map(readBoard)) - -export const deriveBoard = (url: string, identifier: string) => - derived(deriveRelaySignedEvents(url, [{kinds: [BOARD], "#d": [identifier]}]), ([event]) => - event ? readBoard(event) : undefined, - ) - -export const deriveBoardByAddress = ( - url: string, - address: string, -): Readable => - Address.isAddress(address) - ? deriveBoard(url, Address.from(address).identifier) - : readable(undefined) - -export const derivePins = (url: string, address: string) => - derived(deriveRelaySignedEvents(url, [{kinds: [PIN], "#A": [address]}]), $events => - sortEventsDesc($events).map(readPin), - ) - -const makeBoardTemplate = ({ - identifier, - title, - description, - image, - topics, - collaborative, -}: Omit) => ({ - kind: BOARD, - created_at: now(), - content: "", - tags: [ - ["d", identifier], - ["title", title], - ...(description ? [["description", description]] : []), - ...(image ? [["image", image]] : []), - ...topics.map(topic => ["t", topic]), - ...(collaborative ? [["collaborative"]] : []), - ], -}) - -const makePinTemplate = ({ - identifier = "", - boards = [], - description = "", - title = "", - topics = [], - value = [], -}: Partial) => ({ - kind: PIN, - created_at: now(), - content: description, - tags: [ - // Reuse the identifier when editing so the addressable event is replaced; - // fall back to a random one for new pins. - ["d", identifier || randomId()], - ...boards.map(board => ["A", board]), - ...(value.length ? [value] : []), - ...(title ? [["title", title]] : []), - ...topics.map(topic => ["t", topic]), - ], -}) - -const makeDeleteTemplate = (tags: string[][]) => ({ - kind: DELETE, - created_at: now(), - content: "", - tags, -}) - -export const editBoard = (url: string, board: Board) => signAsRelay(url, makeBoardTemplate(board)) - -export const createPin = (url: string, pin: Partial) => signAsRelay(url, makePinTemplate(pin)) - -export const deletePin = (url: string, id: string) => - signAsRelay( - url, - makeDeleteTemplate([ - ["e", id], - ["k", String(PIN)], - ]), - ) - -export const deleteBoard = (url: string, address: string) => - signAsRelay( - url, - makeDeleteTemplate([ - ["a", address], - ["k", String(BOARD)], - ]), - ) - -const valueToReference = (value: string[]): string => { - const [type, data = "", relay] = value - const relays = relay ? [relay] : [] - - if (type === "e") return nip19.neventEncode({id: data, relays}) - - if (type === "p") return nip19.nprofileEncode({pubkey: data, relays}) - - if (type === "a") { - const {kind, pubkey, identifier} = Address.from(data) - - return nip19.naddrEncode({kind, pubkey, identifier, relays}) +const encodeReference = (reference: PinReference) => { + if (reference.type === "event") { + return nip19.neventEncode({id: reference.id, relays: reference.relay ? [reference.relay] : []}) } - return data + if (reference.type === "address") { + const {kind, pubkey, identifier} = Address.from(reference.address) + + return nip19.naddrEncode({ + kind, + pubkey, + identifier, + relays: reference.relay ? [reference.relay] : [], + }) + } + + return reference.id } -export const pinToReference = (pin: Pin): string => valueToReference(pin.value) +export const pinToReference = (pin: PinReader) => { + const reference = pin.reference() -export const eventToReference = (event: TrustedEvent): string => - valueToReference(isReplaceableKind(event.kind) ? ["a", getAddress(event)] : ["e", event.id]) + return reference ? encodeReference(reference) : "" +} -export const referenceToPin = (reference: string): Partial | undefined => { +export const eventToReference = (event: TrustedEvent) => + encodeReference( + isReplaceable(event) + ? {type: "address", address: getAddress(event)} + : {type: "event", id: event.id}, + ) + +// Point a pin at a user-entered nostr link or external url, false if it's neither. +export const setPinReference = (writer: PinWriter, reference: string) => { const trimmed = reference.trim() try { const decoded = nip19.decode(fromNostrURI(trimmed)) - if (decoded.type === "note") return {value: ["e", decoded.data]} - if (decoded.type === "npub") return {value: ["p", decoded.data]} + if (decoded.type === "note") { + writer.setEvent(decoded.data) + return true + } + if (decoded.type === "nevent") { - const {id, relays} = decoded.data - - return {value: relays?.[0] ? ["e", id, relays[0]] : ["e", id]} + writer.setEvent(decoded.data.id, decoded.data.relays?.[0]) + return true } - if (decoded.type === "nprofile") { - const {pubkey, relays} = decoded.data - return {value: relays?.[0] ? ["p", pubkey, relays[0]] : ["p", pubkey]} + // Pins have no pubkey reference type, so a person gets stored as an external + // id — keep the bech32 the user gave us so it round-trips and still embeds. + if (decoded.type === "npub" || decoded.type === "nprofile") { + writer.setExternal(fromNostrURI(trimmed)) + return true } + if (decoded.type === "naddr") { const {kind, pubkey, identifier, relays} = decoded.data - const coordinate = `${kind}:${pubkey}:${identifier}` - return {value: relays?.[0] ? ["a", coordinate, relays[0]] : ["a", coordinate]} + writer.setAddress(new Address(kind, pubkey, identifier).toString(), relays?.[0]) + return true } - return undefined + return false } catch { // Not a nostr entity; fall through to external url handling. } const parsed = parse({content: trimmed}) - return parsed.length === 1 && isLink(parsed[0]) ? {value: ["i", trimmed]} : undefined + if (parsed.length === 1 && isLink(parsed[0])) { + writer.setExternal(trimmed) + return true + } + + return false } diff --git a/src/app/pins.ts b/src/app/pins.ts deleted file mode 100644 index e1844ff2..00000000 --- a/src/app/pins.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {derived} from "svelte/store" -import type {Readable} from "svelte/store" -import {makeEvent, getTagValues} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import {publishThunk, waitForThunkError} from "@welshman/app" -import {load} from "@welshman/net" -import {deriveEventsForUrl, deriveLatestEventForUrl} from "@app/repository" -import {PROTECTED} from "@app/groups" -import {canEnforceNip70} from "@app/relays" - -// NIP-29 proposed pin list kinds (see https://github.com/nostr-protocol/nips/pull/2379) -export const ROOM_PINS = 39005 -export const ROOM_UPDATE_PIN_LIST = 9010 - -const pinListFilters = (h: string) => [{kinds: [ROOM_PINS], "#d": [h]}] - -export const deriveRoomPinIds = (url: string, h: string) => - derived(deriveLatestEventForUrl(url, pinListFilters(h)), event => - getTagValues("e", event?.tags ?? []), - ) - -export const deriveRoomPinnedEvents = ( - url: string, - pinIds: Readable, -): Readable => - derived(pinIds, ($pinIds, set) => { - if ($pinIds.length === 0) { - set([] as TrustedEvent[]) - - return () => {} - } - - const events = deriveEventsForUrl(url, [{ids: $pinIds}]) - - const unsub = events.subscribe($events => { - const byId = new Map($events.map(event => [event.id, event])) - - set( - $pinIds.flatMap(id => { - const event = byId.get(id) - - return event ? [event] : [] - }), - ) - }) - - return () => unsub() - }) - -export const loadRoomPinList = (url: string, h: string, signal?: AbortSignal) => - load({relays: [url], signal, filters: pinListFilters(h)}) - -export const loadRoomPinnedMessages = (url: string, ids: string[], signal?: AbortSignal) => { - if (ids.length === 0) { - return - } - - return load({relays: [url], signal, filters: [{ids}]}) -} - -export const updateRoomPins = async (url: string, h: string, eventIds: string[]) => { - const tags: string[][] = [["h", h], ...eventIds.map(id => ["e", id])] - - if (await canEnforceNip70(url)) { - tags.push(PROTECTED) - } - - const event = makeEvent(ROOM_UPDATE_PIN_LIST, { - content: "", - tags, - }) - - return waitForThunkError(publishThunk({relays: [url], event})) -} - -export const pinRoomMessage = async ( - url: string, - h: string, - eventId: string, - currentIds: string[], -) => { - if (currentIds.includes(eventId)) { - return undefined - } - - return updateRoomPins(url, h, [...currentIds, eventId]) -} - -export const unpinRoomMessage = async ( - url: string, - h: string, - eventId: string, - currentIds: string[], -) => - updateRoomPins( - url, - h, - currentIds.filter(id => id !== eventId), - ) diff --git a/src/app/policies.ts b/src/app/policies.ts index 31c4570b..e2d4358f 100644 --- a/src/app/policies.ts +++ b/src/app/policies.ts @@ -1,9 +1,9 @@ import {get, writable} from "svelte/store" -import {on, call, dissoc, assoc, uniq} from "@welshman/lib" -import {RelayMode} from "@welshman/util" +import {on, call, dissoc, assoc, noop, uniq} from "@welshman/lib" +import {isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util" import type {Socket, RelayMessage, ClientMessage} from "@welshman/net" import { - makeSocketPolicyAuth, + AuthStatus, SocketEvent, isRelayEvent, isRelayOk, @@ -15,10 +15,19 @@ import { isClientNegOpen, isClientNegClose, } from "@welshman/net" -import {sign, pubkey, thunks, getPubkeyRelays} from "@welshman/app" +import {merged} from "@welshman/store" +import { + BlockedRelayLists, + MessagingRelayLists, + RelayLists, + RoomLists, + Thunks, + makeAppPolicyAuth, +} from "@welshman/app" +import type {AppPolicy, IApp} from "@welshman/app" +import {app, appPolicies} from "@app/core" import {BLOCKED_RELAYS} from "@app/env" import {userSettingsValues, getSetting, RelayAuthMode} from "@app/settings" -import {userSpaceUrls} from "@app/groups" // Relays sending events with empty signatures that the user has to choose to trust export const relaysPendingTrust = writable([]) @@ -26,31 +35,52 @@ export const relaysPendingTrust = writable([]) // Relays that mostly send restricted responses to requests and events export const relaysMostlyRestricted = writable>({}) -export const authPolicy = makeSocketPolicyAuth({ - sign, - shouldAuth: (socket: Socket) => { - const $pubkey = pubkey.get() - const mode = getSetting("relay_auth") +// Welshman's default ingest policy drops anything that fails signature verification, but relays +// the user has explicitly trusted are allowed to send events with an empty signature. +export const ingestPolicy: AppPolicy = app => + app.pool.subscribe(socket => { + const onReceive = (message: RelayMessage) => { + if (isRelayEvent(message)) { + const event = message[2] + const trusted = getSetting("trusted_relays").includes(socket.url) - if (!$pubkey) return false - if (mode === RelayAuthMode.Aggressive) return true - if (get(userSpaceUrls).includes(socket.url)) return true - if (getPubkeyRelays($pubkey).includes(socket.url)) return true - if (get(thunks).some(t => t.options.relays.includes(socket.url))) return true - if (getPubkeyRelays($pubkey, RelayMode.Messaging).includes(socket.url)) return true + if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return + if (!trusted && !verifyEvent(event)) return - return false - }, + app.tracker.track(event.id, socket.url) + app.repository.publish(event) + } + } + + socket.on(SocketEvent.Receive, onReceive) + + return () => socket.off(SocketEvent.Receive, onReceive) + }) + +// Welshman's appPolicyAuthUnlessBlocked, plus the conservative mode: only identify to relays +// the user already has a relationship with. +export const authPolicy = makeAppPolicyAuth((socket, $app) => { + const $pubkey = app.get().user?.pubkey + + if (!$pubkey) return false + if ($app.use(BlockedRelayLists).urls($pubkey).get().includes(socket.url)) return false + if (getSetting("relay_auth") === RelayAuthMode.Aggressive) return true + if ($app.use(RoomLists).urls($pubkey).get().includes(socket.url)) return true + if ($app.use(RelayLists).urls($pubkey).get().includes(socket.url)) return true + if (get($app.use(Thunks).history).some(t => t.options.relays.includes(socket.url))) return true + if ($app.use(MessagingRelayLists).urls($pubkey).get().includes(socket.url)) return true + + return false }) -export const blockPolicy = (socket: Socket) => { +const makeBlockPolicy = ($app: IApp) => (socket: Socket) => { const previousOpen = socket.open socket.open = () => { - const $pubkey = pubkey.get() + const $pubkey = $app.user?.pubkey if (BLOCKED_RELAYS.includes(socket.url)) return - if ($pubkey && getPubkeyRelays($pubkey, RelayMode.Blocked).includes(socket.url)) return + if ($pubkey && $app.use(BlockedRelayLists).urls($pubkey).get().includes(socket.url)) return previousOpen() } @@ -60,7 +90,7 @@ export const blockPolicy = (socket: Socket) => { } } -export const trustPolicy = (socket: Socket) => { +const trustPolicy = (socket: Socket) => { const buffer: RelayMessage[] = [] const unsubscribers = [ @@ -76,7 +106,7 @@ export const trustPolicy = (socket: Socket) => { // the receive queue. If trust status is undefined, buffer it for later. on(socket, SocketEvent.Receiving, (message: RelayMessage) => { if (isRelayEvent(message) && !message[2]?.sig) { - const isTrusted = getSetting("trusted_relays").includes(socket.url) + const isTrusted = getSetting("trusted_relays").includes(socket.url) if (!isTrusted) { buffer.push(message) @@ -92,7 +122,7 @@ export const trustPolicy = (socket: Socket) => { } } -export const mostlyRestrictedPolicy = (socket: Socket) => { +const mostlyRestrictedPolicy = (socket: Socket) => { let total = 0 let restricted = 0 @@ -173,3 +203,17 @@ export const mostlyRestrictedPolicy = (socket: Socket) => { unsubscribers.forEach(call) } } + +// Socket policies are installed on the pool rather than the app, so wrap them in an app policy +// to get the same construction/cleanup lifecycle as everything else. +export const socketPolicy: AppPolicy = $app => { + const policies = [makeBlockPolicy($app), trustPolicy, mostlyRestrictedPolicy] + + $app.pool.socketPolicies.push(...policies) + + return () => { + $app.pool.socketPolicies = $app.pool.socketPolicies.filter(p => !policies.includes(p)) + } +} + +appPolicies.push(ingestPolicy, authPolicy, socketPolicy) diff --git a/src/app/polls.ts b/src/app/polls.ts deleted file mode 100644 index e66de38c..00000000 --- a/src/app/polls.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {now, removeUndefined, uniq} from "@welshman/lib" -import type {TrustedEvent} from "@welshman/util" -import {POLL_RESPONSE, getTagValue, getTags, getTagValues, makeEvent} from "@welshman/util" -import {publishThunk} from "@welshman/app" - -export type PollType = "singlechoice" | "multiplechoice" - -export type PollOption = { - id: string - label: string - votes: number -} - -export const getPollType = (event: TrustedEvent): PollType => - getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice" - -export const getPollOptions = (event: TrustedEvent) => - removeUndefined( - getTags("option", event.tags).map(tag => { - const [, id, label = id] = tag - - if (!id) return undefined - - return {id, label} - }), - ) - -export const getPollEndsAt = (event: TrustedEvent) => { - const endsAt = getTagValue("endsAt", event.tags) - - if (!endsAt) return undefined - - const timestamp = parseInt(endsAt) - - return Number.isNaN(timestamp) ? undefined : timestamp -} - -export const isPollClosed = (event: TrustedEvent) => { - const endsAt = getPollEndsAt(event) - - return typeof endsAt === "number" ? endsAt <= now() : false -} - -export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => { - const selections = getTagValues("response", event.tags) - - return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections) -} - -export const getPollResults = (event: TrustedEvent, responses: TrustedEvent[]) => { - const options = getPollOptions(event).map(option => ({...option, votes: 0})) - const counts = new Map(options.map(option => [option.id, option])) - const latestByPubkey = new Map() - - for (const response of responses) { - const current = latestByPubkey.get(response.pubkey) - - if (!current || response.created_at > current.created_at) { - latestByPubkey.set(response.pubkey, response) - } - } - - for (const response of latestByPubkey.values()) { - for (const optionId of getPollResponseSelections(response, getPollType(event))) { - const option = counts.get(optionId) - - if (option) { - option.votes += 1 - } - } - } - - return { - options, - voters: latestByPubkey.size, - } -} - -export type PollResponseParams = { - event: TrustedEvent - selectedIds: string[] -} - -export const makePollResponse = ({event, selectedIds}: PollResponseParams) => - makeEvent(POLL_RESPONSE, { - content: "", - tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])], - }) - -export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) => - publishThunk({event: makePollResponse(params), relays}) diff --git a/src/app/pomade.ts b/src/app/pomade.ts index 3709a60e..aea4d784 100644 --- a/src/app/pomade.ts +++ b/src/app/pomade.ts @@ -1,13 +1,30 @@ -import {get} from "svelte/store" import {Client, type SessionItem, type ClientOptions} from "@pomade/core" import {ifLet, reject, spec} from "@welshman/lib" -import {session, isPomadeSession, loginWithPomade as _loginWithPomade} from "@welshman/app" +import type {Maybe} from "@welshman/lib" +import {pomade, toSession} from "@welshman/app" +import type {Session} from "@welshman/app" +import {login, session} from "@app/core" + +export type PomadeSession = Session<"pomade", {clientOptions: ClientOptions; email: string}> + +export const isPomadeSession = ($session: Maybe): $session is PomadeSession => + $session?.method === pomade.method + +// The pomade-only screens are only reachable from a pomade session, so narrow rather than +// thread undefined through every field they read off it. +export const requirePomadeSession = ($session: Maybe) => { + if (!isPomadeSession($session)) { + throw new Error("This action requires an email login") + } + + return $session +} export const getPomadeClient = async () => { - const $session = get(session) + const $session = session.get() if (isPomadeSession($session)) { - return new Client($session.clientOptions) + return new Client($session.data.clientOptions) } } @@ -75,7 +92,7 @@ export const deleteDeactivatedPomadeSessions = async () => { } export const loginWithPomade = (clientOptions: ClientOptions, email: string) => - _loginWithPomade(clientOptions.group.group_pk.slice(2), email, clientOptions) + login(toSession(pomade, {clientOptions, email})) export const POMADE_INVALID_LOGIN_MESSAGE = "Invalid login information" export const POMADE_NETWORK_ERROR_MESSAGE = "Network error, please try again" diff --git a/src/app/profiles.ts b/src/app/profiles.ts deleted file mode 100644 index 947027d0..00000000 --- a/src/app/profiles.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {get} from "svelte/store" -import { - FOLLOWS, - MESSAGING_RELAYS, - PROFILE, - RELAYS, - createProfile, - editProfile, - isPublishedProfile, - isSignedEvent, - makeEvent, -} from "@welshman/util" -import type {Profile} from "@welshman/util" -import {nthNe} from "@welshman/lib" -import {publishThunk, pubkey, repository} from "@welshman/app" -import {Router} from "@welshman/router" -import {userSpaceUrls} from "@app/groups" - -export const broadcastUserData = async (relays: string[]) => { - const authors = [pubkey.get()!] - const kinds = [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE] - const events = repository.query([{kinds, authors}]) - - for (const event of events) { - if (isSignedEvent(event)) { - await publishThunk({event, relays}).complete - } - } -} - -export const updateProfile = ({profile}: {profile: Profile}) => { - const router = Router.get() - const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) - const scenarios = [router.FromRelays(get(userSpaceUrls)), router.FromUser(), router.Index()] - - // Remove protected tag, we used to add it - template.tags = template.tags.filter(nthNe(0, "-")) - - const event = makeEvent(template.kind, template) - const relays = router.merge(scenarios).getUrls() - - return publishThunk({event, relays}) -} diff --git a/src/app/push/adapters/android.ts b/src/app/push/adapters/android.ts index ab2899ab..7a642c19 100644 --- a/src/app/push/adapters/android.ts +++ b/src/app/push/adapters/android.ts @@ -1,10 +1,10 @@ import {throttle} from "throttle-debounce" import {App} from "@capacitor/app" import {registerPlugin} from "@capacitor/core" -import {session} from "@welshman/app" -import type {Session} from "@welshman/app" import {maybe, now} from "@welshman/lib" import type {Filter} from "@welshman/util" +import type {Session} from "@welshman/app" +import {app, session} from "@app/core" import {pushState} from "@app/push/adapters/common" import type {IPushAdapter} from "@app/push/adapters/common" import {requestPermissions, syncRelaySubscriptions} from "@app/push/adapters/common" @@ -16,7 +16,10 @@ type AndroidFallbackSubscription = { ignore: Array } +// A Session no longer carries the pubkey it belongs to, but the worker needs it to build auth +// events, so it's passed alongside. Keep this in sync with AndroidPushFallbackWorker.kt. type AndroidPushFallbackState = { + pubkey?: string session?: Session activeSince?: number subscriptions?: Array @@ -44,6 +47,7 @@ export class AndroidFallbackNotifications implements IPushAdapter { const doSync = throttle(1000, () => { AndroidPushFallback.syncState({ state: { + pubkey: app.get().user?.pubkey, session: session.get(), activeSince: this._activeSince, subscriptions: Array.from(this._subscriptions.values()), diff --git a/src/app/push/adapters/capacitor.ts b/src/app/push/adapters/capacitor.ts index f1159ddc..0cbd5a74 100644 --- a/src/app/push/adapters/capacitor.ts +++ b/src/app/push/adapters/capacitor.ts @@ -1,21 +1,14 @@ -import {get} from "svelte/store" import {Capacitor} from "@capacitor/core" import {PushNotifications} from "@capacitor/push-notifications" -import { - pubkey, - publishThunk, - loadRelay, - waitForThunkError, - userMessagingRelayList, -} from "@welshman/app" import {assoc, hash, maybe} from "@welshman/lib" import type {Filter} from "@welshman/util" -import {DELETE, getRelaysFromList, makeEvent, Address} from "@welshman/util" +import {Address, DELETE, makeEvent} from "@welshman/util" +import {Relays, User} from "@welshman/app" import {buildUrl} from "@lib/util" +import {app, messagingRelayLists, roomLists, thunks} from "@app/core" +import {device} from "@app/device" import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env" import {pushState} from "@app/push/adapters/common" -import {userSpaceUrls} from "@app/groups" -import {device} from "@app/device" import type {IPushAdapter} from "@app/push/adapters/common" import { onPushNotificationAction, @@ -84,9 +77,9 @@ export class CapacitorNotifications implements IPushAdapter { _getPushUrl = async (url: string) => { for (const candidate of [url, PUSH_BRIDGE]) { - const relay = await loadRelay(candidate) + const relay = await app.get().use(Relays).load(candidate) - if (relay?.supported_nips?.map(String)?.includes("9a")) { + if (relay?.hasNip("9a")) { return candidate } } @@ -109,7 +102,7 @@ export class CapacitorNotifications implements IPushAdapter { const identifier = this._getSubscriptionIdentifier(relay, key) - const thunk = publishThunk({ + const thunk = thunks.get().publish({ relays: [url], event: makeEvent(30390, { tags: [ @@ -122,7 +115,7 @@ export class CapacitorNotifications implements IPushAdapter { }), }) - const error = await waitForThunkError(thunk) + const error = await thunk.waitForError() if (error) { console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error) @@ -139,9 +132,9 @@ export class CapacitorNotifications implements IPushAdapter { const relays = [url] const identifier = this._getSubscriptionIdentifier(relay, key) - const address = new Address(30390, pubkey.get()!, identifier).toString() + const address = new Address(30390, User.require(app.get()).pubkey, identifier).toString() const event = makeEvent(DELETE, {tags: [["a", address]]}) - const error = await waitForThunkError(publishThunk({relays, event})) + const error = await thunks.get().publish({relays, event}).waitForError() if (error) { console.warn(`Failed to unsubscribe ${relay} from notifications:`, error) @@ -192,10 +185,24 @@ export class CapacitorNotifications implements IPushAdapter { pushState.set({}) - await Promise.all(get(userSpaceUrls).map(url => this._unsyncRelay(url, "spaces"))) + const $pubkey = app.get().user?.pubkey - await Promise.all( - getRelaysFromList(get(userMessagingRelayList)).map(url => this._unsyncRelay(url, "messages")), - ) + if ($pubkey) { + await Promise.all( + roomLists + .get() + .urls($pubkey) + .get() + .map(url => this._unsyncRelay(url, "spaces")), + ) + + await Promise.all( + messagingRelayLists + .get() + .urls($pubkey) + .get() + .map(url => this._unsyncRelay(url, "messages")), + ) + } } } diff --git a/src/app/push/adapters/common.ts b/src/app/push/adapters/common.ts index 9e0e12b5..5ebad87e 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -1,6 +1,4 @@ -import {withGetter} from "@welshman/store" import {writable} from "svelte/store" -import {goto} from "$app/navigation" import type {Subscriber, Unsubscriber} from "svelte/store" import { PushNotifications, @@ -9,23 +7,24 @@ import { type Token, } from "@capacitor/push-notifications" import type {PluginListenerHandle} from "@capacitor/core" -import {pubkey, repository, tracker, userMessagingRelayList} from "@welshman/app" -import {merged} from "@welshman/store" +import {goto} from "$app/navigation" import {assoc, call, now, on, poll, spec, throttle} from "@welshman/lib" -import {load, LOCAL_RELAY_URL} from "@welshman/net" +import {LOCAL_RELAY_URL} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net" import { getIdFilters, - getRelaysFromList, - getTagValue, matchFilters, + tagSpec, + tagValue, MESSAGE, type Filter, type TrustedEvent, } from "@welshman/util" +import {merged, withGetter} from "@welshman/store" +import {User} from "@welshman/app" +import {app, messagingRelayLists, network, roomLists} from "@app/core" import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" import {notificationSettings, shouldNotify, userSettingsValues} from "@app/settings" -import {userSpaceUrls} from "@app/groups" import {getEventPath, goToSpace} from "@app/routes" export type PushSubscription = { @@ -66,17 +65,19 @@ export const onNotification = call(() => { subscribers.push(f) if (!unsubscribe) { - unsubscribe = on(repository, "update", ({added}: RepositoryUpdate) => { - const $pubkey = pubkey.get() + unsubscribe = on(app.get().repository, "update", ({added}: RepositoryUpdate) => { + const $pubkey = app.get().user?.pubkey for (const event of added) { if (event.pubkey == $pubkey) { continue } - const h = getTagValue("h", event.tags) + const h = tagValue(tagSpec("h"), event.tags) - if (Array.from(tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) { + if ( + Array.from(app.get().tracker.getRelays(event.id)).every(url => !shouldNotify(url, h)) + ) { continue } @@ -103,7 +104,7 @@ export const onNotification = call(() => { export const onPushNotificationAction = async (action: ActionPerformed) => { const {relay, id} = action.notification.data - const [event] = await load({ + const [event] = await network.get().load({ relays: [relay, LOCAL_RELAY_URL], filters: getIdFilters([id]), }) @@ -157,21 +158,17 @@ export const syncRelaySubscriptions = ( signal: AbortSignal, sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void, ) => { - const $pubkey = pubkey.get() - - if (!$pubkey) { - throw new Error("Attempted to sync push subscriptions without an active pubkey") - } + const $pubkey = User.require(app.get()).pubkey const unsubscribeSpaces = merged([ - userSpaceUrls, + roomLists.get().urls($pubkey).$, notificationSettings, userSettingsValues, ]).subscribe( - throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => { + throttle(3000, ([$spaceUrls, {spaces, mentions}, {alerts}]) => { const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)] - for (const url of $userSpaceUrls) { + for (const url of $spaceUrls) { const {notify = true, exceptions = []} = alerts.find(spec({url})) || {} const filters: Filter[] = [] const ignore: Filter[] = [] @@ -198,9 +195,12 @@ export const syncRelaySubscriptions = ( }), ) - const unsubscribeMessages = merged([userMessagingRelayList, notificationSettings]).subscribe( - throttle(3000, ([$userMessagingRelayList, {messages}]) => { - for (const url of getRelaysFromList($userMessagingRelayList)) { + const unsubscribeMessages = merged([ + messagingRelayLists.get().urls($pubkey).$, + notificationSettings, + ]).subscribe( + throttle(3000, ([$messagingUrls, {messages}]) => { + for (const url of $messagingUrls) { const filters: Filter[] = [] if (messages) { diff --git a/src/app/push/adapters/web.ts b/src/app/push/adapters/web.ts index 24c1a948..f33a491b 100644 --- a/src/app/push/adapters/web.ts +++ b/src/app/push/adapters/web.ts @@ -1,8 +1,8 @@ -import {pubkey} from "@welshman/app" -import {maybe} from "@welshman/lib" import type {Unsubscriber} from "svelte/store" -import {getPubkeyTagValues, matchFilter, type TrustedEvent} from "@welshman/util" +import {maybe} from "@welshman/lib" +import {hexTags, matchFilter, tagValues, type TrustedEvent} from "@welshman/util" import {DM_KINDS} from "@app/content" +import {app} from "@app/core" import {notificationSettings} from "@app/settings" import type {IPushAdapter} from "@app/push/adapters/common" import {onNotification} from "@app/push/adapters/common" @@ -49,15 +49,12 @@ export class WebNotifications implements IPushAdapter { if (!this._unsubscriber) { this._unsubscriber = onNotification(event => { const {push, messages, mentions, spaces} = notificationSettings.get() + const $pubkey = app.get().user?.pubkey if (push && document.hidden && Notification?.permission === "granted") { if (messages && matchFilter({kinds: DM_KINDS}, event)) { this._notify(event, "New direct message", "Someone sent you a direct message.") - } else if ( - mentions && - event.pubkey !== pubkey.get() && - getPubkeyTagValues(event.tags).includes(pubkey.get()!) - ) { + } else if (mentions && $pubkey && tagValues(hexTags("p"), event.tags).includes($pubkey)) { this._notify(event, "Someone mentioned you", "Someone tagged you in a message.") } else if (spaces) { this._notify(event, "New activity", "Someone posted a new message.") diff --git a/src/app/reactions.ts b/src/app/reactions.ts index 4ef54b18..f4ba8441 100644 --- a/src/app/reactions.ts +++ b/src/app/reactions.ts @@ -1,37 +1,53 @@ -import type {TrustedEvent} from "@welshman/util" -import {REACTION, getTag, makeEvent} from "@welshman/util" -import {publishThunk, tagEventForReaction} from "@welshman/app" -import {PROTECTED} from "@app/groups" +import {removeUndefined} from "@welshman/lib" +import type {EventContent, TrustedEvent} from "@welshman/util" +import {deletes, reactions, relays, wraps} from "@app/core" -export type ReactionParams = { - protect: boolean - event: TrustedEvent - content: string +export type ReactionTarget = { url?: string - tags?: string[][] + h?: string + // Where to publish, when it isn't just the space's own relay. + urls?: string[] } -export const makeReaction = ({ - url, - protect, - content, - event, - tags: paramTags = [], -}: ReactionParams) => { - const tags = [...paramTags, ...tagEventForReaction(event, url)] - const groupTag = getTag("h", event.tags) +// A reaction has to travel the same way as the event it's about — protected on relays that +// enforce NIP-70, and tagged into the room the event lives in. +export const publishReaction = async ( + event: TrustedEvent, + {content, tags}: EventContent, + {url, h, urls}: ReactionTarget, +) => { + const protect = url ? await relays.get().hasNip(url, 70) : false + const command = await reactions.get().react(event, content, writer => { + writer.addTags(...tags).setProtected(protect) - if (groupTag) { - tags.push(groupTag) - } + if (url && h) { + writer.setRoom(url, h) + } + }) - if (protect) { - tags.push(PROTECTED) - } - - return makeEvent(REACTION, {content, tags}) + return command.publishToRelays(urls ?? removeUndefined([url])) } -export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => { - publishThunk({event: makeReaction({url: relays[0], ...params}), relays}) +export const retractReaction = async (reaction: TrustedEvent, {url, urls}: ReactionTarget) => { + const protect = url ? await relays.get().hasNip(url, 70) : false + const command = await deletes.get().deleteEvent(reaction, writer => writer.setProtected(protect)) + + return command.publishToRelays(urls ?? removeUndefined([url])) +} + +// Chat reactions go out gift-wrapped to the conversation rather than to a relay. +export const publishWrappedReaction = async ( + event: TrustedEvent, + {content, tags}: EventContent, + pubkeys: string[], +) => { + const command = await reactions.get().react(event, content, writer => writer.addTags(...tags)) + + return wraps.get().publish({event: command.event, recipients: pubkeys, pow: 16}) +} + +export const retractWrappedReaction = async (reaction: TrustedEvent, pubkeys: string[]) => { + const command = await deletes.get().deleteEvent(reaction) + + return wraps.get().publish({event: command.event, recipients: pubkeys, pow: 16}) } diff --git a/src/app/recent.ts b/src/app/recent.ts index a912a536..071a0227 100644 --- a/src/app/recent.ts +++ b/src/app/recent.ts @@ -1,8 +1,8 @@ import {derived} from "svelte/store" import {groupBy, first, sortBy, uniqBy, ago, MONTH} from "@welshman/lib" -import {MESSAGE, COMMENT, getTagValue, getTagValues, getIdAndAddress} from "@welshman/util" +import {MESSAGE, COMMENT, getIdAndAddress, tagSpec, tagValue, tagValues} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" -import {repository} from "@welshman/app" +import {app} from "@app/core" import {deriveEventsForUrl} from "@app/repository" import {CONTENT_KINDS} from "@app/content" @@ -24,7 +24,7 @@ export const deriveRecentActivity = (url: string) => { return derived([messages, content, comments], ([$messages, $content, $comments]) => { const activity: RecentActivityItem[] = [] - const byRoom = groupBy(e => getTagValue("h", e.tags), $messages) + const byRoom = groupBy(e => tagValue(tagSpec("h"), e.tags), $messages) for (const roomMessages of byRoom.values()) { const latest = first(roomMessages) if (latest) { @@ -46,13 +46,13 @@ export const deriveRecentActivity = (url: string) => { } for (const event of $comments) { - for (const k of getTagValues(["E", "A"], event.tags)) { + for (const k of tagValues(tagSpec(["E", "A"]), event.tags)) { latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at)) } } for (const [address, timestamp] of latestActivityByKey.entries()) { - const event = repository.getEvent(address) + const event = app.get().repository.getEvent(address) if (event) { activity.push({type: "content", event, timestamp, count: 1}) diff --git a/src/app/relays.ts b/src/app/relays.ts index 07b6eddc..33ce7840 100644 --- a/src/app/relays.ts +++ b/src/app/relays.ts @@ -1,30 +1,12 @@ -import { - ManagementMethod, - getRelaysFromList, - isShareableRelayUrl, - isSignedEvent, - normalizeRelayUrl, -} from "@welshman/util" -import type {List, RelayProfile} from "@welshman/util" -import {AuthStateEvent, AuthStatus, Pool, SocketEvent, SocketStatus} from "@welshman/net" import {derived, readable} from "svelte/store" -import type {Maybe} from "@welshman/lib" -import {call, on, simpleCache, uniq} from "@welshman/lib" +import {call, on, simpleCache} from "@welshman/lib" +import {normalizeRelayUrl} from "@welshman/util" +import {AuthStateEvent, AuthStatus, SocketEvent, SocketStatus} from "@welshman/net" import {throttled} from "@welshman/store" -import {getRelay, loadRelay, manageRelay, publishThunk, waitForThunkError} from "@welshman/app" import {checkRelayHasLivekit} from "$lib/livekit" -import {stripPrefix} from "@lib/util" +import {app} from "@app/core" import {relaysMostlyRestricted} from "@app/policies" -export const hasNip29 = (relay?: RelayProfile) => - Boolean(relay?.supported_nips?.map?.(String)?.includes?.("29")) - -export const hasNip50 = (relay?: RelayProfile) => - Boolean(relay?.supported_nips?.map?.(String)?.includes?.("50")) - -export const hasNip70 = (relay?: RelayProfile) => - Boolean(relay?.supported_nips?.map?.(String)?.includes?.("70")) - export const encodeRelay = (url: string) => encodeURIComponent( normalizeRelayUrl(url) @@ -34,27 +16,8 @@ export const encodeRelay = (url: string) => export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url)) -// A hosted relay whose custom domain changed keeps 301-ing its old host to the -// new one; the NIP-11 fetch surfaces that (the socket can't). Returns the new -// wss:// url, or undefined when there's no redirect or the fetch fails. -export const fetchRelayRedirect = async (url: string): Promise> => { - try { - const response = await fetch(url.replace(/^ws/, "http"), { - headers: {Accept: "application/nostr+json"}, - }) - - if (!response.redirected) return undefined - - const next = normalizeRelayUrl(response.url.replace(/^http/, "ws")) - - return next === normalizeRelayUrl(url) ? undefined : next - } catch { - return undefined - } -} - export const deriveSocket = (url: string) => { - const socket = Pool.get().get(url) + const socket = app.get().pool.get(url) return readable(socket, set => { const subs = [ @@ -115,39 +78,8 @@ export const deriveSocketStatus = (url: string) => }), ) -export const signAsRelay = async (url: string, template: object): Promise => { - const {result, error} = await manageRelay(url, { - method: "signevent" as ManagementMethod, - params: [template as unknown as string], - }) - - if (error) return error - - if (!isSignedEvent(result)) return "Relay returned an invalid event" - - return stripPrefix(await waitForThunkError(publishThunk({event: result, relays: [url]}))) -} - -export const deriveSupportedMethods = simpleCache(([url]: [string]) => { - return readable([], set => { - manageRelay(url, { - method: ManagementMethod.SupportedMethods, - params: [], - }).then(({result = []}) => set(result)) - }) -}) - export const deriveHasLivekit = simpleCache(([url]: [string]) => readable(undefined, set => { checkRelayHasLivekit(url).then(has => set(has)) }), ) - -export const discoverRelays = (lists: List[]) => - Promise.all( - uniq(lists.flatMap($l => getRelaysFromList($l))) - .filter(isShareableRelayUrl) - .map(url => loadRelay(url)), - ) - -export const canEnforceNip70 = (url: string) => hasNip70(getRelay(url)) diff --git a/src/app/reports.ts b/src/app/reports.ts deleted file mode 100644 index eaffb8dd..00000000 --- a/src/app/reports.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {TrustedEvent} from "@welshman/util" -import {REPORT, makeEvent} from "@welshman/util" -import {publishThunk} from "@welshman/app" - -export type ReportParams = { - event: TrustedEvent - content: string - reason: string -} - -export const makeReport = ({event, reason, content}: ReportParams) => { - const tags = [ - ["p", event.pubkey], - ["e", event.id, reason], - ] - - return makeEvent(REPORT, {content, tags}) -} - -export const publishReport = ({ - relays, - event, - reason, - content, -}: ReportParams & {relays: string[]}) => - publishThunk({event: makeReport({event, reason, content}), relays}) diff --git a/src/app/repository.ts b/src/app/repository.ts index 3d78c531..8c2ef850 100644 --- a/src/app/repository.ts +++ b/src/app/repository.ts @@ -1,46 +1,63 @@ -import { - deriveArray, - deriveDeduplicated, - deriveEventsAsc, - deriveEventsById, - deriveEventsByIdForUrl, - deriveEventsDesc, - getEventsByIdForUrl, - makeDeriveEvent, -} from "@welshman/store" -import {deriveRelay, repository, tracker} from "@welshman/app" -import {sortEventsDesc} from "@welshman/util" -import type {Filter} from "@welshman/util" -import {load} from "@welshman/net" -import {filter, first, spec} from "@welshman/lib" import {derived} from "svelte/store" -export const deriveEvent = makeDeriveEvent({ - repository, - includeDeleted: true, - onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}), -}) +import {filter, spec} from "@welshman/lib" +import type {Filter, TrustedEvent} from "@welshman/util" +import * as store from "@welshman/store" +import {Network, Relays} from "@welshman/app" +import {app, fromApp} from "@app/core" + +// Events + +export const deriveEvent = (idOrAddress: string, relays: string[] = []) => + fromApp($app => + store.makeDeriveEvent({ + repository: $app.repository, + includeDeleted: true, + onDerive: (filters: Filter[], hints: string[]) => + $app.use(Network).load({filters, relays: hints}), + })(idOrAddress, relays), + ) + +export const deriveEventsById = (filters: Filter[] = [{}]) => + fromApp($app => store.deriveEventsById({repository: $app.repository, filters})) export const deriveEvents = (filters: Filter[] = [{}]) => - deriveEventsDesc(deriveEventsById({repository, filters})) + store.deriveEventsDesc(deriveEventsById(filters)) + +export const deriveIsDeleted = (event: TrustedEvent) => + fromApp($app => store.deriveIsDeleted($app.repository, event)) + +// Events on a relay export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) => - getEventsByIdForUrl({url, tracker, repository, filters}).values() + store + .getEventsByIdForUrl({ + url, + filters, + tracker: app.get().tracker, + repository: app.get().repository, + }) + .values() + +export const deriveEventsByIdForUrl = (url: string, filters: Filter[] = [{}]) => + fromApp($app => + store.deriveEventsByIdForUrl({ + url, + filters, + tracker: $app.tracker, + repository: $app.repository, + }), + ) export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) => - deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters})) + store.deriveArray(deriveEventsByIdForUrl(url, filters)) -export const deriveEventsForUrlAsc = (url: string, filters: Filter[] = [{}]) => - deriveEventsAsc(deriveEventsByIdForUrl({url, tracker, repository, filters})) - -export const deriveEventsForUrlDesc = (url: string, filters: Filter[] = [{}]) => - deriveEventsDesc(deriveEventsByIdForUrl({url, tracker, repository, filters})) - -export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) => - deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById => - first(sortEventsDesc($eventsById.values())), +export const deriveEventsByIdByUrl = (filters: Filter[] = [{}]) => + fromApp($app => + store.deriveEventsByIdByUrl({filters, tracker: $app.tracker, repository: $app.repository}), ) export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) => - derived([deriveRelay(url), deriveEventsForUrl(url, filters)], ([relay, events]) => - filter(spec({pubkey: relay?.self}), events), + derived( + [fromApp($app => $app.use(Relays).one(url)), deriveEventsForUrl(url, filters)], + ([$relay, $events]) => filter(spec({pubkey: $relay?.self}), $events as TrustedEvent[]), ) diff --git a/src/app/roles.ts b/src/app/roles.ts new file mode 100644 index 00000000..0d1ad4bb --- /dev/null +++ b/src/app/roles.ts @@ -0,0 +1,23 @@ +import {derived} from "svelte/store" +import {matchTags, tagSpec} from "@welshman/util" +import {relayMemberLists} from "@app/core" + +// Roles are rendered at a fixed saturation and lightness, chosen to read on both themes. +export const roleColor = (hue: number) => `hsl(${hue}, 70%, 50%)` + +// A translucent tint of the role color for use as a background fill. +export const roleColorSoft = (hue: number) => `hsl(${hue}, 70%, 50%, 0.15)` + +// Map parsed from EXTRA values on ["member", pubkey, ...roleIds] tags +export const deriveSpaceMemberRoles = (url: string) => + derived(relayMemberLists.get().forUrl(url), $members => { + const rolesByPubkey = new Map() + + for (const [, member, ...roleIds] of matchTags(tagSpec("member"), $members?.tags() ?? [])) { + if (member) { + rolesByPubkey.set(member, roleIds) + } + } + + return rolesByPubkey + }) diff --git a/src/app/roomPins.ts b/src/app/roomPins.ts new file mode 100644 index 00000000..8fad842c --- /dev/null +++ b/src/app/roomPins.ts @@ -0,0 +1,50 @@ +import {derived, readable} from "svelte/store" +import type {Readable} from "svelte/store" +import {getIdFilters, getIdOrAddress} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {network, roomPinLists} from "@app/core" +import {deriveEventsForUrl} from "@app/repository" + +export const deriveRoomPinnedEvents = (url: string, h: string): Readable => + readable([], set => { + const controller = new AbortController() + + roomPinLists.get().loadForRoom(url, h, controller.signal) + + const unsubscribe = derived, TrustedEvent[]>( + roomPinLists.get().pins(url, h).$, + ($pins, setEvents) => { + if ($pins.length === 0) { + setEvents([]) + + return () => {} + } + + const filters = getIdFilters($pins) + + network.get().load({relays: [url], filters, signal: controller.signal}) + + return deriveEventsForUrl(url, filters).subscribe($events => { + const byPin = new Map( + $events.flatMap(event => [ + [event.id, event], + [getIdOrAddress(event), event], + ]), + ) + + setEvents( + $pins.flatMap(pin => { + const event = byPin.get(pin) + + return event ? [event] : [] + }), + ) + }) + }, + ).subscribe(set) + + return () => { + controller.abort() + unsubscribe() + } + }) diff --git a/src/app/rooms.ts b/src/app/rooms.ts new file mode 100644 index 00000000..4b9cb8a1 --- /dev/null +++ b/src/app/rooms.ts @@ -0,0 +1,220 @@ +import * as nip19 from "nostr-tools/nip19" +import {derived} from "svelte/store" +import {sortBy, uniq} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import { + MESSAGE, + ROOM_ADD_MEMBER, + ROOM_JOIN, + ROOM_LEAVE, + ROOM_REMOVE_MEMBER, + makeEvent, + outbox, + seen, + sortEventsAsc, + toNostrURI, +} from "@welshman/util" +import type {EventContent, TrustedEvent} from "@welshman/util" +import {RoomLists, makeRoomKey} from "@welshman/app" +import type {Room, RoomMeta} from "@welshman/app" +import { + deriveUserItem, + relayManagement, + relayMemberLists, + roomLists, + rooms, + router, + thunks, + user, +} from "@app/core" +import {deriveUserIsSpaceAdmin} from "@app/management" +import {deriveEventsForUrl} from "@app/repository" + +export enum MembershipStatus { + Initial, + Pending, + Granted, +} + +export const ROOM = "h" + +export const PROTECTED = ["-"] + +export enum RoomType { + Text = "text", + Voice = "voice", +} + +export const getRoomType = (room: Maybe) => + room?.meta?.hasLivekit() ? RoomType.Voice : RoomType.Text + +export const isRoomId = (id: string) => id.includes("'") + +export const displayRoom = (url: string, h: string) => + rooms.get().get(makeRoomKey(url, h))?.meta?.name() || h + +export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() + +export const deriveRoomMembers = (url: string, h: string) => + derived(rooms.get().forRoom(url, h), $room => $room?.members?.members() ?? []) + +// A room member also has to be allowed at the relay level, or they won't be able to read the +// room at all. +export const addRoomMembers = async (url: string, room: RoomMeta, pubkeys: string[]) => { + const members = relayMemberLists.get().get(url) + const management = relayManagement.get().forUrl(url) + + const responses = await Promise.all( + pubkeys.filter(pk => !members?.isMember(pk)).map(pk => management.allowPubkey(pk)), + ) + + for (const {error} of responses) { + if (error) { + return error + } + } + + const errors = await Promise.all( + pubkeys.map(pk => + rooms + .get() + .addMember(url, room, pk) + .then(command => command.publish().waitForError()), + ), + ) + + for (const error of errors) { + if (error) { + return error + } + } +} + +export const prependParent = async ( + parent: Maybe, + {content, tags}: EventContent, + url?: string, +): Promise => { + if (parent) { + const resolver = router.get().resolver + const relays = url ? [url] : await resolver.relays([seen(parent)]) + const hint = url ?? (await resolver.relay([outbox(parent.pubkey)])) ?? "" + const nevent = nip19.neventEncode({...parent, relays}) + + content = toNostrURI(nevent) + "\n\n" + content + tags = [...tags, ["q", parent.id, hint, parent.pubkey], ["p", parent.pubkey, hint]] + } + + return {content, tags} +} + +export const publishRoomQuote = async ({ + url, + h, + parent, + protect, + delay, +}: { + url: string + h?: string + parent: TrustedEvent + protect: boolean + delay?: number +}) => { + const tags: string[][] = [] + + if (h) { + tags.push([ROOM, h]) + } + + if (protect) { + tags.push(PROTECTED) + } + + const event = makeEvent(MESSAGE, await prependParent(parent, {content: "", tags}, url)) + + return thunks.get().publish({relays: [url], event, delay}) +} + +// User + +export const userRoomList = deriveUserItem($app => $app.use(RoomLists)) + +export const userSpaceUrls = derived(userRoomList, $userRoomList => $userRoomList?.urls() ?? []) + +// Rooms in the space the user has joined, limited to those the relay still advertises. +export const deriveUserRooms = (url: string) => + derived( + [roomLists.get().roomsForUrl(user.get().pubkey, url).$, rooms.get().forUrl(url).$], + ([$rooms, $spaceRooms]) => { + const known = new Set($spaceRooms.map(room => room.h)) + + return sortBy(roomComparator(url), uniq($rooms.filter(h => known.has(h)))) + }, + ) + +export const deriveOtherRooms = (url: string) => + derived([deriveUserRooms(url), rooms.get().forUrl(url).$], ([$userRooms, $spaceRooms]) => { + const result = $spaceRooms + .filter(room => !room.meta?.hasLivekit() && !$userRooms.includes(room.h)) + .map(room => room.h) + + return sortBy(roomComparator(url), uniq(result)) + }) + +export const deriveOtherVoiceRooms = (url: string) => + derived([deriveUserRooms(url), rooms.get().forUrl(url).$], ([$userRooms, $spaceRooms]) => { + const result = $spaceRooms + .filter(room => room.meta?.hasLivekit() && !$userRooms.includes(room.h)) + .map(room => room.h) + + return sortBy(roomComparator(url), uniq(result)) + }) + +// A space admin administers every room in it, so space admin implies room admin. +export const deriveUserIsRoomAdmin = (url: string, h: string) => + derived( + [user, rooms.get().forRoom(url, h), deriveUserIsSpaceAdmin(url)], + ([$user, $room, $isSpaceAdmin]) => + $isSpaceAdmin || Boolean($room?.admins?.pubkeys().includes($user.pubkey)), + ) + +export const deriveUserRoomMembershipStatus = (url: string, h: string) => + derived( + [ + user, + rooms.get().forRoom(url, h), + deriveEventsForUrl(url, [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}]), + deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]), + deriveUserIsRoomAdmin(url, h), + ], + ([$user, $room, $addRemoveEvents, $joinLeaveEvents, $isAdmin]) => { + if ($isAdmin) { + return MembershipStatus.Granted + } + + let isMember = false + + if ($room?.members) { + isMember = $room.members.isMember($user.pubkey) + } else { + for (const event of sortEventsAsc($addRemoveEvents)) { + if (event.pubkey === $user.pubkey) { + isMember = event.kind === ROOM_ADD_MEMBER + } + } + } + + for (const event of $joinLeaveEvents) { + if (event.pubkey === $user.pubkey) { + if (event.kind === ROOM_JOIN) { + return isMember ? MembershipStatus.Granted : MembershipStatus.Pending + } + + return MembershipStatus.Initial + } + } + + return isMember ? MembershipStatus.Granted : MembershipStatus.Initial + }, + ) diff --git a/src/app/routes.ts b/src/app/routes.ts index 02c20fb1..c171488f 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -3,27 +3,28 @@ import {get} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import {goto} from "$app/navigation" import {page} from "$app/stores" -import type {TrustedEvent} from "@welshman/util" -import {getAddress} from "@welshman/util" -import {tracker, userMessagingRelayList, getRelay} from "@welshman/app" import {identity} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" import { - getTagValue, - MESSAGE, - THREAD, CLASSIFIED, - ZAP_GOAL, EVENT_TIME, + MESSAGE, + PINBOARD, POLL, - getPubkeyTagValues, - getRelaysFromList, + THREAD, + ZAP_GOAL, + getAddress, + hexTags, + tagSpec, + tagValue, + tagValues, } from "@welshman/util" +import {app, messagingRelayLists, relays, user} from "@app/core" import {makeChatId} from "@app/chats" import {entityLink, PLATFORM_URL, PLATFORM_RELAYS} from "@app/env" -import {encodeRelay, hasNip29} from "@app/relays" +import {encodeRelay} from "@app/relays" import {DM_KINDS} from "@app/content" -import {ROOM} from "@app/groups" -import {BOARD} from "@app/pinboards" +import {ROOM} from "@app/rooms" import {pushModal} from "@app/modal" import ChatEnable from "@app/components/ChatEnable.svelte" @@ -57,7 +58,7 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat") export const goToChat = (pubkeys: string[] = []) => { - if (getRelaysFromList(get(userMessagingRelayList)).length === 0) { + if (messagingRelayLists.get().urls(user.get().pubkey).get().length === 0) { pushModal(ChatEnable, {next: () => goToChat(pubkeys)}) } else if (pubkeys.length === 0) { goto(lastChatUrl ?? "/chat") @@ -90,7 +91,7 @@ export const goToSpace = (url: string, hash = "") => { return goto(prevPath + hash, {replaceState: true}) } - if (!hasNip29(getRelay(url))) { + if (!relays.get().get(url)?.hasNip(29)) { return goto(makeSpaceChatPath(url) + hash, {replaceState: true}) } @@ -115,7 +116,7 @@ export const goToHome = () => { // Content types, events export const makeMessagePath = (url: string, event: TrustedEvent) => { - const h = getTagValue(ROOM, event.tags) + const h = tagValue(tagSpec(ROOM), event.tags) const path = h ? makeRoomPath(url, h) : makeSpaceChatPath(url) const qp = new URLSearchParams({at: String(event.created_at)}) @@ -157,7 +158,7 @@ export const scrollToEvent = (id: string) => { } export const goToEvent = (event: TrustedEvent, options: Record = {}) => { - const urls = Array.from(tracker.getRelays(event.id)) + const urls = Array.from(app.get().tracker.getRelays(event.id)) const path = getEventPath(event, urls) if (path.includes("://")) { @@ -171,7 +172,7 @@ export const goToEvent = (event: TrustedEvent, options: Record = {} export const getEventPath = (event: TrustedEvent, urls: string[]) => { if (DM_KINDS.includes(event.kind)) { - return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)]) + return makeChatPath([event.pubkey, ...tagValues(hexTags("p"), event.tags)]) } if (urls.length > 0) { @@ -193,7 +194,7 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => { return makeCalendarPath(url, getAddress(event)) } - if (event.kind === BOARD) { + if (event.kind === PINBOARD) { return makeLibraryPath(url, getAddress(event)) } @@ -205,9 +206,9 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => { return makeMessagePath(url, event) } - const address = getTagValue("A", event.tags) - const kind = getTagValue("K", event.tags) - const id = getTagValue("E", event.tags) + const address = tagValue(tagSpec("A"), event.tags) + const kind = tagValue(tagSpec("K"), event.tags) + const id = tagValue(tagSpec("E"), event.tags) if (id && kind) { if (parseInt(kind) === ZAP_GOAL) { @@ -238,7 +239,7 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => { } export const makeEventPermalink = (event: TrustedEvent, url?: string) => { - const urls = url ? [url] : Array.from(tracker.getRelays(event.id)) + const urls = url ? [url] : Array.from(app.get().tracker.getRelays(event.id)) const path = getEventPath(event, urls) if (path.includes("://")) { @@ -258,7 +259,7 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => { return makeGoalPath(url, event.id) case EVENT_TIME: return makeCalendarPath(url, getAddress(event)) - case BOARD: + case PINBOARD: return makeLibraryPath(url, getAddress(event)) case POLL: return makePollPath(url, event.id) diff --git a/src/app/session.ts b/src/app/session.ts index 2518fc99..33b10a72 100644 --- a/src/app/session.ts +++ b/src/app/session.ts @@ -1,14 +1,87 @@ -import {db, kv, ss} from "@app/storage" +import type {ClientOptions} from "@pomade/core" +import type {Wallet} from "@welshman/util" +import {nip01, nip07, nip46, nip55, pomade, toSession} from "@welshman/app" +import type {Session} from "@welshman/app" +import {app, login, session} from "@app/core" +import {wallet} from "@app/lightning" +import {kv, ss, storage} from "@app/storage" import {deactivateCurrentPomadeSession} from "@app/pomade" import {Push} from "@app/push" +// Sessions used to be a Record keyed by an active pubkey, and carried their +// pubkey plus method-specific fields inline. Convert one so upgrading doesn't log people out. +type LegacySession = { + method: string + pubkey: string + secret?: string + signer?: string + email?: string + clientOptions?: ClientOptions + handler?: {pubkey: string; relays: string[]} + wallet?: Wallet +} + +const toCurrentSession = (legacy: LegacySession): Session | undefined => { + switch (legacy.method) { + case "nip01": + return legacy.secret ? toSession(nip01, {secret: legacy.secret}) : undefined + case "nip07": + return toSession(nip07, {}) + case "nip46": + return legacy.secret && legacy.handler + ? toSession(nip46, { + clientSecret: legacy.secret, + signerPubkey: legacy.handler.pubkey, + relays: legacy.handler.relays, + }) + : undefined + case "nip55": + return legacy.signer + ? toSession(nip55, {pubkey: legacy.pubkey, signer: legacy.signer}) + : undefined + case "pomade": + return legacy.clientOptions && legacy.email + ? toSession(pomade, {clientOptions: legacy.clientOptions, email: legacy.email}) + : undefined + } +} + +const readLegacySession = async () => { + const pubkey = await kv.get("pubkey") + const sessions = await ss.get>("sessions") + const legacy = pubkey ? sessions?.[pubkey] : undefined + + if (legacy?.wallet) { + wallet.set(legacy.wallet) + } + + return legacy ? toCurrentSession(legacy) : undefined +} + +// The session is derived from the app's user, so it can't be synced to storage directly — +// read it back once at startup, then persist it whenever the identity changes. +export const restoreSession = async () => { + const $session = (await ss.get("session")) ?? (await readLegacySession()) + + if ($session) { + await login($session) + } + + return session.subscribe($session => { + if ($session) { + ss.set("session", $session) + } + }) +} + export const logout = async () => { await deactivateCurrentPomadeSession() await Push.disable() await kv.clear() await ss.clear() - await db.clear() + await storage.get()?.clear() + app.get().cleanup() localStorage.clear() window.location.href = "/" diff --git a/src/app/settings.ts b/src/app/settings.ts index c2232acc..8e43e808 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -1,19 +1,13 @@ -import {APP_DATA, makeEvent} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import {deriveItemsByKey, getter, makeLoadItem, withGetter} from "@welshman/store" -import { - ensurePlaintext, - makeOutboxLoader, - makeUserData, - makeUserLoader, - publishThunk, - pubkey, - repository, - signer, -} from "@welshman/app" -import {append, parseJson, remove, spec} from "@welshman/lib" -import {derived, get, writable} from "svelte/store" -import {Router} from "@welshman/router" +import {derived, writable} from "svelte/store" +import {append, remove, spec} from "@welshman/lib" +import {APP_DATA} from "@welshman/util" +import {withGetter} from "@welshman/store" +import {AppData} from "@welshman/domain" +import type {AppDataReader} from "@welshman/domain" +import {DerivedPlugin, Domain, Network, User, projectFrom} from "@welshman/app" +import type {IApp, Projection} from "@welshman/app" +import {app, fromApp, usePlugin} from "@app/core" + export const SETTINGS = "flotilla/settings" export enum RelayAuthMode { @@ -40,11 +34,6 @@ export type SettingsValues = { zap_amounts: number[] } -export type Settings = { - event: TrustedEvent - values: SettingsValues -} - export const defaultSettings: SettingsValues = { show_media: true, hide_sensitive: true, @@ -58,46 +47,46 @@ export const defaultSettings: SettingsValues = { zap_amounts: [21, 210, 2100, 21000], } -export const settingsByPubkey = deriveItemsByKey({ - repository, - getKey: settings => settings.event.pubkey, - filters: [{kinds: [APP_DATA], "#d": [SETTINGS]}], - eventToItem: async (event: TrustedEvent) => { - const values = {...defaultSettings, ...parseJson(await ensurePlaintext(event))} +export class Settings extends DerivedPlugin { + values: Projection - return {event, values} - }, -}) + constructor(app: IApp) { + super(app, { + filters: [{kinds: [APP_DATA], "#d": [SETTINGS]}], + eventToItem: app.use(Domain).reader(AppData), + getKey: settings => settings.author(), + }) -export const getSettingsByPubkey = getter(settingsByPubkey) + this.values = projectFrom(this.index, $settings => ({ + ...defaultSettings, + ...$settings.get(app.user?.pubkey ?? "")?.values>(), + })) + } -export const loadSettings = makeLoadItem( - makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}), - (pubkey: string) => getSettingsByPubkey().get(pubkey), -) + fetch(pubkey: string) { + return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [APP_DATA], "#d": [SETTINGS]}) + } +} -export const userSettings = makeUserData(settingsByPubkey, loadSettings) +export const settings = usePlugin(Settings) -export const loadUserSettings = makeUserLoader(loadSettings) - -export const userSettingsValues = derived(userSettings, $s => $s?.values || defaultSettings) +export const userSettingsValues = withGetter(fromApp($app => $app.use(Settings).values.$)) export const zapAmounts = derived(userSettingsValues, $settings => $settings.zap_amounts) -export const getSettings = getter(userSettingsValues) - -export const getSetting = (key: keyof Settings["values"]) => getSettings()[key] as T +export const getSetting = (key: K) => userSettingsValues.get()[key] export const getShouldNotify = ({alerts}: SettingsValues, url: string, h?: string) => { const pref = alerts.find(spec({url})) if (!pref) return true if (!h) return pref.notify - if (pref.notify) return !pref.exceptions.includes(h) - if (!pref.notify) return pref.exceptions.includes(h) + + return pref.notify ? !pref.exceptions.includes(h) : pref.exceptions.includes(h) } -export const shouldNotify = (url: string, h?: string) => getShouldNotify(getSettings(), url, h) +export const shouldNotify = (url: string, h?: string) => + getShouldNotify(userSettingsValues.get(), url, h) export const deriveShouldNotify = (url: string, h?: string) => derived(userSettingsValues, $settings => getShouldNotify($settings, url, h)) @@ -113,55 +102,51 @@ export const notificationSettings = withGetter( }), ) -export const makeSettings = async (params: Partial) => { - const json = JSON.stringify({...get(userSettingsValues), ...params}) - const content = await signer.get().nip44.encrypt(pubkey.get()!, json) - const tags = [["d", SETTINGS]] +export const publishSettings = async (params: Partial) => { + const $app = app.get() + const reader = await settings.get().forceLoad(User.require($app).pubkey) + const writer = $app + .use(Domain) + .writer(AppData, reader) + .setIdentifier(SETTINGS) + .setEncrypted(true) + .setValues({...userSettingsValues.get(), ...params}) - return makeEvent(APP_DATA, {content, tags}) + const command = await $app.use(Domain).command(writer) + + return command.publish() } -export const publishSettings = async (params: Partial) => - publishThunk({event: await makeSettings(params), relays: Router.get().FromUser().getUrls()}) +export const addTrustedRelay = (url: string) => + publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))}) -export const addTrustedRelay = async (url: string) => - publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))}) +export const removeTrustedRelay = (url: string) => + publishSettings({trusted_relays: remove(url, getSetting("trusted_relays"))}) -export const removeTrustedRelay = async (url: string) => - publishSettings({trusted_relays: remove(url, getSetting("trusted_relays"))}) - -export const setSpaceNotifications = async (url: string, notify: boolean) => { - const {alerts} = getSettings() - const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url) - - let updated: typeof alerts +export const setSpaceNotifications = (url: string, notify: boolean) => { + const alerts = getSetting("alerts") + const existing = alerts.find(spec({url})) if (existing) { - updated = alerts.map((s: SpaceNotificationSettings) => - s.url === url ? {...s, notify, exceptions: []} : s, - ) - } else { - updated = [...alerts, {url, notify, exceptions: []}] + return publishSettings({ + alerts: alerts.map(s => (s.url === url ? {...s, notify, exceptions: []} : s)), + }) } - return publishSettings({alerts: updated}) + return publishSettings({alerts: [...alerts, {url, notify, exceptions: []}]}) } -export const toggleRoomNotifications = async (url: string, h: string) => { - const {alerts} = getSettings() - const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url) +export const toggleRoomNotifications = (url: string, h: string) => { + const alerts = getSetting("alerts") + const existing = alerts.find(spec({url})) - let updated: typeof alerts - - if (!existing) { - updated = [...alerts, {url, notify: true, exceptions: [h]}] - } else { + if (existing) { const exceptions = existing.exceptions.includes(h) ? remove(h, existing.exceptions) : append(h, existing.exceptions) - updated = alerts.map((s: SpaceNotificationSettings) => (s.url === url ? {...s, exceptions} : s)) + return publishSettings({alerts: alerts.map(s => (s.url === url ? {...s, exceptions} : s))}) } - return publishSettings({alerts: updated}) + return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]}) } diff --git a/src/app/share.ts b/src/app/share.ts index 08819721..93edbeb3 100644 --- a/src/app/share.ts +++ b/src/app/share.ts @@ -1,17 +1,15 @@ -import {get} from "svelte/store" import {goto} from "$app/navigation" import type {TrustedEvent} from "@welshman/util" -import {relaysByUrl} from "@welshman/app" import {setKey} from "@lib/implicit" -import EventShare from "@app/components/EventShare.svelte" -import {hasNip29} from "@app/relays" +import {relays} from "@app/core" import {pushModal} from "@app/modal" import {makeSpaceChatPath} from "@app/routes" +import EventShare from "@app/components/EventShare.svelte" // Share an event to chat. On NIP-29 relays we prompt for a room to share into; // otherwise we stash the event and jump to the space chat composer, which quotes it. export const shareEventToChat = (url: string, noun: string, event: TrustedEvent) => { - if (hasNip29(get(relaysByUrl).get(url))) { + if (relays.get().get(url)?.hasNip(29)) { pushModal(EventShare, {url, noun, event}) } else { setKey("share", event) diff --git a/src/app/signer.ts b/src/app/signer.ts new file mode 100644 index 00000000..d9b7707e --- /dev/null +++ b/src/app/signer.ts @@ -0,0 +1,42 @@ +import {derived} from "svelte/store" +import {Logger} from "@welshman/app" +import type {LogMessage} from "@welshman/app" +import {fromApp} from "@app/core" + +export type SignerRequest = { + id: string + method: string + startedAt: number + finishedAt?: number + ok?: boolean +} + +// The logger records one entry per state transition, correlated by id. Fold them back into one +// entry per request so callers can reason about how long each took and how it ended. +export const signerRequests = derived( + fromApp($app => $app.use(Logger).messages.$), + ($messages: LogMessage[]) => { + const requestsById = new Map() + + for (const message of $messages) { + if (message.source !== "signer") continue + + if (message.status === "pending") { + requestsById.set(message.id, { + id: message.id, + method: String(message.method), + startedAt: message.at, + }) + } else { + const request = requestsById.get(message.id) + + if (request) { + request.finishedAt = message.at + request.ok = message.status === "success" + } + } + } + + return Array.from(requestsById.values()) + }, +) diff --git a/src/app/social.ts b/src/app/social.ts index bd2a3497..4e980b4b 100644 --- a/src/app/social.ts +++ b/src/app/social.ts @@ -1,54 +1,65 @@ import {derived} from "svelte/store" -import {displayProfileByPubkey, getProfile, userFollowList, userMuteList} from "@welshman/app" -import {shuffle, tryCatch} from "@welshman/lib" +import {shuffle} from "@welshman/lib" import { + COMMENT, + addressTags, getAddress, - getAddressTagValues, - getEventTagValues, - getListTags, - getParentAddrs, - getParentIds, - getPubkeyTagValues, - getTagValues, + hexTags, + tagSpec, + tagValues, + topicTags, } from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {withGetter} from "@welshman/store" +import {getCommentTagValues, getReplyTagValues} from "@welshman/domain" +import {followLists, muteLists, profiles, user} from "@app/core" import {DEFAULT_PUBKEYS} from "@app/env" -export const bootstrapPubkeys = derived(userFollowList, $userFollowList => { - const appPubkeys = DEFAULT_PUBKEYS.split(",") - const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList))) - return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] -}) +export const bootstrapPubkeys = derived( + [user, followLists.get().index.$], + ([$user, $followLists]) => { + const appPubkeys = DEFAULT_PUBKEYS.split(",") + const userPubkeys = shuffle($followLists.get($user.pubkey)?.pubkeys() ?? []) + + return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] + }, +) + +// Ids and addresses of an event's immediate parents, falling back to its thread roots. +const getParents = ({kind, tags}: TrustedEvent) => { + const {roots, replies} = kind === COMMENT ? getCommentTagValues(tags) : getReplyTagValues(tags) + + return replies.length > 0 ? replies : roots +} export const isEventMuted = withGetter( - derived(userMuteList, $userMuteList => { - const pubkey = $userMuteList?.event.pubkey - const tags = getListTags($userMuteList) - const mutedEvents = new Set(getEventTagValues(tags)) - const mutedPubkeys = new Set(getPubkeyTagValues(tags)) - const mutedAddresses = new Set(getAddressTagValues(tags)) - const mutedTopics = new Set(getTagValues("t", tags)) - const mutedWords = getTagValues("word", tags) + derived([user, muteLists.get().index.$], ([$user, $muteLists]) => { + const muteList = $muteLists.get($user.pubkey) + const tags = muteList?.tags() ?? [] + const mutedEvents = new Set(tagValues(hexTags("e"), tags)) + const mutedPubkeys = new Set(tagValues(hexTags("p"), tags)) + const mutedAddresses = new Set(tagValues(addressTags("a"), tags)) + const mutedTopics = new Set(tagValues(topicTags("t"), tags)) + const mutedWords = tagValues(tagSpec("word"), tags) const regex = mutedWords.length > 0 ? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`) : undefined return (e: TrustedEvent) => { - if (!pubkey) return false - if (pubkey === e.pubkey) return false + if (!muteList) return false + if ($user.pubkey === e.pubkey) return false if (mutedPubkeys.has(e.pubkey)) return true if (mutedEvents.has(e.id)) return true if (mutedAddresses.has(getAddress(e))) return true - if (getParentIds(e).some(id => mutedEvents.has(id))) return true - if (getParentAddrs(e).some(address => mutedAddresses.has(address))) return true - if (getTagValues("t", e.tags).some(t => mutedTopics.has(t))) return true + if (getParents(e).some(v => mutedEvents.has(v) || mutedAddresses.has(v))) return true + if (tagValues(topicTags("t"), e.tags).some(t => mutedTopics.has(t))) return true if (regex) { - if (e.content?.toLowerCase().match(regex)) return true - if (displayProfileByPubkey(e.pubkey).toLowerCase().match(regex)) return true - if (tryCatch(() => getProfile(e.pubkey)?.nip05?.match(regex))) return true + const profile = profiles.get().get(e.pubkey) + + if (profile?.display().toLowerCase().match(regex)) return true + if (profile?.nip05()?.match(regex)) return true } return false diff --git a/src/app/storage.ts b/src/app/storage.ts index 5609097f..c94ec31d 100644 --- a/src/app/storage.ts +++ b/src/app/storage.ts @@ -1,5 +1,10 @@ -import {on, throttle, indexBy, fromPairs, batch, call} from "@welshman/lib" -import {throttled} from "@welshman/store" +import {writable} from "svelte/store" +import type {Unsubscriber} from "svelte/store" +import {deleteDB} from "idb" +import {SecureStorage} from "@aparajita/capacitor-secure-storage" +import {Preferences} from "@capacitor/preferences" +import {noop, on, throttle, batch, call} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" import { ALERT_ANDROID, ALERT_EMAIL, @@ -23,33 +28,21 @@ import { ROOM_MEMBERS, ROOM_ADMINS, ROOM_META, + ROOM_PINS, ROOM_DELETE, ROOM_REMOVE_MEMBER, ROOMS, verifiedSymbol, } from "@welshman/util" -import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util" +import type {Handle, TrustedEvent} from "@welshman/util" +import {withGetter} from "@welshman/store" import type {RepositoryUpdate, WrapItem} from "@welshman/net" -import type {Handle, RelayStats} from "@welshman/app" -import { - tracker, - plaintext, - repository, - relaysByUrl, - relayStatsByUrl, - onRelayStats, - handlesByNip05, - zappersByLnurl, - onZapper, - onHandle, - wrapManager, - onRelay, -} from "@welshman/app" -import type {Unsubscriber} from "svelte/store" -import {SecureStorage} from "@aparajita/capacitor-secure-storage" -import {Preferences} from "@capacitor/preferences" +import {Relay, Zapper} from "@welshman/domain" +import type {RelayInfo, ZapperValues} from "@welshman/domain" +import {Handles, Plaintext, Relays, RelayStats, User, Zappers} from "@welshman/app" +import type {AppPolicy, IApp, RelayStatsItem} from "@welshman/app" import {IDB} from "@lib/indexeddb" -import {ROOM_PINS} from "@app/pins" +import {appPolicies} from "@app/core" export const kv = call(() => { let p = Promise.resolve() @@ -119,20 +112,16 @@ export const ss = call(() => { return {get, set, clear} }) -export const db = new IDB({ - name: "flotilla-9gl", - version: 1, - stores: [ - {name: "events", keyPath: "id"}, - {name: "tracker", keyPath: "id"}, - {name: "relays", keyPath: "url"}, - {name: "relayStats", keyPath: "url"}, - {name: "handles", keyPath: "nip05"}, - {name: "zappers", keyPath: "lnurl"}, - {name: "plaintext", keyPath: "key"}, - {name: "wrapManager", keyPath: "id"}, - ], -}) +const TABLES = [ + {name: "events", keyPath: "id"}, + {name: "tracker", keyPath: "id"}, + {name: "relays", keyPath: "url"}, + {name: "relayStats", keyPath: "url"}, + {name: "handles", keyPath: "nip05"}, + {name: "zappers", keyPath: "lnurl"}, + {name: "plaintext", keyPath: "key"}, + {name: "wrapManager", keyPath: "id"}, +] const FLUSH_INTERVAL = 3000 @@ -170,255 +159,309 @@ type TrackerItem = {id: string; relays: string[]} type PlaintextItem = {key: string; value: string} -const loadCriticalEvents = async () => { - const table = db.table("events") - const initialEvents = await table.getAll() - const keep: TrustedEvent[] = [] - const drop: string[] = [] +/** + * Caches an app's repository, tracker and local collections in indexeddb. Everything stored + * here belongs to a single identity, so each gets its own database, named for the pubkey the + * policy below requires before it builds one. + */ +class Storage { + ready: Promise - for (const event of initialEvents) { - if (shouldPersistEvent(event)) { - event[verifiedSymbol] = true - keep.push(event) + private db: IDB + private unsubscribers: Unsubscriber[] = [] + private timeouts: ReturnType[] = [] + private stopped = false + + constructor(private readonly app: IApp) { + // Every identity used to share one database; drop it rather than leave it on disk. + void deleteDB("flotilla-9gl") + + this.db = new IDB({name: `flotilla-9gl-${User.require(app).pubkey}`, stores: TABLES}) + this.ready = this.start() + } + + close = () => this.db.close() + + clear = () => this.db.clear() + + cleanup = () => { + this.stopped = true + this.timeouts.forEach(clearTimeout) + this.unsubscribers.forEach(call) + this.close() + } + + private start = async () => { + await this.db.connect() + + const [, unsubscribeRelays] = await Promise.all([this.loadCriticalEvents(), this.initRelays()]) + + await this.loadCriticalTracker() + + this.addUnsubscriber(this.syncEvents()) + this.addUnsubscriber(this.syncTracker()) + this.addUnsubscriber(unsubscribeRelays) + + const defer = (init: () => Promise) => { + this.timeouts.push( + setTimeout(async () => { + if (!this.stopped) { + this.addUnsubscriber(await init()) + } + }, 0), + ) + } + + defer(this.initRelayStats) + defer(this.initHandles) + defer(this.initZappers) + defer(this.initPlaintext) + defer(this.initWrapManager) + } + + private addUnsubscriber = (unsubscriber: Unsubscriber) => { + if (this.stopped) { + unsubscriber() } else { - drop.push(event.id) + this.unsubscribers.push(unsubscriber) } } - repository.load(keep) + private loadCriticalEvents = async () => { + const table = this.db.table("events") + const initialEvents = await table.getAll() + const keep: TrustedEvent[] = [] + const drop: string[] = [] - if (drop.length > 0) { - void table.bulkDelete(drop) + for (const event of initialEvents) { + if (shouldPersistEvent(event)) { + event[verifiedSymbol] = true + keep.push(event) + } else { + drop.push(event.id) + } + } + + this.app.repository.load(keep) + + if (drop.length > 0) { + void table.bulkDelete(drop) + } } -} -const syncEvents = () => { - const table = db.table("events") + private syncEvents = () => { + const table = this.db.table("events") - return on( - repository, - "update", - batch(3000, async (updates: RepositoryUpdate[]) => { - const add: TrustedEvent[] = [] - const remove = new Set() + return on( + this.app.repository, + "update", + batch(3000, async (updates: RepositoryUpdate[]) => { + const add: TrustedEvent[] = [] + const remove = new Set() - for (const update of updates) { - for (const event of update.added) { - if (shouldPersistEvent(event)) { - add.push(event) - remove.delete(event.id) + for (const update of updates) { + for (const event of update.added) { + if (shouldPersistEvent(event)) { + add.push(event) + remove.delete(event.id) + } + } + + for (const id of update.removed) { + remove.add(id) } } - for (const id of update.removed) { - remove.add(id) + if (add.length > 0) { + await table.bulkPut(add) } + + if (remove.size > 0) { + await table.bulkDelete(remove) + } + }), + ) + } + + private loadCriticalTracker = async () => { + const table = this.db.table("tracker") + const relaysById = new Map>() + const stale: string[] = [] + + for (const {id, relays} of await table.getAll()) { + if (this.app.repository.getEvent(id)) { + relaysById.set(id, new Set(relays)) + } else { + stale.push(id) + } + } + + this.app.tracker.load(relaysById) + + if (stale.length > 0) { + void table.bulkDelete(stale) + } + } + + private syncTracker = () => { + const table = this.db.table("tracker") + + const _onAdd = async (ids: Iterable) => { + const items: TrackerItem[] = [] + + for (const id of ids) { + const event = this.app.repository.getEvent(id) + + if (!event || !shouldPersistEvent(event)) continue + + const relays = Array.from(this.app.tracker.getRelays(id)) + + if (relays.length === 0) continue + + items.push({id, relays}) } - if (add.length > 0) { - await table.bulkPut(add) + await table.bulkPut(items) + } + + const _onRemove = async (ids: Iterable) => { + await table.bulkDelete(Array.from(ids)) + } + + const onAdd = batch(3000, _onAdd) + const onRemove = batch(3000, _onRemove) + const onLoad = () => _onAdd(this.app.tracker.relaysById.keys()) + const onClear = () => _onRemove(this.app.tracker.relaysById.keys()) + + this.app.tracker.on("add", onAdd) + this.app.tracker.on("remove", onRemove) + this.app.tracker.on("load", onLoad) + this.app.tracker.on("clear", onClear) + + return () => { + this.app.tracker.off("add", onAdd) + this.app.tracker.off("remove", onRemove) + this.app.tracker.off("load", onLoad) + this.app.tracker.off("clear", onClear) + } + } + + private initRelays = async () => { + const table = this.db.table("relays") + + for (const row of await table.getAll()) { + this.app.use(Relays).set(row.url, new Relay(row.url, row)) + } + + const enqueue = batch( + FLUSH_INTERVAL, + idleWrite((relays: Relay[]) => table.bulkPut(relays.map(relay => ({...relay})))), + ) + + return this.app.use(Relays).onItem((_url, relay) => { + if (relay) enqueue(relay) + }) + } + + private initRelayStats = async () => { + const table = this.db.table("relayStats") + + for (const row of await table.getAll()) { + this.app.use(RelayStats).set(row.url, row) + } + + const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)) + + return this.app.use(RelayStats).onItem((_url, stats) => { + if (stats) enqueue(stats) + }) + } + + private initHandles = async () => { + const table = this.db.table("handles") + + for (const row of await table.getAll()) { + this.app.use(Handles).set(row.nip05, row) + } + + const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)) + + return this.app.use(Handles).onItem((_nip05, handle) => { + if (handle) enqueue(handle) + }) + } + + private initZappers = async () => { + const table = this.db.table("zappers") + + for (const row of await table.getAll()) { + // Validation is meaningless without these, and rows cached before they were required + // won't have them. + if (row.pubkey && row.nostrPubkey) { + this.app.use(Zappers).set(row.lnurl, new Zapper(row)) } - - if (remove.size > 0) { - await table.bulkDelete(remove) - } - }), - ) -} - -const loadCriticalTracker = async () => { - const table = db.table("tracker") - const relaysById = new Map>() - const stale: string[] = [] - - for (const {id, relays} of await table.getAll()) { - if (!repository.getEvent(id)) { - stale.push(id) - continue } - relaysById.set(id, new Set(relays)) + const enqueue = batch( + FLUSH_INTERVAL, + idleWrite((zappers: Zapper[]) => table.bulkPut(zappers.map(zapper => ({...zapper})))), + ) + + return this.app.use(Zappers).onItem((_lnurl, zapper) => { + if (zapper) enqueue(zapper) + }) } - tracker.load(relaysById) + private initPlaintext = async () => { + const table = this.db.table("plaintext") - if (stale.length > 0) { - void table.bulkDelete(stale) - } -} - -const syncTracker = () => { - const table = db.table("tracker") - - const _onAdd = async (ids: Iterable) => { - const items: TrackerItem[] = [] - - for (const id of ids) { - const event = repository.getEvent(id) - - if (!event || !shouldPersistEvent(event)) continue - - const relays = Array.from(tracker.getRelays(id)) - - if (relays.length === 0) continue - - items.push({id, relays}) + for (const {key, value} of await table.getAll()) { + this.app.use(Plaintext).set(key, value) } - await table.bulkPut(items) + const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut)) + + return this.app.use(Plaintext).onItem((key, value) => { + if (value) enqueue({key, value}) + }) } - const _onRemove = async (ids: Iterable) => { - await table.bulkDelete(Array.from(ids)) - } + private initWrapManager = async () => { + const table = this.db.table("wrapManager") - const onAdd = batch(3000, _onAdd) - const onRemove = batch(3000, _onRemove) - const onLoad = () => _onAdd(tracker.relaysById.keys()) - const onClear = () => _onRemove(tracker.relaysById.keys()) + this.app.wrapManager.load(await table.getAll()) - tracker.on("add", onAdd) - tracker.on("remove", onRemove) - tracker.on("load", onLoad) - tracker.on("clear", onClear) + const addOne = batch(3000, table.bulkPut) + const removeOne = throttle(3000, table.bulkDelete) - return () => { - tracker.off("add", onAdd) - tracker.off("remove", onRemove) - tracker.off("load", onLoad) - tracker.off("clear", onClear) + this.app.wrapManager.on("add", addOne) + this.app.wrapManager.on("remove", removeOne) + + return () => { + this.app.wrapManager.off("add", addOne) + this.app.wrapManager.off("remove", removeOne) + } } } -const loadCriticalRelays = async () => { - const table = db.table("relays") +// The current app's cache, which only exists once there's an identity to cache for. +export const storage = withGetter(writable>(undefined)) - relaysByUrl.set(indexBy(r => r.url, await table.getAll())) -} +// Storage is scoped to one app's repository, tracker and caches, so it's built and torn down +// with the app rather than living on as a module-level singleton. +export const storagePolicy: AppPolicy = $app => { + if ($app.user) { + const $storage = new Storage($app) -const syncRelays = () => - onRelay(batch(FLUSH_INTERVAL, idleWrite(db.table("relays").bulkPut))) + storage.set($storage) -const initRelayStats = async () => { - const table = db.table("relayStats") - - relayStatsByUrl.set(indexBy(r => r.url, await table.getAll())) - - return onRelayStats(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))) -} - -const initHandles = async () => { - const table = db.table("handles") - - handlesByNip05.set(indexBy(r => r.nip05, await table.getAll())) - - return onHandle(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))) -} - -const initZappers = async () => { - const table = db.table("zappers") - - zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll())) - - return onZapper(batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))) -} - -const initPlaintext = async () => { - const table = db.table("plaintext") - const initialRecords = await table.getAll() - - plaintext.set(fromPairs(initialRecords.map(({key, value}) => [key, value]))) - - return throttled(3000, plaintext).subscribe($plaintext => { - table.bulkPut(Object.entries($plaintext).map(([key, value]) => ({key, value}))) - }) -} - -const initWrapManager = async () => { - const table = db.table("wrapManager") - - wrapManager.load(await table.getAll()) - - const addOne = batch(3000, table.bulkPut) - const removeOne = throttle(3000, table.bulkDelete) - - wrapManager.on("add", addOne) - wrapManager.on("remove", removeOne) - - return () => { - wrapManager.off("add", addOne) - wrapManager.off("remove", removeOne) - } -} - -type StorageSync = { - unsubscribe: Unsubscriber - ready: Promise -} - -export const sync = (): StorageSync => { - const unsubscribers: Unsubscriber[] = [] - const deferredTimers: ReturnType[] = [] - let stopped = false - - const addUnsubscriber = (unsubscriber: Unsubscriber) => { - if (stopped) { - unsubscriber() - } else { - unsubscribers.push(unsubscriber) + return () => { + $storage.cleanup() + storage.set(undefined) } } - const scheduleDeferred = (task: () => Promise) => { - const timeout = setTimeout(() => { - if (stopped) return - - void task() - }, 0) - - deferredTimers.push(timeout) - } - - const ready = call(async () => { - await db.connect() - - await Promise.all([loadCriticalEvents(), loadCriticalRelays()]) - await loadCriticalTracker() - - addUnsubscriber(syncEvents()) - addUnsubscriber(syncTracker()) - addUnsubscriber(syncRelays()) - - scheduleDeferred(async () => { - addUnsubscriber(await initRelayStats()) - }) - - scheduleDeferred(async () => { - addUnsubscriber(await initHandles()) - }) - - scheduleDeferred(async () => { - addUnsubscriber(await initZappers()) - }) - - scheduleDeferred(async () => { - addUnsubscriber(await initPlaintext()) - }) - - scheduleDeferred(async () => { - addUnsubscriber(await initWrapManager()) - }) - }) - - const unsubscribe = () => { - stopped = true - - for (const timeout of deferredTimers) { - clearTimeout(timeout) - } - - unsubscribers.forEach(unsubscriber => unsubscriber()) - } - - return {unsubscribe, ready} + return noop } + +appPolicies.push(storagePolicy) diff --git a/src/app/sync.ts b/src/app/sync.ts index 77f56a0d..334d0572 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -1,68 +1,78 @@ import {page} from "$app/stores" import type {Unsubscriber} from "svelte/store" -import {last, call, assoc, WEEK, MONTH, ago} from "@welshman/lib" -import {merged} from "@welshman/store" -import {Router} from "@welshman/router" +import {ago, assoc, call, MONTH, WEEK} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" import { - getListTags, - getRelayTagValues, - WRAP, - MUTES, + APP_DATA, FOLLOWS, - ROOM_META, - ROOM_DELETE, - ROOM_ADMINS, - ROOM_MEMBERS, + MESSAGE, + MUTES, + PIN, + PINBOARD, + POLL_RESPONSE, + RELAY_ADD_MEMBER, + RELAY_MEMBERS, + RELAY_REMOVE_MEMBER, + RELAY_ROLE, ROOM_ADD_MEMBER, - ROOM_REMOVE_MEMBER, + ROOM_ADMINS, + ROOM_CREATE_PERMISSION, + ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, - ROOM_CREATE_PERMISSION, - RELAY_MEMBERS, - RELAY_ADD_MEMBER, - RELAY_REMOVE_MEMBER, - MESSAGE, - POLL_RESPONSE, - APP_DATA, - isSignedEvent, + ROOM_MEMBERS, + ROOM_META, + ROOM_PINS, + ROOM_REMOVE_MEMBER, + WRAP, + outbox, unionFilters, } from "@welshman/util" -import type {Filter, List, PublishedList, TrustedEvent} from "@welshman/util" -import {request, load} from "@welshman/net" +import type {Filter} from "@welshman/util" +import type { + FollowListReader, + MessagingRelayListReader, + RelayListReader, + RoomListReader, +} from "@welshman/domain" +import {merged, synced, withGetter} from "@welshman/store" import { - pubkey, - loadRelay, - userRelayList, - userMessagingRelayList, - loadRelayList, - loadMessagingRelayList, - loadBlossomServerList, - loadBlockedRelayList, - loadFollowList, - loadMuteList, - loadProfile, - userFollowList, - getFollows, - repository, - shouldUnwrap, + FollowLists, + MessagingRelayLists, + RelayLists, + RoomLists, + Sync, + makeRoomKey, } from "@welshman/app" +import { + app, + blockedRelayLists, + blossomServerLists, + deriveUserItem, + followLists, + messagingRelayLists, + muteLists, + network, + profiles, + relayLists, + relays, + roomLists, + router, +} from "@app/core" +import {LIVEKIT_PARTICIPANTS} from "@app/call" import {REACTION_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" import {INDEXER_RELAYS, PLATFORM_RELAYS} from "@app/env" -import {loadSettings} from "@app/settings" -import { - loadGroupList, - userSpaceUrls, - userGroupList, - getSpaceUrlsFromGroupList, - getSpaceRoomsFromGroupList, -} from "@app/groups" -import {decodeRelay} from "@app/relays" -import {RELAY_ROLE} from "@app/members" import {FEATURED_CONTENT_D} from "@app/featured" -import {BOARD, PIN} from "@app/pinboards" +import {decodeRelay} from "@app/relays" +import {Settings} from "@app/settings" +import {kv} from "@app/storage" import {hasBlossomSupport} from "@app/uploads" -import {LIVEKIT_PARTICIPANTS} from "@app/call" -import {ROOM_PINS} from "@app/pins" + +// Whether to sync gift wraps. Unwrapping itself is unconditional, so this is the only +// thing keeping a signer from being asked to decrypt the user's entire DM history. +export const shouldUnwrap = withGetter( + synced({key: "shouldUnwrap", storage: kv, defaultValue: false}), +) // Utils @@ -70,97 +80,52 @@ type SyncOpts = { url: string signal: AbortSignal filters: Filter[] - onEvent?: (event: TrustedEvent) => void } -const pullOneWithFallback = async ( - url: string, - filter: Filter, - signal: AbortSignal, - onEvent?: (event: TrustedEvent) => void, -) => { +const pullAndListen = ({url, signal, filters}: SyncOpts) => { if (signal.aborted) return - const cachedEvents = repository.query([filter]).filter(isSignedEvent) - const since = last(cachedEvents.slice(10))?.created_at || 0 - - if (onEvent) { - for (const event of cachedEvents) { - onEvent(event) - } - } - - // Temporarily disable negentropy until the new welshman lands, which fixes - // error handling (neg-err with auth-required were never getting retried) - const shouldFallback = true - // !hasNegentropy(url) || - // (await new Promise(resolve => { - // if (signal.aborted) { - // resolve(false) - // return - // } - - // // If teardown wins while the diff is opening, skip the fallback path and let cleanup stay in control. - // const diff = new Difference({relay: url, filter, events: cachedEvents, signal}) - - // diff.on(DifferenceEvent.Error, () => { - // resolve(true) - // }) - - // diff.on(DifferenceEvent.Close, () => { - // for (const ids of chunk(100, Array.from(diff.need))) { - // requestOne({relay: url, signal, autoClose: true, filters: [{ids}], onEvent}) - // } - - // resolve(false) - // }) - // })) - - if (shouldFallback && !signal.aborted) { - request({relays: [url], signal, autoClose: true, filters: [{since, ...filter}], onEvent}) - } + app + .get() + .use(Sync) + .pull({relays: [url], filters}) + network.get().request({ + relays: [url], + signal, + filters: unionFilters(filters.map(assoc("limit", 0))), + }) } -export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts) => { - await loadRelay(url) +const userRoomList = deriveUserItem($app => $app.use(RoomLists)) - if (signal.aborted) return +const userRelayList = deriveUserItem($app => $app.use(RelayLists)) - await Promise.all(filters.map(filter => pullOneWithFallback(url, filter, signal, onEvent))) -} +const userFollowList = deriveUserItem($app => $app.use(FollowLists)) -const listen = ({url, signal, filters, onEvent}: SyncOpts) => { - const relays = [url] +const userMessagingRelayList = deriveUserItem($app => $app.use(MessagingRelayLists)) - request({relays, signal, filters: unionFilters(filters.map(assoc("limit", 0))), onEvent}) -} - -const pullAndListen = (options: SyncOpts) => { - if (options.signal.aborted) return - - pullWithFallback(options) - listen(options) -} +const getSpaceUrls = ($roomList: Maybe) => + PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : ($roomList?.urls() ?? []) // Relays const syncRelays = () => { for (const url of INDEXER_RELAYS) { - loadRelay(url) + relays.get().load(url) } const unsubscribePage = page.subscribe($page => { if ($page.params.relay) { const url = decodeRelay($page.params.relay) - loadRelay(url) + relays.get().load(url) hasBlossomSupport(url) } }) - const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => { - for (const url of urls) { - loadRelay(url) + const unsubscribeSpaceUrls = userRoomList.subscribe($roomList => { + for (const url of $roomList?.urls() ?? []) { + relays.get().load(url) } }) @@ -173,7 +138,7 @@ const syncRelays = () => { // User data const syncUserSpaceMembership = (url: string) => { - const $pubkey = pubkey.get() + const $pubkey = app.get().user?.pubkey const controller = new AbortController() if ($pubkey) { @@ -192,7 +157,7 @@ const syncUserSpaceMembership = (url: string) => { } const syncUserRoomMembership = (url: string, h: string) => { - const $pubkey = pubkey.get() + const $pubkey = app.get().user?.pubkey const controller = new AbortController() if ($pubkey) { @@ -212,21 +177,19 @@ const syncUserRoomMembership = (url: string, h: string) => { const syncUserData = () => { const unsubscribersByKey = new Map() - const syncGroupList = ($userGroupList: List | undefined) => { - if ($userGroupList) { + const syncRoomList = ($roomList: Maybe) => { + if ($roomList) { const keys = new Set() - const urls = - PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : getSpaceUrlsFromGroupList($userGroupList) - for (const url of urls) { + for (const url of getSpaceUrls($roomList)) { if (!unsubscribersByKey.has(url)) { unsubscribersByKey.set(url, syncUserSpaceMembership(url)) } keys.add(url) - for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) { - const key = `${url}'${h}` + for (const h of $roomList.roomsForUrl(url)) { + const key = makeRoomKey(url, h) if (!unsubscribersByKey.has(key)) { unsubscribersByKey.set(key, syncUserRoomMembership(url, h)) @@ -245,46 +208,40 @@ const syncUserData = () => { } } - const syncRelayList = ($userRelayList: PublishedList | undefined) => { - const pubkey = $userRelayList?.event?.pubkey + const syncRelayList = ($relayList: Maybe) => { + const author = $relayList?.author() - if (pubkey) { - loadBlossomServerList(pubkey) - loadBlockedRelayList(pubkey) - loadFollowList(pubkey) - loadGroupList(pubkey) - loadMuteList(pubkey) - loadProfile(pubkey) - loadSettings(pubkey) + if (author) { + blossomServerLists.get().load(author) + blockedRelayLists.get().load(author) + followLists.get().load(author) + roomLists.get().load(author) + muteLists.get().load(author) + profiles.get().load(author) + app.get().use(Settings).load(author) } } - const syncFollowNetwork = ($userFollowList: List | undefined) => { - const author = $userFollowList?.event?.pubkey + const syncFollowNetwork = async ($followList: Maybe) => { + const authors = $followList?.pubkeys() ?? [] - if (author) { - const authors = getFollows(author) + if (authors.length > 0) { + const scenario = await router.get().resolve(authors.map(author => outbox(author))) - load({ + network.get().load({ filters: [{kinds: [FOLLOWS, MUTES], authors}], - relays: Router.get().FromPubkeys(authors).limit(8).getUrls(), + relays: scenario.limit(8).getUrls(), }) } } - const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => { - syncGroupList($userGroupList) - }) - - const unsubscribeRelayList = merged([userRelayList]).subscribe(([$userRelayList]) => { - syncRelayList($userRelayList) - }) - + const unsubscribeRoomList = userRoomList.subscribe(syncRoomList) + const unsubscribeRelayList = userRelayList.subscribe(syncRelayList) const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork) return () => { unsubscribersByKey.forEach(call) - unsubscribeGroupList() + unsubscribeRoomList() unsubscribeRelayList() unsubscribeFollowList() } @@ -317,7 +274,7 @@ const syncSpace = (url: string) => { ROOM_MEMBERS, ROOM_DELETE, LIVEKIT_PARTICIPANTS, - BOARD, + PINBOARD, ROOM_PINS, ], }, @@ -339,13 +296,10 @@ const syncSpace = (url: string) => { } const syncSpaces = () => { - const store = merged([userGroupList, page]) const unsubscribersByUrl = new Map() - const unsubscribe = store.subscribe(([$userGroupList, $page]) => { - const urls = new Set( - PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : getSpaceUrlsFromGroupList($userGroupList), - ) + const unsubscribe = merged([userRoomList, page]).subscribe(([$roomList, $page]) => { + const urls = new Set(getSpaceUrls($roomList)) const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined if (currentUrl) { @@ -404,34 +358,6 @@ const syncDMs = () => { } } - const syncPubkey = ($pubkey: string | undefined, $shouldUnwrap: boolean) => { - if ($pubkey !== currentPubkey) { - unsubscribeAll() - } - - if ($pubkey && $shouldUnwrap) { - loadRelayList($pubkey) - .then(() => loadMessagingRelayList($pubkey)) - .then($l => { - if ($l && currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) { - subscribeAll($pubkey, getRelayTagValues(getListTags($l))) - } - }) - } - - currentPubkey = $pubkey - currentShouldUnwrap = $shouldUnwrap - } - - const syncList = ($userMessagingRelayList: List | undefined) => { - const $pubkey = pubkey.get() - const $shouldUnwrap = shouldUnwrap.get() - - if ($pubkey && $shouldUnwrap) { - subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList))) - } - } - const subscribeAll = (pubkey: string, urls: string[]) => { // Start syncing newly added relays for (const url of urls) { @@ -449,20 +375,44 @@ const syncDMs = () => { } } - const unsubscribePubkey = merged([pubkey, shouldUnwrap]).subscribe(([$pubkey, $shouldUnwrap]) => { - syncPubkey($pubkey, $shouldUnwrap) + const syncPubkey = ($pubkey: Maybe, $shouldUnwrap: boolean) => { + if ($pubkey !== currentPubkey) { + unsubscribeAll() + } + + if ($pubkey && $shouldUnwrap) { + relayLists + .get() + .load($pubkey) + .then(() => messagingRelayLists.get().load($pubkey)) + .then($list => { + if ($list && currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) { + subscribeAll($pubkey, $list.urls()) + } + }) + } + + currentPubkey = $pubkey + currentShouldUnwrap = $shouldUnwrap + } + + const syncList = ($list: Maybe) => { + const $pubkey = app.get().user?.pubkey + + if ($pubkey && shouldUnwrap.get()) { + subscribeAll($pubkey, $list?.urls() ?? []) + } + } + + const unsubscribeUser = merged([app, shouldUnwrap]).subscribe(([$app, $shouldUnwrap]) => { + syncPubkey($app.user?.pubkey, $shouldUnwrap) }) - // When user messaging relays change, update synchronization - const unsubscribeList = merged([userMessagingRelayList]).subscribe( - ([$userMessagingRelayList]) => { - syncList($userMessagingRelayList) - }, - ) + const unsubscribeList = userMessagingRelayList.subscribe(syncList) return () => { unsubscribeAll() - unsubscribePubkey() + unsubscribeUser() unsubscribeList() } } diff --git a/src/app/title.ts b/src/app/title.ts index 4e605200..0af928a4 100644 --- a/src/app/title.ts +++ b/src/app/title.ts @@ -1,10 +1,11 @@ import {append, identity, uniq} from "@welshman/lib" -import {repository, displayProfileByPubkey} from "@welshman/app" -import {displayPubkey, getTagValue} from "@welshman/util" -import {PLATFORM_NAME} from "@app/env" +import {tagSpec, tagValue} from "@welshman/util" +import {displayPubkey} from "@welshman/domain" +import {makeRoomKey} from "@welshman/app" import {decodePubkey} from "@lib/util" +import {app, profiles, rooms} from "@app/core" +import {PLATFORM_NAME} from "@app/env" import {decodeRelay} from "@app/relays" -import {getRoom, makeRoomId} from "@app/groups" import {splitChatId} from "@app/chats" const FALLBACK_APP_NAME = "Flotilla" @@ -65,9 +66,9 @@ const getRoomTitle = (params: RouteParams) => { return "Room" } - const url = decodeRelay(relay) + const room = rooms.get().get(makeRoomKey(decodeRelay(relay), h)) - return getRoom(makeRoomId(url, h))?.name || "Room" + return room?.meta?.name() || "Room" } const getEventForTitle = (routeId: string, params: RouteParams) => { @@ -81,7 +82,7 @@ const getEventForTitle = (routeId: string, params: RouteParams) => { return } - return repository.getEvent(eventId) + return app.get().repository.getEvent(eventId) } const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => { @@ -127,7 +128,7 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => { const profilePubkey = decodePubkey(page.params.npub!) if (profilePubkey) { - return makeTitle(displayProfileByPubkey(profilePubkey)) + return makeTitle(profiles.get().display(profilePubkey).get()) } } @@ -138,19 +139,19 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => { const event = getEventForTitle(routeId, page.params) if (routeId === "/spaces/[relay]/threads/[id]") { - return makeTitle(getTagValue("title", event?.tags || []) || "Thread") + return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Thread") } if (routeId === "/spaces/[relay]/calendar/[address]") { - return makeTitle(getTagValue("title", event?.tags || []) || "Event") + return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Event") } if (routeId === "/spaces/[relay]/classifieds/[address]") { - return makeTitle(getTagValue("title", event?.tags || []) || "Listing") + return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Listing") } if (routeId === "/spaces/[relay]/goals/[id]") { - return makeTitle(event?.content || getTagValue("summary", event?.tags || []) || "Goal") + return makeTitle(event?.content || tagValue(tagSpec("summary"), event?.tags || []) || "Goal") } return makeTitle() diff --git a/src/app/uploads.ts b/src/app/uploads.ts index fd78d947..0afc1703 100644 --- a/src/app/uploads.ts +++ b/src/app/uploads.ts @@ -1,30 +1,20 @@ +import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib" +import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util" import {Nip01Signer} from "@welshman/signer" import type {UploadTask} from "@welshman/editor" -import { - canUploadBlob, - encryptFile, - getListTags, - getTagValues, - makeBlossomAuthEvent, - uploadBlob, -} from "@welshman/util" -import {getRelay, signer, userBlossomServerList} from "@welshman/app" -import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib" -import {get} from "svelte/store" import {compressFile} from "@lib/html" +import {app, blossomServerLists, relays} from "@app/core" import {DEFAULT_BLOSSOM_SERVERS} from "@app/env" export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http")) export const fetchHasBlossomSupport = async (url: string) => { - const relay = getRelay(url) - - if (relay?.supported_nips?.map(String).includes("BUD-02")) { + if (relays.get().get(url)?.hasNip("BUD-02")) { return true } const server = normalizeBlossomUrl(url) - const $signer = signer.get() || Nip01Signer.ephemeral() + const $signer = app.get().user?.signer || Nip01Signer.ephemeral() const headers: Record = { "X-Content-Type": "text/plain", "X-Content-Length": "1", @@ -58,10 +48,14 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) => } } - const userUrls = getTagValues("server", getListTags(get(userBlossomServerList))) + const $pubkey = app.get().user?.pubkey - for (const url of userUrls) { - return normalizeBlossomUrl(url) + if ($pubkey) { + const userUrl = first(blossomServerLists.get().urls($pubkey).get()) + + if (userUrl) { + return normalizeBlossomUrl(userUrl) + } } return first(DEFAULT_BLOSSOM_SERVERS)! @@ -111,7 +105,7 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) => const ext = "." + type.split("/")[1] const server = await getBlossomServer(options) const hashes = [await sha256(await file.arrayBuffer())] - const $signer = signer.get() || Nip01Signer.ephemeral() + const $signer = app.get().user?.signer || Nip01Signer.ephemeral() const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes}) const authEvent = await $signer.sign(authTemplate) const res = await uploadBlob(server, file, {authEvent}) diff --git a/src/lib/currency.ts b/src/lib/currency.ts index 3add9efa..60581f7a 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -1,3 +1,5 @@ +import {spec} from "@welshman/lib" + export type CurrencyOption = { code: string name: string @@ -163,10 +165,10 @@ export const currencyOptions: CurrencyOption[] = [ {name: "Zambian Kwacha", symbol: "K", code: "ZMW"}, ] -export const defaultCurrencyOption = currencyOptions.find(c => c.code === "SAT") +export const defaultCurrencyOption = currencyOptions.find(spec({code: "SAT"})) export const getCurrencyOption = (code: string): CurrencyOption => - currencyOptions.find(c => c.code === code) || {name: code, symbol: "$", code} + currencyOptions.find(spec({code})) || {name: code, symbol: "$", code} export const displayCurrency = ({code, name}: CurrencyOption) => `${code} (${name})` diff --git a/src/lib/indexeddb.ts b/src/lib/indexeddb.ts index f3427322..f34034fb 100644 --- a/src/lib/indexeddb.ts +++ b/src/lib/indexeddb.ts @@ -9,46 +9,50 @@ export type IDBStore = { export type IDBOptions = { name: string - version: number stores: IDBStore[] } export class IDB { - connection: Maybe> + connection: Maybe>> failedToConnect = false constructor(readonly options: IDBOptions) {} + // Object stores can only be created during a version change, and which stores we need depends + // on who is logged in, so open at whatever version exists and bump it to add missing ones. + private open = async () => { + const {name, stores} = this.options + const db = await openDB(name) + const missing = stores.filter(store => !db.objectStoreNames.contains(store.name)) + + if (missing.length === 0) { + return db + } + + const version = db.version + 1 + + db.close() + + return openDB(name, version, { + upgrade(idbDb: IDBPDatabase) { + for (const {name, keyPath} of missing) { + idbDb.createObjectStore(name, {keyPath}) + } + }, + blocked() {}, + blocking() {}, + }) + } + async connect() { if (!this.failedToConnect && !this.connection) { - const {name, version, stores} = this.options - - try { - this.connection = openDB(name, version, { - upgrade(idbDb: IDBPDatabase) { - const names = new Set(stores.map(store => store.name)) - - for (const table of idbDb.objectStoreNames) { - if (!names.has(table)) { - idbDb.deleteObjectStore(table) - } - } - - for (const {name, keyPath} of stores) { - try { - idbDb.createObjectStore(name, {keyPath}) - } catch (e) { - console.warn(e) - } - } - }, - blocked() {}, - blocking() {}, - }) - } catch (e) { + this.connection = this.open().catch(e => { console.error("Failed to connect to indexeddb", e) + this.failedToConnect = true - } + + return undefined + }) } return this.connection @@ -104,12 +108,12 @@ export class IDB { } close = () => { - this.connection?.then(c => c.close()) + this.connection?.then(c => c?.close()) this.connection = undefined } clear = async () => { - await this.connection?.then(c => c.close()) + await this.connection?.then(c => c?.close()) await deleteDB(this.options.name, { blocked() {}, }) diff --git a/src/lib/livekit.ts b/src/lib/livekit.ts index 431e282a..12f1f1ad 100644 --- a/src/lib/livekit.ts +++ b/src/lib/livekit.ts @@ -4,9 +4,9 @@ const toHttpUrl = (url: string) => .replace(/^ws:\/\//, "http://") .replace(/\/$/, "") -const livekitEndpoint = (url: string, groupId?: string) => { +const livekitEndpoint = (url: string, roomId?: string) => { const base = `${toHttpUrl(url)}/.well-known/nip29/livekit` - return groupId ? `${base}/${groupId}` : base + return roomId ? `${base}/${roomId}` : base } export const checkRelayHasLivekit = async (url: string): Promise => { @@ -20,4 +20,4 @@ export const checkRelayHasLivekit = async (url: string): Promise => { } } -export const getLivekitEndpoint = (url: string, groupId: string) => livekitEndpoint(url, groupId) +export const getLivekitEndpoint = (url: string, roomId: string) => livekitEndpoint(url, roomId) diff --git a/src/lib/test/relayMocks.ts b/src/lib/test/relayMocks.ts index 03b923f0..2d1a1514 100644 --- a/src/lib/test/relayMocks.ts +++ b/src/lib/test/relayMocks.ts @@ -1,8 +1,9 @@ import {on} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" -import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository, netContext} from "@welshman/net" -import type {ClientMessage, NetContext, RelayMessage} from "@welshman/net" +import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository} from "@welshman/net" +import type {AdapterFactory, ClientMessage, RelayMessage} from "@welshman/net" // The window key Playwright writes the mock config to (see e2e/support/relayMocks.ts). Keep it in // sync with the literal duplicated there. @@ -50,10 +51,10 @@ class FixtureAdapter extends AbstractAdapter { } } -// Override netContext.getAdapter so every real relay url is served from memory and no websocket is -// ever created for it. Non-relay urls (e.g. the local:// repository relay) fall through to -// welshman's default handling. -export const installRelayMocks = (config: RelayMockConfig) => { +// An adapter factory serving every real relay url from memory, so no websocket is ever created for +// one. Non-relay urls (e.g. the local:// repository relay) fall through to welshman's default +// handling by returning undefined. +export const makeRelayMockAdapter = (config: RelayMockConfig): AdapterFactory => { const reposByUrl = new Map() for (const [url, events] of Object.entries(config.relays ?? {})) { @@ -63,29 +64,20 @@ export const installRelayMocks = (config: RelayMockConfig) => { } const emptyRepository = new Repository() - const fallback = netContext.getAdapter - netContext.getAdapter = ((url: string, context: NetContext) => { - if (!isRelayUrl(url)) { - return fallback ? fallback(url, context) : undefined + return (url: string) => { + if (isRelayUrl(url)) { + return new FixtureAdapter(url, reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository) } - - const repository = reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository - - return new FixtureAdapter(url, repository) - }) as NetContext["getAdapter"] + } } -// Called once on app startup. Installs the mocks only when Playwright has injected a config, so it -// is a no-op for real users. -export const maybeInstallRelayMocks = () => { - const config = (globalThis as Record)[RELAY_MOCKS_KEY] as - | RelayMockConfig - | undefined +// Called when the app is created. Yields an adapter factory only when Playwright has injected a +// config, so it is a no-op for real users. +export const maybeMakeRelayMockAdapter = (): Maybe => { + const config = (globalThis as {[RELAY_MOCKS_KEY]?: RelayMockConfig})[RELAY_MOCKS_KEY] if (config) { - installRelayMocks(config) + return makeRelayMockAdapter(config) } - - return Boolean(config) } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7ad2ada0..cc9f4249 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,131 +9,48 @@ import {dev} from "$app/environment" import {goto} from "$app/navigation" import {page} from "$app/stores" - import {sync, throttled} from "@welshman/store" - import {always, call} from "@welshman/lib" - import {defaultSocketPolicies, netContext, Pool} from "@welshman/net" - import {appContext, pubkey, sessions, signerLog, shouldUnwrap} from "@welshman/app" - import {routerContext} from "@welshman/router" - import {verifyEvent} from "@welshman/util" - import type {TrustedEvent} from "@welshman/util" import {context as pomadeContext} from "@pomade/core" - import * as lib from "@welshman/lib" + import {sync, throttled} from "@welshman/store" + import * as domain from "@welshman/domain" import * as util from "@welshman/util" - import * as feeds from "@welshman/feeds" - import * as router from "@welshman/router" - import * as store from "@welshman/store" - import * as welshmanSigner from "@welshman/signer" - import * as net from "@welshman/net" - import * as app from "@welshman/app" + import * as lib from "@welshman/lib" + import {Logger} from "@welshman/app" import {isMobile} from "@lib/html" - import * as implicit from "@lib/implicit" - import {maybeInstallRelayMocks} from "@lib/test/relayMocks" import AppContainer from "@app/components/AppContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte" + import {app} from "@app/core" import {setupHistory} from "@app/routes" import {setupAnalytics} from "@app/analytics" - import {authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/policies" - import {db, kv, ss} from "@app/storage" + import "@app/policies" + import {restoreSession} from "@app/session" + import {signerRequests} from "@app/signer" + import {wallet} from "@app/lightning" + import {kv, ss, storage} from "@app/storage" import {device} from "@app/device" - import {getSetting, userSettings, notificationSettings} from "@app/settings" - import {DUFFLEPUD_URL, DEFAULT_RELAYS, INDEXER_RELAYS, POMADE_SIGNERS} from "@app/env" - import {pushState} from "@app/push/adapters/common" - import {syncApplicationData} from "@app/sync" - import * as groups from "@app/groups" - import * as comments from "@app/comments" - import * as deletes from "@app/deletes" - import * as reactions from "@app/reactions" - import * as profiles from "@app/profiles" - import * as lightning from "@app/lightning" - import * as uploads from "@app/uploads" - import * as appPolls from "@app/polls" - import * as pinboards from "@app/pinboards" - import * as reports from "@app/reports" - import * as relays from "@app/relays" - import * as settings from "@app/settings" - import * as members from "@app/members" - import * as chats from "@app/chats" - import * as content from "@app/content" + import {userSettingsValues, notificationSettings} from "@app/settings" + import {shouldUnwrap, syncApplicationData} from "@app/sync" import * as env from "@app/env" - import * as repository from "@app/repository" - import * as social from "@app/social" - import * as appDevice from "@app/device" - import * as actionItems from "@app/actionItems" - import * as appFeeds from "@app/feeds" - import * as access from "@app/access" - import * as healthChecks from "@app/healthChecks" import {theme} from "@app/theme" import {toast, pushToast} from "@app/toast" import * as notifications from "@app/notifications" import {Push} from "@app/push" - import {onPushNotificationAction} from "@app/push/adapters/common" - import * as storage from "@app/storage" + import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" import {getPageTitle} from "@app/title" import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte" const {children} = $props() - // Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory - // fixtures instead of the network. No-op for real users; stripped from production builds. - if (import.meta.env.DEV) { - maybeInstallRelayMocks() - } - // Do this asap to avoid a font size flash // @ts-ignore document.documentElement.style["font-size"] = `${localStorage.getItem("font-size") || 1.1}rem` - const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy] - // Add stuff to window for convenience - Object.assign(window, { - get, - nip19, - theme, - ...lib, - ...implicit, - ...welshmanSigner, - ...router, - ...store, - ...util, - ...feeds, - ...net, - ...app, - ...groups, - ...relays, - ...settings, - ...members, - ...chats, - ...content, - ...env, - ...repository, - ...social, - ...appDevice, - ...actionItems, - ...appFeeds, - ...access, - ...healthChecks, - ...comments, - ...deletes, - ...reactions, - ...profiles, - ...lightning, - ...uploads, - ...appPolls, - ...pinboards, - ...reports, - ...notifications, - }) + Object.assign(window, {get, nip19, theme, app, domain, Logger, ...lib, ...util}) // Set up context for various modules - pomadeContext.setSignerUrls(POMADE_SIGNERS) + pomadeContext.setSignerUrls(env.POMADE_SIGNERS) pomadeContext.setArgonWorker(import("@pomade/core/argon-worker.js?worker")) - appContext.dufflepudUrl = DUFFLEPUD_URL - routerContext.getIndexerRelays = always(INDEXER_RELAYS) - routerContext.getDefaultRelays = always(DEFAULT_RELAYS) - netContext.isEventValid = (event: TrustedEvent, url: string) => - getSetting("trusted_relays").includes(url) || verifyEvent(event) // Handle a deep link (universal/app link or custom scheme). Used for both // warm-start links (via the appUrlOpen event) and cold-start links (via @@ -182,12 +99,17 @@ } }) - // Cleanup on page close - window.addEventListener("beforeunload", () => db.close()) + const closeStorage = () => storage.get()?.close() - const unsubscribe = call(async () => { + // Cleanup on page close + window.addEventListener("beforeunload", closeStorage) + + const unsubscribe = lib.call(async () => { const unsubscribers: Unsubscriber[] = [] + // Attach the user before anything reads or decrypts on their behalf + unsubscribers.push(await restoreSession()) + // Sync stuff to storage await Promise.all([ sync({ @@ -195,21 +117,16 @@ store: device, storage: kv, }), - sync({ - key: "pubkey", - store: pubkey, - storage: kv, - }), - sync({ - key: "sessions", - store: sessions, - storage: ss, - }), sync({ key: "shouldUnwrap", store: shouldUnwrap, storage: kv, }), + sync({ + key: "wallet", + store: wallet, + storage: ss, + }), sync({ key: "notificationSettings", store: notificationSettings, @@ -222,12 +139,8 @@ }), ]) - const storageSync = storage.sync() - - unsubscribers.push(storageSync.unsubscribe) - // Wait for critical storage data only - await storageSync.ready + await storage.get()?.ready // Handle cold-start deep links. When a link launches the app from a killed // state the intent arrives via onCreate, so Capacitor never emits @@ -241,13 +154,7 @@ } // Close the database connection on reload - unsubscribers.push(() => db.close()) - - // Add our extra policies now that we're set up - defaultSocketPolicies.push(...policies) - - // Remove policies when we're done - unsubscribers.push(() => defaultSocketPolicies.splice(-policies.length)) + unsubscribers.push(closeStorage) // History, navigation, application data unsubscribers.push(setupHistory(), setupAnalytics(), syncApplicationData()) @@ -267,14 +174,14 @@ // Initialize background notifications unsubscribers.push(Push.sync()) - // When the user logs in, drop connections opened anonymously and sync again - let lastPubkey = pubkey.get() + // Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync + // application data against the new identity's relays. + let currentApp = app.get() unsubscribers.push( - pubkey.subscribe($pubkey => { - if ($pubkey !== lastPubkey) { - lastPubkey = $pubkey - Pool.get().clear() + app.subscribe($app => { + if ($app !== currentApp) { + currentApp = $app syncApplicationData() } }), @@ -282,15 +189,15 @@ // Listen for signer errors, report to user via toast unsubscribers.push( - throttled(10_000, signerLog).subscribe($log => { + throttled(10_000, signerRequests).subscribe($requests => { if ($toast) return const longCutoff = Date.now() - 30_000 const shortCutoff = Date.now() - 10_000 - const pending = $log.filter(x => !x.finished_at && x.started_at < longCutoff) - const completed = $log.filter(x => x.finished_at && x.finished_at > shortCutoff) + const pending = $requests.filter(r => !r.finishedAt && r.startedAt < longCutoff) + const completed = $requests.filter(r => r.finishedAt && r.finishedAt > shortCutoff) const showPendingError = pending.length > 10 - const showCompletedError = completed.length > 5 && completed.filter(x => x.ok).length === 0 + const showCompletedError = completed.length > 5 && completed.filter(r => r.ok).length === 0 if (showPendingError || showCompletedError) { pushToast({ @@ -312,29 +219,27 @@ document.body.setAttribute("data-theme", $theme) document.body.setAttribute("data-fl-theme", env.FL_THEME) }), - userSettings.subscribe( - debounce(100, $userSettings => { - if ($userSettings) { - localStorage.setItem("font-size", $userSettings.values.font_size) + userSettingsValues.subscribe( + debounce(100, $settings => { + localStorage.setItem("font-size", String($settings.font_size)) - // @ts-ignore - document.documentElement.style["font-size"] = `${$userSettings.values.font_size}rem` - } + // @ts-ignore + document.documentElement.style["font-size"] = `${$settings.font_size}rem` }), ), ) - return () => unsubscribers.forEach(call) + return () => unsubscribers.forEach(lib.call) }) // Cleanup on hot reload import.meta.hot?.dispose(() => { App.removeAllListeners() - unsubscribe.then(call) + unsubscribe.then(lib.call) }) $effect(() => { - document.title = getPageTitle({page: $page, pubkey: $pubkey}) + document.title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) }) diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 8660201e..00da7ee4 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -3,7 +3,6 @@ import {onMount} from "svelte" import {page} from "$app/stores" import {sleep} from "@welshman/lib" - import {shouldUnwrap} from "@welshman/app" import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import ChatSquarePlus from "@assets/icons/chat-square-plus.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl" @@ -20,6 +19,7 @@ import ChatItem from "@app/components/ChatItem.svelte" import {chatSearch} from "@app/chats" import {pushModal} from "@app/modal" + import {shouldUnwrap} from "@app/sync" type Props = { children?: Snippet diff --git a/src/routes/chat/[chat]/+page.svelte b/src/routes/chat/[chat]/+page.svelte index cc0d51e2..37c02b5e 100644 --- a/src/routes/chat/[chat]/+page.svelte +++ b/src/routes/chat/[chat]/+page.svelte @@ -14,15 +14,15 @@ import {page} from "$app/stores" import type {MakeNonOptional} from "@welshman/lib" import {append, uniq} from "@welshman/lib" - import {pubkey} from "@welshman/app" import Chat from "@app/components/Chat.svelte" import {splitChatId} from "@app/chats" import {notificationSettings} from "@app/settings" import {pushToast} from "@app/toast" import {Push} from "@app/push" + import {user} from "@app/core" const {chat} = $page.params as MakeNonOptional - const pubkeys = uniq(append($pubkey!, splitChatId(chat))) + const pubkeys = uniq(append($user.pubkey, splitChatId(chat))) onMount(async () => { if (!$dmNotificationsPrompted) { diff --git a/src/routes/people/+page.svelte b/src/routes/people/+page.svelte index 4e8aef10..b3b55f60 100644 --- a/src/routes/people/+page.svelte +++ b/src/routes/people/+page.svelte @@ -1,14 +1,16 @@
    diff --git a/src/routes/settings/content/+page.svelte b/src/routes/settings/content/+page.svelte index dbaeb72d..e8d01c0a 100644 --- a/src/routes/settings/content/+page.svelte +++ b/src/routes/settings/content/+page.svelte @@ -1,15 +1,5 @@
    diff --git a/src/routes/settings/hosting/+page.svelte b/src/routes/settings/hosting/+page.svelte index 82cb2102..29d34450 100644 --- a/src/routes/settings/hosting/+page.svelte +++ b/src/routes/settings/hosting/+page.svelte @@ -1,9 +1,8 @@ @@ -84,7 +96,7 @@ relays={searchRelayUrls} addRelay={addSearchRelay} removeRelay={removeSearchRelay} - matchRelay={url => hasNip50(getRelay(url))} /> + matchRelay={relaySupportsSearch} /> pushModal(WalletConnect) @@ -33,16 +33,14 @@ const updateReceivingAddress = () => pushModal(WalletUpdateReceivingAddress) - const profile = $derived($profilesByPubkey.get($pubkey || "")) - const profileLightningAddress = $derived(profile?.lud16) - const walletLud16 = $derived( - $session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined, - ) + const profile = $derived($profiles.get($user.pubkey)) + const profileLightningAddress = $derived(profile?.lnurl()) + const walletLud16 = $derived($wallet && isNWCWallet($wallet) ? $wallet.info.lud16 : undefined) const pay = () => pushModal(WalletPay) const receive = () => { - if ($session?.wallet) { + if ($wallet) { pushModal(WalletReceive) } else { pushModal(WalletConnect) @@ -51,7 +49,7 @@ let walletStatus = $state("checking") - const isWalletAvailable = $derived(Boolean($session?.wallet) && walletStatus === "connected") + const isWalletAvailable = $derived(Boolean($wallet) && walletStatus === "connected") const statusClass = $derived( cx("flex items-center gap-2 text-sm", { "text-success": walletStatus === "connected", @@ -60,12 +58,12 @@ ) const connectionVerb = $derived(walletStatus === "connected" ? "Connected to" : "Configured for") - const startWalletStatusCheck = async (wallet = $session?.wallet) => { + const startWalletStatusCheck = async (currentWallet = $wallet) => { walletStatus = "checking" - if (wallet) { + if (currentWallet) { const promise = - wallet.type === WalletType.NWC + currentWallet.type === WalletType.NWC ? getNwcClient().getInfo() : call(async () => { const client = getWebLn() @@ -83,7 +81,7 @@ } $effect(() => { - startWalletStatusCheck($session?.wallet) + startWalletStatusCheck($wallet) }) const resetZapAmounts = () => { @@ -144,7 +142,7 @@ Your Wallet - {#if $session?.wallet} + {#if $wallet}
    {#if walletStatus === "checking"} @@ -165,13 +163,13 @@ {/if}
    - {#if $session?.wallet} - {#if $session.wallet.type === "webln"} - {@const {node, version} = $session.wallet.info} + {#if $wallet} + {#if $wallet.type === "webln"} + {@const {node, version} = $wallet.info}

    {connectionVerb} {node?.alias || version || "unknown wallet"} - via {$session.wallet.type} + via {$wallet.type}

    {#if walletStatus === "connected"} @@ -194,8 +192,8 @@ {/if}

    - {:else if $session.wallet.type === "nwc"} - {@const {lud16, relayUrl} = $session.wallet.info} + {:else if $wallet.type === "nwc"} + {@const {lud16, relayUrl} = $wallet.info}

    {connectionVerb} {lud16} via @@ -257,7 +255,7 @@

    - {#if profileLightningAddress && walletLud16 && profile?.lud16 !== walletLud16} + {#if profileLightningAddress && walletLud16 && profileLightningAddress !== walletLud16}
    Your profile has a different lightning address than your connected wallet. diff --git a/src/routes/spaces/+page.svelte b/src/routes/spaces/+page.svelte index e0743ad2..6d787bea 100644 --- a/src/routes/spaces/+page.svelte +++ b/src/routes/spaces/+page.svelte @@ -3,11 +3,11 @@ import {flip} from "svelte/animate" import {cubicOut} from "svelte/easing" import {derived as _derived} from "svelte/store" - import {dec, insertAt, removeAt, sleep} from "@welshman/lib" - import type {RelayProfile} from "@welshman/util" + import {addToMapKey, dec, insertAt, removeAt, sleep, spec} from "@welshman/lib" import {ROOMS} from "@welshman/util" + import type {Relay} from "@welshman/domain" import {throttled} from "@welshman/store" - import {pull, relays, createSearch} from "@welshman/app" + import {Sync, createSearch, publish} from "@welshman/app" import {createScroller, isMobile} from "@lib/html" import {fly} from "@lib/transition" import DragHandle from "@assets/icons/drag-handle.svg?dataurl" @@ -26,7 +26,8 @@ import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte" - import {userSpaceUrls, loadUserGroupList, groupListPubkeysByUrl, setSpaces} from "@app/groups" + import {app, relays, roomLists, user} from "@app/core" + import {userSpaceUrls} from "@app/rooms" import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env" import {bootstrapPubkeys} from "@app/social" import {parseInviteLink} from "@app/access" @@ -36,25 +37,44 @@ const addSpace = () => pushModal(SpaceAdd) - const relaySearch = _derived(throttled(1000, relays), $relays => { - const options = $relays.filter(r => $groupListPubkeysByUrl.has(r.url)) + const userSpacesLoaded = $roomLists.load($user.pubkey) - return createSearch(options, { - getValue: (relay: RelayProfile) => relay.url, - sortFn: ({score, item}) => { - if (score && score > 0.1) return -score! + // How many people list each space, used both to limit search to spaces someone + // has joined and to rank them. + const spacePubkeysByUrl = _derived($roomLists.all.$, $roomLists => { + const result = new Map>() - const wotScore = $groupListPubkeysByUrl.get(item.url)?.size || 0 + for (const roomList of $roomLists) { + for (const url of roomList.urls()) { + addToMapKey(result, url, roomList.author()) + } + } - return score ? dec(score) * wotScore : -wotScore - }, - fuseOptions: { - keys: ["url", "name", {name: "description", weight: 0.3}], - shouldSort: false, - }, - }) + return result }) + const relaySearch = _derived( + [throttled(1000, $relays.all.$), spacePubkeysByUrl], + ([$relays, $spacePubkeysByUrl]) => { + const options = $relays.filter(r => $spacePubkeysByUrl.has(r.url)) + + return createSearch(options, { + getValue: (relay: Relay) => relay.url, + sortFn: ({score, item}) => { + if (score && score > 0.1) return -score! + + const wotScore = $spacePubkeysByUrl.get(item.url)?.size || 0 + + return score ? dec(score) * wotScore : -wotScore + }, + fuseOptions: { + keys: ["url", "name", {name: "description", weight: 0.3}], + shouldSort: false, + }, + }) + }, + ) + const openSpace = (url: string, claim = "") => { if ($userSpaceUrls.includes(url)) { goToSpace(url) @@ -128,7 +148,7 @@ lastDragTarget = undefined if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) { - void setSpaces(orderedSpaceUrls).catch(console.error) + void $roomLists.setRelays(orderedSpaceUrls).then(publish).catch(console.error) } dragStartOrder = undefined @@ -166,9 +186,7 @@ const searchResults = $derived($relaySearch.searchOptions(term)) const userSpaceSet = $derived(new Set($userSpaceUrls)) const filteredUserUrls = $derived( - term - ? orderedSpaceUrls.filter(url => searchResults.some(r => r.url === url)) - : orderedSpaceUrls, + term ? orderedSpaceUrls.filter(url => searchResults.some(spec({url}))) : orderedSpaceUrls, ) const otherSpaces = $derived( searchResults.filter(r => !userSpaceSet.has(r.url) && r.url !== inviteData?.url), @@ -182,7 +200,7 @@ }, }) - pull({ + $app.use(Sync).pull({ filters: [{kinds: [ROOMS], authors: $bootstrapPubkeys}], relays: DEFAULT_RELAYS, }) @@ -231,7 +249,7 @@ {:else} - {#await loadUserGroupList()} + {#await userSpacesLoaded}
    Loading your spaces... diff --git a/src/routes/spaces/[relay]/+layout.svelte b/src/routes/spaces/[relay]/+layout.svelte index c54fb299..00e21d13 100644 --- a/src/routes/spaces/[relay]/+layout.svelte +++ b/src/routes/spaces/[relay]/+layout.svelte @@ -8,6 +8,7 @@ import {page} from "$app/stores" import type {Maybe} from "@welshman/lib" import {once} from "@welshman/lib" + import {normalizeRelayUrl} from "@welshman/util" import Page from "@lib/components/Page.svelte" import SecondaryNav from "@lib/components/SecondaryNav.svelte" import SpaceMenu from "@app/components/SpaceMenu.svelte" @@ -16,10 +17,11 @@ import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceRedirect from "@app/components/SpaceRedirect.svelte" import {deriveRelayAuthError} from "@app/access" - import {loadUserGroupList, userSpaceUrls} from "@app/groups" + import {relays, roomLists, user} from "@app/core" + import {userSpaceUrls} from "@app/rooms" import {modal, pushModal} from "@app/modal" import {relaysPendingTrust} from "@app/policies" - import {decodeRelay, fetchRelayRedirect} from "@app/relays" + import {decodeRelay} from "@app/relays" import {makeSpacePath} from "@app/routes" type Props = { @@ -38,13 +40,27 @@ const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true})) - // Detect a moved custom domain (see fetchRelayRedirect) + // Detect a moved custom domain — the relay's nip-11 document answered with a redirect const checkRedirect = once(() => { - fetchRelayRedirect(url).then(next => { - redirectUrl = next + $relays.load(url).then($relay => { + const next = $relay?.redirect_to + + if (next) { + redirectUrl = normalizeRelayUrl(next.replace(/^http/, "ws")) + } }) }) + const loadSpaces = async () => { + const currentPubkey = user.get().pubkey + + if (currentPubkey) { + await $roomLists.load(currentPubkey, [url]) + } + + spacesLoaded = true + } + // Track this manually since we want to avoid race conditions in which we show this prompt before we load let spacesLoaded = $state(false) @@ -57,7 +73,7 @@ $effect(() => { if ($modal) return - if (redirectUrl && !redirectPrompted.has(url)) { + if (redirectUrl && redirectUrl !== url && !redirectPrompted.has(url)) { redirectPrompted.add(url) pushModal(SpaceRedirect, {url, newUrl: redirectUrl}) } else if (!$userSpaceUrls.includes(url) && !joinPrompted.has(url)) { @@ -65,9 +81,7 @@ joinPrompted.add(url) pushModal(SpaceJoin, {url}) } else { - loadUserGroupList([url]).then(() => { - spacesLoaded = true - }) + loadSpaces() } } else if ($authError) { showAuthError() diff --git a/src/routes/spaces/[relay]/about/+page.svelte b/src/routes/spaces/[relay]/about/+page.svelte index 463b3cb4..4b188d07 100644 --- a/src/routes/spaces/[relay]/about/+page.svelte +++ b/src/routes/spaces/[relay]/about/+page.svelte @@ -2,7 +2,6 @@ import {page} from "$app/stores" import {goto} from "$app/navigation" import {displayRelayUrl} from "@welshman/util" - import {deriveRelay} from "@welshman/app" import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl" import ShieldUser from "@assets/icons/shield-user.svg?dataurl" import BillList from "@assets/icons/bill-list.svg?dataurl" @@ -16,11 +15,12 @@ import ProfileLink from "@app/components/ProfileLink.svelte" import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte" import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte" + import {relays} from "@app/core" import {makeSpacePath} from "@app/routes" import {decodeRelay} from "@app/relays" const url = decodeRelay($page.params.relay!) - const relay = deriveRelay(url) + const relay = $relays.one(url) const showMenu = () => goto(makeSpacePath(url)) diff --git a/src/routes/spaces/[relay]/calendar/+page.svelte b/src/routes/spaces/[relay]/calendar/+page.svelte index 894abbaa..f0fab54e 100644 --- a/src/routes/spaces/[relay]/calendar/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/+page.svelte @@ -5,7 +5,7 @@ import {page} from "$app/stores" import {now, last, formatTimestampAsDate} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {EVENT_TIME, getTagValue} from "@welshman/util" + import {EVENT_TIME, tagValue, tagSpec} from "@welshman/util" import {fly} from "@lib/transition" import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl" @@ -26,7 +26,7 @@ const makeEvent = () => pushModal(CalendarEventCreate, {url}) - const getStart = (event: TrustedEvent) => parseInt(getTagValue("start", event.tags) || "") + const getStart = (event: TrustedEvent) => parseInt(tagValue(tagSpec("start"), event.tags) || "") let element: HTMLElement | undefined = $state() let loading = $state(true) diff --git a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte index 9d86d9ab..50a47ff5 100644 --- a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte @@ -1,12 +1,11 @@ diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index 063823bf..0e933b4f 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -5,7 +5,7 @@ import {page} from "$app/stores" import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {ZAP_GOAL, getTagValue} from "@welshman/util" + import {ZAP_GOAL, tagSpec, tagValue} from "@welshman/util" import {fly} from "@lib/transition" import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl" @@ -34,7 +34,7 @@ const [goals, comments] = partition(spec({kind: ZAP_GOAL}), $events) for (const comment of comments) { - const id = getTagValue("E", comment.tags) + const id = tagValue(tagSpec("E"), comment.tags) if (id) { pushToMapKey(scores, id, comment.created_at) diff --git a/src/routes/spaces/[relay]/goals/[id]/+page.svelte b/src/routes/spaces/[relay]/goals/[id]/+page.svelte index 6efee7ff..8ce2c71e 100644 --- a/src/routes/spaces/[relay]/goals/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/goals/[id]/+page.svelte @@ -1,12 +1,12 @@