Handle failed loaders, normalize relay urls to fix dms bug

This commit is contained in:
Jon Staab 2026-08-31 20:17:52 -07:00
parent 88d8bee015
commit 44f1a186f5
7 changed files with 170 additions and 104 deletions

View file

@ -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.

View file

@ -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()
})

View file

@ -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",

Binary file not shown.

View file

@ -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 = () => {

View file

@ -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<Draft>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
onSubmit: (event: EventContent) => MaybeAsync<void>
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()

View file

@ -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<string>, $shouldUnwrap: boolean) => {
const syncPubkey = async ($pubkey: Maybe<string>, $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<MessagingRelayListReader>) => {
const syncList = () => {
const $pubkey = app.get().user?.pubkey
if ($pubkey && shouldUnwrap.get()) {
subscribeAll($pubkey, $list?.urls() ?? [])
subscribeAll($pubkey, messagingRelayLists.get().urls($pubkey).get())
}
}