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. - Clicking that card opens the quoted post.
- A quote whose event has not arrived shows a loading placeholder in its place. - 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 ### US-066 — See distinctive inline tokens
As alice, I want hashtags, mentions, emoji, code, and addresses to look 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. the same invoice is covered by US-102.
**External link-outs.** Self-hosted and third-party options on the **External link-outs.** Self-hosted and third-party options on the
space-creation page, the About page's source, blog, podcast and support links, space-creation page, and the About page's source, blog, podcast and support
and a note's timestamp permalink to another nostr client. The harness blocks links. The harness blocks navigation off the app's origin, so nothing about the
navigation off the app's origin, so nothing about the destination is observable. destination is observable.
**Diagnostic log sending.** The privacy page's button that bundles client-side **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 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 {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 {Article, Thread} from "@welshman/domain"
import {expect, roomPath, spacePath, test, users} from "../harness" import {dialog, expect, message, roomPath, spacePath, test, users} from "../harness"
import type {SeededEvent} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string}
// @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows. // @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows.
const INVOICE = const INVOICE =
@ -14,7 +12,7 @@ const INVOICE =
"agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9" "agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9"
test("US-060 reveal a flagged sensitive message", async ({seed, as}) => { 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 scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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}) => { test("US-062 see images and video inline", async ({seed, as}) => {
let picture!: Seeded let picture!: SeededEvent
let clip!: Seeded let clip!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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 announcement = "https://example.test/announcement"
const unreadable = "https://example.test/unreadable" const unreadable = "https://example.test/unreadable"
let standalone!: Seeded let standalone!: SeededEvent
let broken!: Seeded let broken!: SeededEvent
let sentence!: Seeded let sentence!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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}) => { 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 scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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}) => { test("US-065 see quoted and embedded content", async ({seed, as}) => {
let original!: Seeded let original!: SeededEvent
let reply!: Seeded let reply!: SeededEvent
let thread!: Seeded let thread!: SeededEvent
let threadQuote!: Seeded let threadQuote!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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}`) 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}) => { test("US-066 see distinctive inline tokens", async ({seed, as}) => {
const mention = `nostr:${npubEncode(users.bob.pubkey)}` const mention = `nostr:${npubEncode(users.bob.pubkey)}`
const content = [ const content = [
@ -306,7 +396,7 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => {
"```", "```",
].join("\n") ].join("\n")
let tokens!: Seeded let tokens!: SeededEvent
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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}) => { 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 scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import type {EventPointer} from "nostr-tools/nip19"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -12,16 +13,16 @@
import CommentTree from "@app/components/CommentTree.svelte" import CommentTree from "@app/components/CommentTree.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import NoteItem from "@app/components/NoteItem.svelte"
import {makeFeedContext} from "@app/feeds" import {makeFeedContext} from "@app/feeds"
import {notes} from "@app/social" import {getQuoteRef, notes} from "@app/social"
import type {CommentNode, NotePointer} from "@app/social" import type {CommentNode} from "@app/social"
type Props = { type Props = {
pointer: NotePointer pointer: EventPointer
} }
const {pointer}: Props = $props() const {pointer}: Props = $props()
const relays = $notes.relays(pointer) const relays = $notes.relays(getQuoteRef(pointer))
const context = makeFeedContext({relays}) const context = makeFeedContext({relays})
const event = $notes.deriveEvent(pointer) const event = $notes.deriveEvent(pointer)
const replies = $derived($event ? $notes.deriveReplies($event) : readable<CommentNode[]>([])) 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 {get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import type {EventPointer} from "nostr-tools/nip19"
import {page} from "$app/stores" import {page} from "$app/stores"
import {identity, tryCatch} from "@welshman/lib" import {identity, tryCatch} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
@ -21,12 +22,11 @@ import {
} from "@welshman/util" } from "@welshman/util"
import {app, messagingRelayLists, user} from "@app/core" import {app, messagingRelayLists, user} from "@app/core"
import {makeChatId} from "@app/chats" 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 {decodeRelay, encodeRelay} from "@app/relays"
import {DM_KINDS, getActivityTarget} from "@app/content" import {DM_KINDS, getActivityTarget} from "@app/content"
import {navigate, pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import type {NavigateOptions} from "@app/modal" import type {ModalOptions, NavigateOptions} from "@app/modal"
import type {NotePointer} from "@app/social"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
import NoteDetail from "@app/components/NoteDetail.svelte" import NoteDetail from "@app/components/NoteDetail.svelte"
@ -239,7 +239,7 @@ export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
if (path) { if (path) {
return `${PLATFORM_URL}${path}${path.includes("?") ? "&" : "?"}event=${pointer}` return `${PLATFORM_URL}${path}${path.includes("?") ? "&" : "?"}event=${pointer}`
} else { } 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. // 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. // 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) pushNote(pointer)
return goToHome() return goToHome()
@ -273,12 +274,8 @@ export const goToNote = (pointer: NotePointer) => {
export const goToEvent = (event: TrustedEvent, options: NavigateOptions = {}) => { export const goToEvent = (event: TrustedEvent, options: NavigateOptions = {}) => {
const urls = Array.from(app.get().tracker.getRelays(event.id)) const urls = Array.from(app.get().tracker.getRelays(event.id))
// A note has a page of its own and still opens in a modal, over whatever is on screen.
if (event.kind === NOTE) { const path = event.kind === NOTE ? undefined : makeEventPath(event, urls)
return pushNote({id: event.id, pubkey: event.pubkey, relays: urls})
}
const path = makeEventPath(event, urls)
if (path) { if (path) {
if (!scrollToEvent(event.id)) { if (!scrollToEvent(event.id)) {
@ -287,6 +284,10 @@ export const goToEvent = (event: TrustedEvent, options: NavigateOptions = {}) =>
navigate(path, {replaceState, ...options}) navigate(path, {replaceState, ...options})
} }
} else { } 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, topicTags,
} from "@welshman/util" } from "@welshman/util"
import type {EventRef, TrustedEvent} from "@welshman/util" import type {EventRef, TrustedEvent} from "@welshman/util"
import type {EventPointer} from "nostr-tools/nip19"
import {withGetter} from "@welshman/store" import {withGetter} from "@welshman/store"
import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain" import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain"
import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app" 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 {decodeGeohash} from "@lib/geohash"
import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core" import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core"
import {getProfileGeohash} from "@app/location" import {getProfileGeohash} from "@app/location"
import type {QuotePointer} from "@app/quotes"
import {DEFAULT_PUBKEYS} from "@app/env" import {DEFAULT_PUBKEYS} from "@app/env"
// People // People
@ -99,15 +101,13 @@ export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollow
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] 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<EventPointer> => {
export const decodeNotePointer = (entity: string): Maybe<NotePointer> => {
const decoded = tryCatch(() => nip19.decode(fromNostrURI(entity))) const decoded = tryCatch(() => nip19.decode(fromNostrURI(entity)))
if (decoded?.type === "nevent") { if (decoded?.type === "nevent") {
return {id: decoded.data.id, relays: decoded.data.relays, pubkey: decoded.data.author} return decoded.data
} }
if (decoded?.type === "note") { 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. // 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. // Ids and addresses of an event's immediate parents, falling back to its thread roots.
@ -265,16 +271,17 @@ export class Notes {
.use(Router) .use(Router)
.resolver.relays([...relaySelections(ref.relays ?? []), seen(ref), eventOutbox(ref)]) .resolver.relays([...relaySelections(ref.relays ?? []), seen(ref), eventOutbox(ref)])
load = async (pointer: NotePointer) => load = async (pointer: EventPointer) =>
this.app.repository.getEvent(pointer.id) ?? this.app.repository.getEvent(pointer.id) ??
first( first(
await this.app await this.app.use(Network).loadComplete({
.use(Network) relays: await this.relays(getQuoteRef(pointer)),
.loadComplete({relays: await this.relays(pointer), filters: getIdFilters([pointer.id])}), filters: getIdFilters([pointer.id]),
}),
) )
// A pointer carrying no hints would never resolve, so the load beside it asks the author's relays. // 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) this.load(pointer)
return this.app.use(Events).one(pointer.id, pointer.relays ?? []).$ return this.app.use(Events).one(pointer.id, pointer.relays ?? []).$

View file

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