From 44f1a186f5677f72294ef4cdafbe2e5ee8de3480 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 31 Aug 2026 20:17:52 -0700 Subject: [PATCH] Handle failed loaders, normalize relay urls to fix dms bug --- e2e/USER_STORIES.md | 23 +++++ e2e/specs/dms.spec.ts | 41 ++++++++ package.json | 20 ++-- pnpm-lock.yaml | Bin 398156 -> 398156 bytes src/app/components/Chat.svelte | 142 +++++++++++++------------- src/app/components/ChatCompose.svelte | 12 ++- src/app/sync.ts | 36 +++---- 7 files changed, 170 insertions(+), 104 deletions(-) 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 ba86ce8f4a47f46530ba0ca9261bbcf51c2ad955..5cf385311c8f898e276f187b32a6f307e8aac81e 100644 GIT binary patch delta 3547 zcmdT`U5p!771lI&$?m2lp_?>K(%m+Rlx*t~&!5L00c!m__ShcJcy|2v0yCZ;|BYwu zu|2j`8byHeMieY{kPs3)kT*)8LMsq`=nLvY`p}1}6-}FNd1-l|ia@3OhU>L=v)QyV z5h)Uue6M{qcg{KAIp_P%wJVQayYlGO{qqPlY8^fd*9%9N;EK&3f$NWttin}ZcxLpo z`2z^${`>~`8a(*y2jkn9T;y#~-u;Jr>BYqx`{BuVAAV$&TGa`ZMoCiE30%i8tQJXGJ^?@ORU0`xdhg*AlY7vEo9}^u`^UGz{776nHaR^-tp;SjkI-~g zF6)@A%Bbcm`)N$^`^%W0Qp&{WCrjkc!#>C>GEK$7Z=9MclICMxAU5rA@3v_I{&1bejhNSZ=AU8@T71qA3OK04^C`+ zZ~|G$!QfuO&*YM&w!E&f-Ec=X^MoDL%zjZ~5;>N&8sYUU8dZAD-k{xJ8iSlhQ)^K? z%sXPfp{M$TOkC-gf^jZW^414Ey_zN25T8vDIb#&Ocxu$&|B(BOHgX1nuyP%&!0q*S z!DDcPaE`ajH^AeQZOMIV6O8M*Wh2XQ3qFsWb6>WR2PaNSc7!N3;(j*i3wC;>YR4JD zhzAIo^QcwR*AH;=5bfcOG(})dMMy-O16r_z0AGzKSPEwiT+7GVxEidKDg%reWHpWz z5~B+*oOEC9BB#gY`vvz>2br6i8&YYT%u4lEjL1+*q7o)O=|qRcYQ9!RBE_IaOFSnq zdAX}3>yA`w$Oc==_Z%(nVY@QdFf!Rt-BT&~R5L$_Tj6%E*%hI=)9z~nWWhzR0~pw+ zgE0l*l`i6&I@9$Gy*@R9GE$!P_-WQu>sX^R70tYaJ{?D{U)X43S0mWCS5MuJn-O58OumMjy#cjSAARt}=YK zW~_x&v!^heL-1(1qky$+C4Ok`I2yxjIR!gZ`1P|vrR?y}w zJ}E?Nl~ll=Vnfn40+7!3l)yOZW7Hy~n6uA1TEniGa?{(WD$hd=kUHZot-M3*WHmI$?kjwxa$E+(t#wx8_UY@%)!$#zHUgt#?PskGLc!>VECL}z#8+Zp}@ z9G{rXQBlG6Qg+PL^BB=icq#!&aSDbcscMVT>C^zLM0Gmk8LC3O7tS}ZMlaU1V?v&a z+60sC_|+&kjMhW#hzCtIlSZ~qwDMam=zjfg;GlcvJaW)|Zv5-b1!ToNWFz2q2DaVl zB2OK#J62npVh*`MO6az?i#i25*KCQZWuf^(KSlQ}mQeXv#fjIdj2+Ek!@L<=i=a}z z$vF()3wfDR)zieIg&b97g-pSy7mH|gNQ+8SNwr48_qO_AR~1ZsfL-sOW`XUrEZQlU zPMEQ+64~VA10#v)SX0h9I?gN2qC&|YMKah*fHLDk*(dU0u9X#ik$N)N?bL;^Cn|Q= z4J*(-z2mMdg z_AMP56XU&!`$ReT55ah5*rEH4Uws?AB|=4>rSC5C#=ey;%6|7PkpJ9q*p|74G5J39 zA50A6Apn9hQwQ&Z^xw(M_diEa-N&=stRdzuMNjQctb6l)@ccMU^Jm62wd}t1d!U}2 zIeEEA8$7;ShGx3)e*oy-qIZAuA$SJX)Xc$h^QS;^4>`!<#&drF$H%NVp`u;=^WaCo z12yu00@JlW0XTC`wekMf-CZ#K;A3za+PnGdear5$Gk4HkCjoFj`WP@f0q-4gU;fLM zi+f>m%b$S$y;Ihtav>$BXNArG9l*ixc3#FQChriedu}JM%l>mV-gg;(^LKNE``dr* nqFH(2>`oxdu67H28AkKfOLHsk_kG~$i|^b58}HnjvzGo1QLZsw delta 3530 zcmdT`TZ|i58P+7TWOtJ&p__D@gxxlclx*t~&)9Rb1ZsR8UuM>~owYqLbjCAddwlnJ z>=`RGiU8$}Qbyt+AtZPpkD*YZRisvG`%v)^9^j=FO`E3h(DFbPfl9fCGj`V5Zd#d$ z6bVZ{$3B`f=l{O%KmYe%yZp$t%a6RUZyq6??vW#KytupsM|}P$9DjUt4UW>n)6P%l z_ah|u*$wooaN)BbjBjrO$XjT6=b!Z#uPol!2UouR(8Fuw8YPpY;x8+*Mv;U@%Ce04 zNtF!vC0~HVeE1WKkHHUk!9b2U?>=;5vJXaZ>s^!qe|!s_ck)ZeCaaH=Yq(0uWtzem z%;!^7Tp&}(M1@PES^sw`-eam~NubByXDl`oK3BbRHvd%Ln?gl(boac|7{l@zzw%$L1 ztZq85T|Fak;T-Q(yy-}c`~=I>Pkr*TEx(1 z>zM#nkQ1?7q0?ZvSfdsS`=k0W!YN7Jj~ASa-#g`4`woJi_mI<&fz|8iDx7}(4*DfH zK`!0t`5Wk?lW7S&c@uS$xoaRRa0)()oCPl%$boT$QK`}{g@Z%Z;>e6oX^vt>(WoVo z4AILf5p9#z5}UdgGsE&`5u=QO*}%+{8CNB~BUD<+s=#Fmv>-}$*Qa&z$(D)f<&bmk z(n;`36FKE(?iavwePnKYm|)vtevtBH6B(`%DVcms5_?jas`a>0H<9%Oi}k^P$IXgA z!zR6hsz&oU+$*vL+%j7hp2oV;X4tPLlZFz)_zY$=`KS)M=ix!WvXKRVT}KZbtdGXgjqMTo7-1LXLgyM*7cko5S-K_!GKp(aj> zSXFL@l}}#Zt)uWQ zdZX>NvV5Mc5EVLBi^P0P+>?sw$$pDa1uRVI1Qm!UVjH5ObjcvE6;-@$T0soUrYjaH z^wR;A$rHJtkOBCyJuA+4{&5ieb%cCz4BW&EoWjOc-S*LyT0y9I2k8`@CrVg7Tf}S0 zMx@y7P#m#Q3U`}IRo7FbU92!vMd`sbP(y8lNze^iD;Xx6igTVQ)+>3O?03hqySTT- z{TLPqSKO=^ET%S?R;OC#b&2H@n^j536*4Kd#_L!w#YfYs7|h9qpeDzqSRYSNR=O_o z0ee_v>q@RjMB6!{r>aqnNp*58hj*I+nANAi*Zzhc0;ew^hroIF*BcASDmZK)=w0%+ zV=<8@7mR+pr%vh4#eG8D(~5gAZ%HD!_%Ic+b1gP6V;%3HV)xsux1S|Erk5WI_GZ4L zX<3O$RPv!TU(juXr)V+k9YnGW+ff_ouE69XRl=KiqVk8Z>T zb-h|(nmR5DqpFe_4F_y9l?X|MVbq3QGtAW*!AL(}wqyO&C`V=KhTbPxnc(G;CvOaJ zyPp`XlNvV^Ej%D%KWD~ z`PenwN_c-(KJkgn2dLX2OpE*0)o-G2=1e`=B*ks;u#Rs2z_fYkNkI+-#H_xD}U}ZRW2y_S(1s{HdvQxObJKC53G7fMz zNuc~OYTbX$n&d95$^0zPnZKj36}+p8D>AWv>Q=D1zV8d=1<8NTX8WFl-}vpEAH4pr o9d%Y(I5P#Z0@T~+mm!+3JU6!rzE7f0U3&XAy7l($x%Se(0a-ONv;Y7A 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()) } }