Open a quote in the app whether or not its event has loaded (#692)

This commit is contained in:
Coracle-Bot 2026-09-30 17:20:55 +00:00 committed by hodlbod
parent 334e4f8353
commit 7234e4a375
11 changed files with 289 additions and 118 deletions

View file

@ -1084,6 +1084,19 @@ Acceptance:
- Clicking that card opens the quoted post.
- A quote whose event has not arrived shows a loading placeholder in its place.
### US-140 — Open a quote before it has loaded
As alice, I want a quote to open in the app whether or not its event has
arrived, so that tapping one never hands me to another client.
Acceptance:
- A quote surrounded by prose renders as a truncated label, and clicking one
whose event has not arrived opens a modal saying it is loading, with no new
tab opened.
- A quote whose event has to be fetched opens that event once it arrives.
- Clicking the label of a quoted thread opens that thread's page.
### US-066 — See distinctive inline tokens
As alice, I want hashtags, mentions, emoji, code, and addresses to look
@ -1938,9 +1951,9 @@ hosted checkout and is confirmed by a backend webhook. The Lightning path for
the same invoice is covered by US-102.
**External link-outs.** Self-hosted and third-party options on the
space-creation page, the About page's source, blog, podcast and support links,
and a note's timestamp permalink to another nostr client. The harness blocks
navigation off the app's origin, so nothing about the destination is observable.
space-creation page, and the About page's source, blog, podcast and support
links. The harness blocks navigation off the app's origin, so nothing about the
destination is observable.
**Diagnostic log sending.** The privacy page's button that bundles client-side
logs into a DM to the platform's support contact. It targets a hardcoded pubkey

View file

@ -1,11 +1,9 @@
import {npubEncode} from "nostr-tools/nip19"
import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {MESSAGE, makeEvent} from "@welshman/util"
import {MESSAGE, NOTE, makeEvent} from "@welshman/util"
import {Article, Thread} from "@welshman/domain"
import {expect, roomPath, spacePath, test, users} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string}
import {dialog, expect, message, roomPath, spacePath, test, users} from "../harness"
import type {SeededEvent} from "../harness"
// @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows.
const INVOICE =
@ -14,7 +12,7 @@ const INVOICE =
"agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9"
test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
let flagged!: Seeded
let flagged!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -100,8 +98,8 @@ test("US-061 expand a long post", async ({seed, as}) => {
})
test("US-062 see images and video inline", async ({seed, as}) => {
let picture!: Seeded
let clip!: Seeded
let picture!: SeededEvent
let clip!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -137,9 +135,9 @@ test("US-063 preview a shared link", async ({seed, as}) => {
const announcement = "https://example.test/announcement"
const unreadable = "https://example.test/unreadable"
let standalone!: Seeded
let broken!: Seeded
let sentence!: Seeded
let standalone!: SeededEvent
let broken!: SeededEvent
let sentence!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -199,7 +197,7 @@ test("US-063 preview a shared link", async ({seed, as}) => {
})
test("US-064 follow a link to another space", async ({seed, as}) => {
let reference!: Seeded
let reference!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -235,10 +233,10 @@ test("US-064 follow a link to another space", async ({seed, as}) => {
})
test("US-065 see quoted and embedded content", async ({seed, as}) => {
let original!: Seeded
let reply!: Seeded
let thread!: Seeded
let threadQuote!: Seeded
let original!: SeededEvent
let reply!: SeededEvent
let thread!: SeededEvent
let threadQuote!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -296,6 +294,98 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => {
await page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`)
})
test("US-140 open a quote before it has loaded", async ({seed, as}) => {
const late = "the tide tables are posted by the ramp"
let elsewhere!: SeededEvent
let fetched!: SeededEvent
let thread!: SeededEvent
const scenario = await seed(({relay, open, user, at}) => {
const space = relay("space")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// Bob's note lives on a relay that answers nothing, so the quote naming it cannot resolve.
elsewhere = outbox.note(user.bob, "the ferry runs until eight", at(2, HOUR))
// No space view loads a kind 1, so a quote naming this one resolves only once it is asked for.
fetched = space.event(user.bob, () => makeEvent(NOTE, {content: late}), at(2, HOUR))
thread = space.event(
user.bob,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "general")
.setTitle("Roadmap for Q3")
.setContent("Here is what we are planning for the next quarter.")
.renderTemplate(),
at(3, HOUR),
)
// A quote surrounded by prose renders as a truncated label rather than as a card.
space.event(
user.bob,
() =>
makeEvent(MESSAGE, {
content: `worth reading nostr:${neventEncode({...elsewhere.event, relays: [outbox.url]})} when you have a minute`,
tags: [["h", "general"]],
}),
at(20, MINUTE),
)
space.event(
user.bob,
() =>
makeEvent(MESSAGE, {
content: `have a look at nostr:${neventEncode({...thread.event, relays: [space.url]})} before Friday`,
tags: [["h", "general"]],
}),
at(15, MINUTE),
)
space.event(
user.bob,
() =>
makeEvent(MESSAGE, {
content: `one more nostr:${neventEncode({...fetched.event, relays: [space.url]})} if you are walking`,
tags: [["h", "general"]],
}),
at(10, MINUTE),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"), {
silent: [scenario.open("outbox").url],
})
const label = /^nevent1/
const quote = dialog(page, "Quote")
await message(page, "worth reading").getByRole("button", {name: label}).click()
await expect(quote).toContainText("Loading event...")
expect(page.context().pages()).toHaveLength(1)
await page.keyboard.press("Escape")
await expect(quote).toHaveCount(0)
await message(page, "one more").getByRole("button", {name: label}).click()
await expect(dialog(page, "Note")).toContainText(late)
await page.keyboard.press("Escape")
await expect(dialog(page, "Note")).toHaveCount(0)
await message(page, "have a look").getByRole("button", {name: label}).click()
await page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`)
})
test("US-066 see distinctive inline tokens", async ({seed, as}) => {
const mention = `nostr:${npubEncode(users.bob.pubkey)}`
const content = [
@ -306,7 +396,7 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => {
"```",
].join("\n")
let tokens!: Seeded
let tokens!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -350,7 +440,7 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => {
})
test("US-067 copy a shared invoice or token", async ({seed, as}) => {
let invoice!: Seeded
let invoice!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")

View file

@ -15,12 +15,9 @@
isLink,
isProfile,
isRoom,
isEvent,
isEllipsis,
isAddress,
isNewline,
} from "@welshman/content"
import type {Parsed} from "@welshman/content"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
@ -39,6 +36,7 @@
import ContentMention from "@app/components/ContentMention.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {makeRoomPath} from "@app/routes"
import {isQuote} from "@app/quotes"
import {deriveValidCommands, getCommandsForInvocation} from "@app/commands"
import {userSettingsValues} from "@app/settings"
@ -113,8 +111,6 @@
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
let warningDismissed = $state(false)
const ignoreWarning = () => {

View file

@ -14,12 +14,8 @@
isInvoice,
isLink,
isProfile,
isEvent,
isAddress,
isNewline,
} from "@welshman/content"
import type {Parsed} from "@welshman/content"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@ -31,7 +27,7 @@
import ContentNewline from "@app/components/ContentNewline.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import {entityLink} from "@app/env"
import {goToQuote, isQuote} from "@app/quotes"
import {userSettingsValues} from "@app/settings"
type Props = {
@ -64,8 +60,6 @@
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
let warningDismissed = $state(false)
const ignoreWarning = () => {
@ -144,12 +138,11 @@
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isQuote(parsed)}
<Link
external
class="overflow-hidden text-ellipsis whitespace-nowrap underline"
href={entityLink(parsed.raw)}>
<Button
class="max-w-full cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap underline"
onclick={() => goToQuote(parsed.value, event, url)}>
{fromNostrURI(parsed.raw).slice(0, 16) + "…"}
</Link>
</Button>
{:else}
{@html renderAsHtml(parsed)}
{/if}

View file

@ -1,28 +1,18 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {derived, writable} from "svelte/store"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
Address,
MESSAGE,
eventOutbox,
fromNostrURI,
getIdFilters,
relays as relaySelections,
seen,
} from "@welshman/util"
import {MESSAGE, fromNostrURI, getIdFilters} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {events, network, router} from "@app/core"
import {entityLink} from "@app/env"
import {goToEvent} from "@app/routes"
import {events, network} from "@app/core"
import {getQuoteIdOrAddress, getQuoteRelays, goToQuote} from "@app/quotes"
import type {QuotePointer} from "@app/quotes"
type Props = {
value: any
value: QuotePointer
raw: string
event: TrustedEvent
inline?: boolean
@ -31,51 +21,22 @@
const {value, raw, event, inline = false, url}: Props = $props()
const {id, identifier, kind, pubkey = value.author, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const ref = {id, pubkey, kind, identifier, relays}
const hints = removeUndefined([...relays, url])
const mergedRelays = writable(hints)
const idOrAddress = getQuoteIdOrAddress(value)
const hints = removeUndefined([...(value.relays ?? []), url])
const quote = $events.one(idOrAddress, hints).$
// Whoever quoted the event must have seen it, so the quoting event's relays are asked too.
$router.resolver
.relays([
...relaySelections(removeUndefined([url])),
seen(ref),
eventOutbox(ref),
seen(event, 0.5),
])
.then(urls => {
mergedRelays.set(urls)
if (!$quote) {
$network.loadLenient({filters: getIdFilters([idOrAddress]), relays: urls})
}
})
const entity = derived(mergedRelays, $mergedRelays =>
id
? nip19.neventEncode({id, relays: $mergedRelays})
: new Address(kind, pubkey, identifier, $mergedRelays).toNaddr(),
)
const onclick = () => {
if ($quote) {
goToEvent($quote)
} else {
window.open(entityLink($entity))
getQuoteRelays(value, event, url).then(relays => {
if (!$quote) {
$network.loadLenient({filters: getIdFilters([idOrAddress]), relays})
}
}
})
</script>
<Button
class={inline
? "max-w-full cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap underline"
: "my-2 block w-full max-w-full text-left"}
{onclick}>
onclick={() => goToQuote(value, event, url)}>
{#if inline}
{fromNostrURI(raw).slice(0, 16) + "…"}
{:else if $quote}

View file

@ -2,6 +2,7 @@
import {onDestroy} from "svelte"
import {readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import type {EventPointer} from "nostr-tools/nip19"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@ -12,16 +13,16 @@
import CommentTree from "@app/components/CommentTree.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import {makeFeedContext} from "@app/feeds"
import {notes} from "@app/social"
import type {CommentNode, NotePointer} from "@app/social"
import {getQuoteRef, notes} from "@app/social"
import type {CommentNode} from "@app/social"
type Props = {
pointer: NotePointer
pointer: EventPointer
}
const {pointer}: Props = $props()
const relays = $notes.relays(pointer)
const relays = $notes.relays(getQuoteRef(pointer))
const context = makeFeedContext({relays})
const event = $notes.deriveEvent(pointer)
const replies = $derived($event ? $notes.deriveReplies($event) : readable<CommentNode[]>([]))

View file

@ -0,0 +1,46 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {popModal} from "@app/modal"
import {goToEvent} from "@app/routes"
import {loadQuote} from "@app/quotes"
import type {QuotePointer} from "@app/quotes"
import {pushToast} from "@app/toast"
type Props = {
pointer: QuotePointer
event: TrustedEvent
url?: string
}
const {pointer, event, url}: Props = $props()
let waiting = true
onDestroy(() => {
waiting = false
})
loadQuote(pointer, event, url).then(quoted => {
if (waiting) {
popModal()
if (quoted) {
goToEvent(quoted, {replaceState: true})
} else {
pushToast({theme: "error", message: "Failed to load that event."})
}
}
})
</script>
<Modal label="Quote">
<ModalBody>
<div class="flex justify-center py-20">
<Spinner loading>Loading event...</Spinner>
</div>
</ModalBody>
</Modal>

63
src/app/quotes.ts Normal file
View file

@ -0,0 +1,63 @@
import {first, removeUndefined} from "@welshman/lib"
import {
Address,
eventOutbox,
getIdFilters,
relays as relaySelections,
seen,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {isAddress, isEvent} from "@welshman/content"
import type {Parsed, ParsedAddress, ParsedEvent} from "@welshman/content"
import type {AddressPointer, EventPointer} from "nostr-tools/nip19"
import {events, network, router} from "@app/core"
import {pushModal} from "@app/modal"
import {goToEvent} from "@app/routes"
import {getQuoteRef} from "@app/social"
import QuoteLoading from "@app/components/QuoteLoading.svelte"
export type QuotePointer = EventPointer | AddressPointer
export const isQuote = (parsed: Parsed): parsed is ParsedEvent | ParsedAddress =>
isEvent(parsed) || isAddress(parsed)
export const getQuoteIdOrAddress = (pointer: QuotePointer) =>
"id" in pointer
? pointer.id
: new Address(pointer.kind, pointer.pubkey, pointer.identifier).toString()
// Whoever quoted an event saw it, so the relays of the event doing the quoting are asked as well.
export const getQuoteRelays = (pointer: QuotePointer, event: TrustedEvent, url?: string) => {
const ref = getQuoteRef(pointer)
return router
.get()
.resolver.relays([
...relaySelections(removeUndefined([url])),
seen(ref),
eventOutbox(ref),
seen(event, 0.5),
])
}
export const loadQuote = async (pointer: QuotePointer, event: TrustedEvent, url?: string) => {
const relays = await getQuoteRelays(pointer, event, url)
const filters = getIdFilters([getQuoteIdOrAddress(pointer)])
// An id names one event, so the relays that answer first have it; an address names the newest.
const load = "id" in pointer ? network.get().loadLenient : network.get().loadComplete
return first(sortEventsDesc(await load({filters, relays})))
}
// A quote names an event that may not have arrived, so a modal loads it before going anywhere.
export const goToQuote = (pointer: QuotePointer, event: TrustedEvent, url?: string) => {
const loaded = events.get().one(getQuoteIdOrAddress(pointer)).get()
if (loaded) {
goToEvent(loaded)
} else {
pushModal(QuoteLoading, {pointer, event, url})
}
}

View file

@ -1,5 +1,6 @@
import {get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import type {EventPointer} from "nostr-tools/nip19"
import {page} from "$app/stores"
import {identity, tryCatch} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
@ -21,12 +22,11 @@ import {
} from "@welshman/util"
import {app, messagingRelayLists, user} from "@app/core"
import {makeChatId} from "@app/chats"
import {entityLink, PLATFORM_URL, PLATFORM_RELAYS} from "@app/env"
import {PLATFORM_URL, PLATFORM_RELAYS} from "@app/env"
import {decodeRelay, encodeRelay} from "@app/relays"
import {DM_KINDS, getActivityTarget} from "@app/content"
import {navigate, pushModal} from "@app/modal"
import type {NavigateOptions} from "@app/modal"
import type {NotePointer} from "@app/social"
import type {ModalOptions, NavigateOptions} from "@app/modal"
import ChatEnable from "@app/components/ChatEnable.svelte"
import NoteDetail from "@app/components/NoteDetail.svelte"
@ -239,7 +239,7 @@ export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
if (path) {
return `${PLATFORM_URL}${path}${path.includes("?") ? "&" : "?"}event=${pointer}`
} else {
return entityLink(pointer)
return `${PLATFORM_URL}/${pointer}`
}
}
@ -262,10 +262,11 @@ export const scrollToEvent = (id: string) => {
}
// A note opens over whatever is on screen rather than on a page of its own.
export const pushNote = (pointer: NotePointer) => pushModal(NoteDetail, {pointer}, {size: "large"})
export const pushNote = (pointer: EventPointer, options: ModalOptions = {}) =>
pushModal(NoteDetail, {pointer}, {size: "large", ...options})
// A url resolving to a note has nothing on screen to open it over, so the app goes behind it.
export const goToNote = (pointer: NotePointer) => {
export const goToNote = (pointer: EventPointer) => {
pushNote(pointer)
return goToHome()
@ -273,12 +274,8 @@ export const goToNote = (pointer: NotePointer) => {
export const goToEvent = (event: TrustedEvent, options: NavigateOptions = {}) => {
const urls = Array.from(app.get().tracker.getRelays(event.id))
if (event.kind === NOTE) {
return pushNote({id: event.id, pubkey: event.pubkey, relays: urls})
}
const path = makeEventPath(event, urls)
// A note has a page of its own and still opens in a modal, over whatever is on screen.
const path = event.kind === NOTE ? undefined : makeEventPath(event, urls)
if (path) {
if (!scrollToEvent(event.id)) {
@ -287,6 +284,10 @@ export const goToEvent = (event: TrustedEvent, options: NavigateOptions = {}) =>
navigate(path, {replaceState, ...options})
}
} else {
window.open(entityLink(nip19.neventEncode({id: event.id, relays: urls})))
// A kind with no page of its own opens there too, rather than in another client.
pushNote(
{id: event.id, author: event.pubkey, relays: urls},
{replaceState: options.replaceState},
)
}
}

View file

@ -28,6 +28,7 @@ import {
topicTags,
} from "@welshman/util"
import type {EventRef, TrustedEvent} from "@welshman/util"
import type {EventPointer} from "nostr-tools/nip19"
import {withGetter} from "@welshman/store"
import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain"
import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app"
@ -35,6 +36,7 @@ import type {IApp} from "@welshman/app"
import {decodeGeohash} from "@lib/geohash"
import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core"
import {getProfileGeohash} from "@app/location"
import type {QuotePointer} from "@app/quotes"
import {DEFAULT_PUBKEYS} from "@app/env"
// People
@ -99,15 +101,13 @@ export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollow
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
})
// Pointers: how a note is named before it has been loaded.
// Pointers: how an event is named before it has been loaded.
export type NotePointer = EventRef & {id: string}
export const decodeNotePointer = (entity: string): Maybe<NotePointer> => {
export const decodeNotePointer = (entity: string): Maybe<EventPointer> => {
const decoded = tryCatch(() => nip19.decode(fromNostrURI(entity)))
if (decoded?.type === "nevent") {
return {id: decoded.data.id, relays: decoded.data.relays, pubkey: decoded.data.author}
return decoded.data
}
if (decoded?.type === "note") {
@ -115,6 +115,12 @@ export const decodeNotePointer = (entity: string): Maybe<NotePointer> => {
}
}
// A relay selection names an event author `pubkey`, where an event pointer names it `author`.
export const getQuoteRef = (pointer: QuotePointer): EventRef =>
"id" in pointer
? {id: pointer.id, pubkey: pointer.author, kind: pointer.kind, relays: pointer.relays}
: pointer
// Structure: the shape of a conversation, read off tags alone.
// Ids and addresses of an event's immediate parents, falling back to its thread roots.
@ -265,16 +271,17 @@ export class Notes {
.use(Router)
.resolver.relays([...relaySelections(ref.relays ?? []), seen(ref), eventOutbox(ref)])
load = async (pointer: NotePointer) =>
load = async (pointer: EventPointer) =>
this.app.repository.getEvent(pointer.id) ??
first(
await this.app
.use(Network)
.loadComplete({relays: await this.relays(pointer), filters: getIdFilters([pointer.id])}),
await this.app.use(Network).loadComplete({
relays: await this.relays(getQuoteRef(pointer)),
filters: getIdFilters([pointer.id]),
}),
)
// A pointer carrying no hints would never resolve, so the load beside it asks the author's relays.
deriveEvent = (pointer: NotePointer) => {
deriveEvent = (pointer: EventPointer) => {
this.load(pointer)
return this.app.use(Events).one(pointer.id, pointer.relays ?? []).$

View file

@ -25,7 +25,7 @@
found = true
if (event.kind === NOTE) {
goToNote({id: event.id, pubkey: event.pubkey, relays})
goToNote({id: event.id, author: event.pubkey, relays})
} else {
goToEvent(event, {replaceState: true})
}