From e052191bb273319d2d776113fdd7015df7e77fbb Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Fri, 11 Sep 2026 00:12:19 +0000 Subject: [PATCH] Show a person's NIP-38 status on their profile page and preview --- e2e/USER_STORIES.md | 4 ++- e2e/specs/people.spec.ts | 37 +++++++++++++++++++++++-- src/app/components/ProfileDetail.svelte | 2 ++ src/app/components/ProfilePage.svelte | 3 ++ src/app/components/ProfileStatus.svelte | 34 +++++++++++++++++++++++ src/app/statuses.ts | 25 +++++++++++++++++ 6 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/app/components/ProfileStatus.svelte create mode 100644 src/app/statuses.ts diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 188ab922..aeaaebe8 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1144,6 +1144,8 @@ Acceptance: - His profile shows display name, avatar, banner, about text, and a shortened npub whose copy button confirms the copy. +- A status he has published shows what he is up to, and links to the url it + names; an expired one shows nothing. - A website he has set renders as a link. - A Spaces panel lists the spaces he belongs to with a count, marks any alice also belongs to as "Member", and navigates to a space when clicked; with none, @@ -1214,7 +1216,7 @@ check who someone is without losing my place. Acceptance: - Clicking bob's avatar or mention in a member list or message opens a popover - with his avatar, name, about text, and badges. + with his avatar, name, about text, status, and badges. - The popover offers "View Full Profile", which navigates to his profile page. - Closing the popover leaves alice where she was. diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts index 0b05356c..b5035a16 100644 --- a/e2e/specs/people.spec.ts +++ b/e2e/specs/people.spec.ts @@ -2,7 +2,7 @@ import {createHash} from "node:crypto" import type {Locator, Page} from "@playwright/test" import {nprofileEncode, npubEncode} from "nostr-tools/nip19" import {HOUR, MINUTE, MONTH} from "@welshman/lib" -import {NOTE, makeEvent} from "@welshman/util" +import {NOTE, STATUS, makeEvent} from "@welshman/util" import type {SignedEvent} from "@welshman/util" import {FollowList, Note, PinList, Profile} from "@welshman/domain" import { @@ -142,7 +142,7 @@ test("US-075 view someone's profile", async ({seed, as}) => { const avatar = "https://images.test/bob-avatar.png" const banner = "https://images.test/bob-banner.png" - const scenario = await seed(({relay, user}) => { + const scenario = await seed(({relay, user, at}) => { const space = relay("space") const other = relay("other") @@ -167,11 +167,34 @@ test("US-075 view someone's profile", async ({seed, as}) => { .renderTemplate(), ) + space.event( + user.bob, + makeEvent(STATUS, { + content: "Scrubbing the decks", + tags: [ + ["d", "general"], + ["r", "https://bobbarnacle.example/decks"], + ], + }), + ) + space.relayList(user.bob) // Carol belongs to no space at all, which is what the panel's empty state is about. space.member(user.carol) space.profile(user.carol, {name: "Carol Cutter"}) + // An expired status is one its author asked relays to stop serving, so it is not what she is + // up to any more. + space.event( + user.carol, + makeEvent(STATUS, { + content: "Ashore until spring", + tags: [ + ["d", "general"], + ["expiration", String(at(1, HOUR))], + ], + }), + ) space.relayList(user.carol) }) @@ -185,6 +208,10 @@ test("US-075 view someone's profile", async ({seed, as}) => { await expect(region.locator(`img[src="${avatar}"]`).last()).toBeVisible() await expect(region.locator(`img[src="${banner}"]`)).toBeVisible() await expect(region.getByText("Deckhand, dockside cook")).toBeVisible() + await expect(region.getByRole("link", {name: "Scrubbing the decks"})).toHaveAttribute( + "href", + "https://bobbarnacle.example/decks", + ) const npub = region.getByText(shortNpub(users.bob)) @@ -217,6 +244,7 @@ test("US-075 view someone's profile", async ({seed, as}) => { await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible() await expect(sidebar(page).getByText("No spaces found.")).toBeVisible() + await expect(region.getByText("Ashore until spring")).toHaveCount(0) }) test("US-076 follow and unfollow", async ({seed, as}) => { @@ -554,6 +582,10 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => { picture: avatar, }) space.message(user.bob, "general", "the cat has the helm", at(1, HOUR)) + space.event( + user.bob, + makeEvent(STATUS, {content: "Scrubbing the decks", tags: [["d", "general"]]}), + ) space.relayList(user.bob) }) @@ -576,6 +608,7 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => { await expect(topDialog(page).getByText("Bob Barnacle")).toBeVisible() await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible() await expect(topDialog(page).getByText("Deckhand, dockside cook")).toBeVisible() + await expect(topDialog(page).getByText("Scrubbing the decks")).toBeVisible() await expect(topDialog(page).getByText(/Last active/)).toBeVisible() await page.keyboard.press("Escape") diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index 3c3b6f68..9600e038 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -17,6 +17,7 @@ import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileMenu from "@app/components/ProfileMenu.svelte" + import ProfileStatus from "@app/components/ProfileStatus.svelte" import {messagingRelayLists, profiles, relayManagement, user} from "@app/core" import {deriveUserIsSpaceAdmin} from "@app/management" import {navigate, popModal, pushModal} from "@app/modal" @@ -134,6 +135,7 @@ {/snippet} + diff --git a/src/app/components/ProfilePage.svelte b/src/app/components/ProfilePage.svelte index ab0fcfa1..61fa508f 100644 --- a/src/app/components/ProfilePage.svelte +++ b/src/app/components/ProfilePage.svelte @@ -16,6 +16,7 @@ import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileTrust from "@app/components/ProfileTrust.svelte" import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte" + import ProfileStatus from "@app/components/ProfileStatus.svelte" import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte" import ProfileEdit from "@app/components/ProfileEdit.svelte" import ProfileMenu from "@app/components/ProfileMenu.svelte" @@ -180,6 +181,8 @@ + + {#if website} + import {now, removeUndefined} from "@welshman/lib" + import {getExpiration} from "@welshman/domain" + import Pulse from "@assets/icons/pulse.svg?dataurl" + import Icon from "@lib/components/Icon.svelte" + import Link from "@lib/components/Link.svelte" + import {statuses} from "@app/statuses" + + type Props = { + pubkey: string + url?: string + } + + const {pubkey, url}: Props = $props() + + const status = $statuses.one(pubkey, removeUndefined([url])) + + // Empty content is how NIP-38 says there is nothing to report, and an expiration is the author + // asking relays to stop serving it. Both read as no status at all. + const expiration = $derived($status ? getExpiration($status) : undefined) + const text = $derived(expiration && expiration <= now() ? "" : $status?.content) + const link = $derived($status?.tags.find(t => t[0] === "r")?.[1]) + + +{#if text} +
+ + {#if link} + {text} + {:else} + {text} + {/if} +
+{/if} diff --git a/src/app/statuses.ts b/src/app/statuses.ts new file mode 100644 index 00000000..d554d785 --- /dev/null +++ b/src/app/statuses.ts @@ -0,0 +1,25 @@ +import {STATUS} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {DerivedPlugin, Network} from "@welshman/app" +import type {IApp} from "@welshman/app" +import {usePlugin} from "@app/core" + +// NIP-38 keys a status by its `d` tag. "general" is the one clients read as what someone is up to; +// "music" is the other one in the spec and belongs to a player rather than a profile. +const filter = {kinds: [STATUS], "#d": ["general"]} + +export class Statuses extends DerivedPlugin { + constructor(app: IApp) { + super(app, { + filters: [filter], + eventToItem: event => event, + getKey: event => event.pubkey, + }) + } + + fetch(pubkey: string, hints: string[] = []) { + return this.app.use(Network).loadUsingOutbox(pubkey, filter, hints) + } +} + +export const statuses = usePlugin(Statuses)