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