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
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</ProfileMenu>
|
||||
</div>
|
||||
<ProfileStatus {pubkey} {url} />
|
||||
<ProfileAbout {pubkey} {url} />
|
||||
<ProfileBadges {pubkey} {url} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<ProfileStatus pubkey={target} />
|
||||
|
||||
{#if website}
|
||||
<Link
|
||||
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