diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md
index ef86b058..b4ba3f77 100644
--- a/e2e/USER_STORIES.md
+++ b/e2e/USER_STORIES.md
@@ -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
diff --git a/e2e/specs/content-rendering.spec.ts b/e2e/specs/content-rendering.spec.ts
index e7e2d3eb..90b930f0 100644
--- a/e2e/specs/content-rendering.spec.ts
+++ b/e2e/specs/content-rendering.spec.ts
@@ -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")
diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte
index 5aa8fbfa..eb7fd64f 100644
--- a/src/app/components/Content.svelte
+++ b/src/app/components/Content.svelte
@@ -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 = () => {
diff --git a/src/app/components/ContentMinimal.svelte b/src/app/components/ContentMinimal.svelte
index 2054f6f3..c7ba65a2 100644
--- a/src/app/components/ContentMinimal.svelte
+++ b/src/app/components/ContentMinimal.svelte
@@ -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)}
{:else if isQuote(parsed)}
-
+
{:else}
{@html renderAsHtml(parsed)}
{/if}
diff --git a/src/app/components/ContentQuote.svelte b/src/app/components/ContentQuote.svelte
index 9f4d991a..7a88afdc 100644
--- a/src/app/components/ContentQuote.svelte
+++ b/src/app/components/ContentQuote.svelte
@@ -1,28 +1,18 @@