diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index ec395e16..c7e52eb9 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -526,6 +526,21 @@ Acceptance: a conversation with bob appears in her list without a reload. - Opening it shows his message. +### US-108 — Read messages from a relay you only use for messages + +As alice, I want the conversations on my messaging relay to load even when that relay is not one +of my spaces, so that direct messages work wherever I have pointed them. + +Acceptance: + +- With alice's messaging relays naming a relay she has not joined and neither reads from nor + publishes to, a conversation held there still appears in her chat list and opens with its + history. +- That relay hands her messages to nobody but her, so her messaging relay list is the only thing + that can vouch for her to it. +- A messaging relay list written by another client, naming the same relay without the trailing + slash, is honoured the same way. + ## Articles & threads ### US-037 — Write and publish an article @@ -1565,6 +1580,14 @@ navigation off the app's origin, so nothing about the destination is observable. logs into a DM to the platform's support contact. It targets a hardcoded pubkey whose relays are not part of the sealed test network. +**A network read that fails.** Every relay a scenario declares answers, and a url the container +does not serve is answered by an empty relay rather than refused, so no spec can express a read +that fails. That leaves one invariant untested: a send whose reads fail before the message exists +must keep the text in the composer and say so, rather than clearing as though it went. It is the +shape of the bug that motivated US-108 — `@welshman/store`'s `load` rejects rather than resolving +empty, so a failure there aborts a publish before its thunk is made and nothing reaches the +timeline to carry a status. Testing it needs a seam for making a relay unusable. + **Internals with no user-visible surface.** The legacy session-storage format migration, which has no observable difference and no supported way to seed the old shape. `ProfileFeed` and `ProfileLatest` components that no route reaches. diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts index 94ff6f0a..8acfb767 100644 --- a/e2e/specs/dms.spec.ts +++ b/e2e/specs/dms.spec.ts @@ -611,3 +611,44 @@ test("US-036 receive a new conversation live", async ({seed, as}) => { await expect(bubble(alice, "starting a chat with you")).toBeVisible() }) + +test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => { + await seed(({relay, user, at}) => { + const space = relay("space") + const inbox = relay("other") + + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + enableDms(space, user.bob) + + // Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and + // not one of her read or write relays. Membership of it is only what lets a wrap addressed to + // her be stored there — it never reaches her room list — so her messaging relay list is the + // one thing that can vouch for her, and the relay serves her nothing until it does. + inbox.member(user.alice) + + // Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written + // by another client is where a url missing its trailing slash comes from, and the relay it + // names is the same relay either way. + space.event(user.alice, () => + space + .kind(MessagingRelayList) + .writer() + .addTags(["relay", inbox.url.replace(/\/$/, "")]) + .renderTemplate(), + ) + + inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR)) + }) + + const page = await as(users.alice, "/chat") + + const conversation = chatItems(page).filter({hasText: "over on your inbox relay"}) + + await expect(conversation).toContainText("Bob Barnacle") + + await conversation.click() + + await expect(pageBar(page)).toContainText("Bob Barnacle") + await expect(bubble(page, "over on your inbox relay")).toBeVisible() +}) diff --git a/package.json b/package.json index 020df8f0..f0f32d48 100644 --- a/package.json +++ b/package.json @@ -78,16 +78,16 @@ "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/sveltekit": "^1.1.0", - "@welshman/app": "^0.9.5", - "@welshman/content": "^0.9.5", - "@welshman/domain": "^0.9.5", - "@welshman/editor": "^0.9.5", - "@welshman/feeds": "^0.9.5", - "@welshman/lib": "^0.9.5", - "@welshman/net": "^0.9.5", - "@welshman/signer": "^0.9.5", - "@welshman/store": "^0.9.5", - "@welshman/util": "^0.9.5", + "@welshman/app": "^0.9.6", + "@welshman/content": "^0.9.6", + "@welshman/domain": "^0.9.6", + "@welshman/editor": "^0.9.6", + "@welshman/feeds": "^0.9.6", + "@welshman/lib": "^0.9.6", + "@welshman/net": "^0.9.6", + "@welshman/signer": "^0.9.6", + "@welshman/store": "^0.9.6", + "@welshman/util": "^0.9.6", "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "dompurify": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba86ce8f..5cf38531 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index dc62c52f..c00e931d 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -89,83 +89,85 @@ } const onSubmit = async (params: EventContent) => { - try { - const ptags = others.map(pk => ["p", pk]) + const ptags = others.map(pk => ["p", pk]) - // Remove p tags since they result in forking the conversation - params.tags = params.tags.filter(nthNe(0, "p")) + // Remove p tags since they result in forking the conversation + params.tags = params.tags.filter(nthNe(0, "p")) - // Add our reply quote to content - params = await prependParent(parent, params) + // Add our reply quote to content + params = await prependParent(parent, params) - if (eventToEdit) { - if (eventToEdit.content === params.content) { - return - } - - const command = await $deletes.deleteEvent(eventToEdit) - - await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16}) + if (eventToEdit) { + // An edit that changes nothing is just a dismissal + if (eventToEdit.content === params.content) { + return clearEventToEdit() } - const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags) - const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" "))) - const templates: EventTemplate[] = [] - const buffer = [] + const command = await $deletes.deleteEvent(eventToEdit) - const addTemplate = (kind: number, content: string, tags: string[][]) => { - content = content.trim() - - if (content) { - templates.push( - makeEvent(kind, { - content, - tags: [...tags, ...ptags], - created_at: eventToEdit?.created_at, - }), - ) - } - } - - for (const p of parse(params)) { - const imeta = isLink(p) - ? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()]))) - : undefined - - if (isLink(p) && imeta) { - addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) - addTemplate(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url"))) - } else { - buffer.push(p.raw) - } - } - - addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) - - // Split the message into multiple pieces so that we can use kind 15 to send images per nip 17 - // Sleep 1 second between each one to make sure timestamps are distinct - const thunks = await Promise.all( - Array.from(enumerate(templates)).map(([i, event]) => - $wraps.publish({ - event, - recipients: pubkeys, - delay: $userSettingsValues.send_delay + ms(i), - pow: 16, - }), - ), - ) - - pushToast({ - timeout: 30_000, - children: { - component: ThunkToast, - props: {thunk: $app.use(Thunks).merge(thunks)}, - }, - }) - } finally { - clearParent() - clearEventToEdit() + await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16}) } + + const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags) + const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" "))) + const templates: EventTemplate[] = [] + const buffer = [] + + const addTemplate = (kind: number, content: string, tags: string[][]) => { + content = content.trim() + + if (content) { + templates.push( + makeEvent(kind, { + content, + tags: [...tags, ...ptags], + created_at: eventToEdit?.created_at, + }), + ) + } + } + + for (const p of parse(params)) { + const imeta = isLink(p) + ? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()]))) + : undefined + + if (isLink(p) && imeta) { + addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) + addTemplate(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url"))) + } else { + buffer.push(p.raw) + } + } + + addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) + + // Split the message into multiple pieces so that we can use kind 15 to send images per nip 17 + // Sleep 1 second between each one to make sure timestamps are distinct + const thunks = await Promise.all( + Array.from(enumerate(templates)).map(([i, event]) => + $wraps.publish({ + event, + recipients: pubkeys, + delay: $userSettingsValues.send_delay + ms(i), + pow: 16, + }), + ), + ) + + // Only once the message exists. Publishing has to read each recipient's messaging relays first, + // and a failed read throws before any thunk is made — so the reply or edit this was part of has + // to survive that along with the draft the composer is holding on to. + clearParent() + clearEventToEdit() + + pushToast({ + timeout: 30_000, + children: { + component: ThunkToast, + props: {thunk: $app.use(Thunks).merge(thunks)}, + }, + }) } const onEscape = () => { diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index 8412e31c..be576a28 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -2,6 +2,7 @@ import {onDestroy, onMount} from "svelte" import {writable} from "svelte/store" import cx from "classnames" + import type {MaybeAsync} from "@welshman/lib" import type {EventContent} from "@welshman/util" import {isMobile, preventDefault} from "@lib/html" import GallerySend from "@assets/icons/gallery-send.svg?dataurl" @@ -12,6 +13,7 @@ import EditorContent from "@app/editor/EditorContent.svelte" import {makeEditor} from "@app/editor" import {type DraftKey, type Draft} from "@app/drafts" + import {pushToast} from "@app/toast" import type {Share} from "@app/share" type Props = { @@ -19,7 +21,7 @@ draftKey?: DraftKey onEscape?: () => void onEditPrevious?: () => void - onSubmit: (event: EventContent) => void + onSubmit: (event: EventContent) => MaybeAsync initialValues?: Share } @@ -68,7 +70,13 @@ if (!content) return - onSubmit({content, tags}) + try { + await onSubmit({content, tags}) + } catch (error) { + console.error("Failed to send message", error) + + return pushToast({theme: "error", message: "Failed to send your message."}) + } draftKey?.clear() ed.chain().clearContent().run() diff --git a/src/app/sync.ts b/src/app/sync.ts index 72370ea9..76da4133 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -1,6 +1,6 @@ import {page} from "$app/stores" import type {Unsubscriber} from "svelte/store" -import {ago, assoc, call, MONTH, WEEK} from "@welshman/lib" +import {ago, assoc, call, noop, MONTH, WEEK} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import { APP_DATA, @@ -29,12 +29,7 @@ import { unionFilters, } from "@welshman/util" import type {Filter} from "@welshman/util" -import type { - FollowListReader, - MessagingRelayListReader, - RelayListReader, - RoomListReader, -} from "@welshman/domain" +import type {FollowListReader, RelayListReader, RoomListReader} from "@welshman/domain" import {merged, synced, withGetter} from "@welshman/store" import { FollowLists, @@ -375,32 +370,29 @@ const syncDMs = () => { } } - const syncPubkey = ($pubkey: Maybe, $shouldUnwrap: boolean) => { + const syncPubkey = async ($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 + + if ($pubkey && $shouldUnwrap) { + await relayLists.get().load($pubkey).catch(noop) + await messagingRelayLists.get().load($pubkey).catch(noop) + + if (currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) { + subscribeAll($pubkey, messagingRelayLists.get().urls($pubkey).get()) + } + } } - const syncList = ($list: Maybe) => { + const syncList = () => { const $pubkey = app.get().user?.pubkey if ($pubkey && shouldUnwrap.get()) { - subscribeAll($pubkey, $list?.urls() ?? []) + subscribeAll($pubkey, messagingRelayLists.get().urls($pubkey).get()) } }