Show a person's NIP-38 status on their profile page and preview

This commit is contained in:
Coracle-Bot 2026-09-11 00:12:19 +00:00
parent e8a4535030
commit e052191bb2
6 changed files with 102 additions and 3 deletions

View file

@ -1144,6 +1144,8 @@ Acceptance:
- His profile shows display name, avatar, banner, about text, and a shortened - His profile shows display name, avatar, banner, about text, and a shortened
npub whose copy button confirms the copy. 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 website he has set renders as a link.
- A Spaces panel lists the spaces he belongs to with a count, marks any alice - 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, 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: Acceptance:
- Clicking bob's avatar or mention in a member list or message opens a popover - 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. - The popover offers "View Full Profile", which navigates to his profile page.
- Closing the popover leaves alice where she was. - Closing the popover leaves alice where she was.

View file

@ -2,7 +2,7 @@ import {createHash} from "node:crypto"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import {nprofileEncode, npubEncode} from "nostr-tools/nip19" import {nprofileEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE, MONTH} from "@welshman/lib" 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 type {SignedEvent} from "@welshman/util"
import {FollowList, Note, PinList, Profile} from "@welshman/domain" import {FollowList, Note, PinList, Profile} from "@welshman/domain"
import { import {
@ -142,7 +142,7 @@ test("US-075 view someone's profile", async ({seed, as}) => {
const avatar = "https://images.test/bob-avatar.png" const avatar = "https://images.test/bob-avatar.png"
const banner = "https://images.test/bob-banner.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 space = relay("space")
const other = relay("other") const other = relay("other")
@ -167,11 +167,34 @@ test("US-075 view someone's profile", async ({seed, as}) => {
.renderTemplate(), .renderTemplate(),
) )
space.event(
user.bob,
makeEvent(STATUS, {
content: "Scrubbing the decks",
tags: [
["d", "general"],
["r", "https://bobbarnacle.example/decks"],
],
}),
)
space.relayList(user.bob) space.relayList(user.bob)
// Carol belongs to no space at all, which is what the panel's empty state is about. // Carol belongs to no space at all, which is what the panel's empty state is about.
space.member(user.carol) space.member(user.carol)
space.profile(user.carol, {name: "Carol Cutter"}) 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) 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="${avatar}"]`).last()).toBeVisible()
await expect(region.locator(`img[src="${banner}"]`)).toBeVisible() await expect(region.locator(`img[src="${banner}"]`)).toBeVisible()
await expect(region.getByText("Deckhand, dockside cook")).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)) 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(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
await expect(sidebar(page).getByText("No spaces found.")).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}) => { 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, picture: avatar,
}) })
space.message(user.bob, "general", "the cat has the helm", at(1, HOUR)) 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) 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).getByText("Bob Barnacle")).toBeVisible()
await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible() await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
await expect(topDialog(page).getByText("Deckhand, dockside cook")).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 expect(topDialog(page).getByText(/Last active/)).toBeVisible()
await page.keyboard.press("Escape") await page.keyboard.press("Escape")

View file

@ -17,6 +17,7 @@
import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileMenu from "@app/components/ProfileMenu.svelte" import ProfileMenu from "@app/components/ProfileMenu.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte"
import {messagingRelayLists, profiles, relayManagement, user} from "@app/core" import {messagingRelayLists, profiles, relayManagement, user} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveUserIsSpaceAdmin} from "@app/management"
import {navigate, popModal, pushModal} from "@app/modal" import {navigate, popModal, pushModal} from "@app/modal"
@ -134,6 +135,7 @@
{/snippet} {/snippet}
</ProfileMenu> </ProfileMenu>
</div> </div>
<ProfileStatus {pubkey} {url} />
<ProfileAbout {pubkey} {url} /> <ProfileAbout {pubkey} {url} />
<ProfileBadges {pubkey} {url} /> <ProfileBadges {pubkey} {url} />
</div> </div>

View file

@ -16,6 +16,7 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileTrust from "@app/components/ProfileTrust.svelte" import ProfileTrust from "@app/components/ProfileTrust.svelte"
import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte" import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte"
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte" import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte" import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileMenu from "@app/components/ProfileMenu.svelte" import ProfileMenu from "@app/components/ProfileMenu.svelte"
@ -180,6 +181,8 @@
</Button> </Button>
</div> </div>
<ProfileStatus pubkey={target} />
{#if website} {#if website}
<Link <Link
external external

View file

@ -0,0 +1,34 @@
<script lang="ts">
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])
</script>
{#if text}
<div class="text-content-muted flex items-center gap-2 text-sm">
<Icon icon={Pulse} size={4} class="shrink-0" />
{#if link}
<Link external href={link} class="link line-clamp-2 wrap-break-word">{text}</Link>
{:else}
<span class="line-clamp-2 wrap-break-word">{text}</span>
{/if}
</div>
{/if}

25
src/app/statuses.ts Normal file
View file

@ -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<TrustedEvent> {
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)