Show a person's NIP-38 status on their profile page and preview
This commit is contained in:
parent
e8a4535030
commit
e052191bb2
6 changed files with 102 additions and 3 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
34
src/app/components/ProfileStatus.svelte
Normal file
34
src/app/components/ProfileStatus.svelte
Normal 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
25
src/app/statuses.ts
Normal 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)
|
||||||
Loading…
Reference in a new issue