Handle failed loaders, normalize relay urls to fix dms bug
This commit is contained in:
parent
88d8bee015
commit
44f1a186f5
7 changed files with 170 additions and 104 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
20
package.json
20
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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue