Add the profile page for users

This commit is contained in:
dtonon 2026-08-26 14:48:41 +01:00
parent bcbc70ecb8
commit f40680cbf9
13 changed files with 517 additions and 76 deletions

View file

@ -0,0 +1,20 @@
import { error, json } from "@sveltejs/kit";
import { CACHE_CONTROL, GROUP_ID, MODE } from "$lib/config";
import { decodeProfileId, fetchProfiles } from "$lib/forum/profiles";
import { fetchUserPosts } from "$lib/forum/userPosts";
import { forumQuery, profileQuery } from "$lib/ssr/relay";
export async function GET({ params, setHeaders }) {
setHeaders({ "cache-control": CACHE_CONTROL });
const pubkey = decodeProfileId(params.id);
if (!pubkey) error(404, "Not found");
const [profiles, posts] = await Promise.all([
fetchProfiles(profileQuery, [pubkey]),
fetchUserPosts(
forumQuery,
pubkey,
MODE === "simple" ? GROUP_ID : undefined,
),
]);
return json({ pubkey, posts, profiles });
}

View file

@ -11,8 +11,10 @@
import {
profileStore,
ensureProfile,
entryFromUser,
type ProfileEntry,
} from "$lib/profiles.svelte";
import { profilePath } from "$lib/forum/profiles";
import { partialsStore } from "$lib/partials.svelte";
import PostContent from "$lib/components/PostContent.svelte";
import { page } from "$app/state";
@ -58,20 +60,7 @@
// Server-rendered profile, used until the live one is fetched.
function snapshotEntry(pk: string): ProfileEntry | undefined {
const u = page.data.profiles?.[pk];
if (!u) return undefined;
const md = u.metadata ?? {};
return {
pubkey: pk,
npub: u.npub,
name: md.name,
displayName: md.display_name,
nip05: md.nip05,
picture: md.picture ?? u.image,
about: md.about,
website: md.website,
lud16: md.lud16,
fetchedAt: u.lastUpdated || 0,
};
return u ? entryFromUser(u) : undefined;
}
function displayName(c: Contact): string {
@ -113,26 +102,35 @@
class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
>
<div class="flex gap-4">
{#if c.entry?.picture}
<img
src={c.entry.picture}
alt=""
class="h-14 w-14 shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-lg font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{displayName(c)[0].toUpperCase()}
</span>
{/if}
<a
href={profilePath(c.pubkey)}
class="shrink-0"
aria-hidden="true"
tabindex="-1"
>
{#if c.entry?.picture}
<img
src={c.entry.picture}
alt=""
class="h-14 w-14 rounded-full object-cover"
/>
{:else}
<span
class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-lg font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{displayName(c)[0].toUpperCase()}
</span>
{/if}
</a>
<div class="flex min-w-0 flex-1 items-start justify-between gap-3">
<div class="min-w-0">
<p
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
>
{displayName(c)}
<a href={profilePath(c.pubkey)} class="hover:text-accent">
{displayName(c)}
</a>
</p>
{#if c.entry?.nip05}
<p class="truncate text-neutral-400 dark:text-neutral-500">
@ -141,12 +139,10 @@
{/if}
</div>
<a
href="https://njump.me/{c.npub}"
target="_blank"
rel="noopener noreferrer"
href={profilePath(c.pubkey)}
class="text-accent shrink-0 text-sm hover:underline"
>
View profile ↗
View profile
</a>
</div>
</div>

View file

@ -0,0 +1,236 @@
<script lang="ts">
import { page } from "$app/state";
import { MODE } from "$lib/config";
import { getGroupName } from "$lib/group.svelte";
import {
profileStore,
ensureProfile,
entryFromUser,
type ProfileEntry,
} from "$lib/profiles.svelte";
import { userPostsStore, loadUserPosts } from "$lib/userPosts.svelte";
import type { UserPost } from "$lib/forum/userPosts";
import Meta from "$lib/components/Meta.svelte";
import { summarize } from "$lib/seo";
const pubkey = $derived<string | null>(page.data.pubkey ?? null);
$effect(() => {
if (!pubkey) return;
ensureProfile(pubkey);
loadUserPosts(pubkey);
});
// Server-rendered profile, used until the live one is fetched.
const entry = $derived.by<ProfileEntry | undefined>(() => {
if (!pubkey) return undefined;
const live = profileStore.profiles.get(pubkey);
if (live) return live;
const u = page.data.profiles?.[pubkey];
return u ? entryFromUser(u) : undefined;
});
const posts = $derived(
pubkey && userPostsStore.pubkey === pubkey
? userPostsStore.posts
: (page.data.posts ?? null),
);
const npub = $derived(entry?.npub ?? page.params.id ?? "");
const name = $derived(
entry?.displayName || entry?.name || `${npub.slice(0, 12)}…`,
);
const initial = $derived(name[0]?.toUpperCase() ?? "?");
function websiteHref(url: string): string {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function websiteLabel(url: string): string {
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
}
function formatDate(ts: number) {
return new Date(ts * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function postHref(p: UserPost): string {
return p.id === p.threadId
? `/thread/${p.threadId}`
: `/thread/${p.threadId}#post-${p.id}`;
}
const jsonLd = $derived(
pubkey
? {
"@context": "https://schema.org",
"@type": "Person",
name,
url: page.url.origin + page.url.pathname,
...(entry?.picture ? { image: entry.picture } : {}),
...(entry?.about ? { description: summarize(entry.about, 160) } : {}),
}
: undefined,
);
</script>
<Meta
title={name}
description={entry?.about ? summarize(entry.about, 160) : ""}
image={entry?.picture}
{jsonLd}
/>
{#snippet postList(items: UserPost[], empty: string)}
{#if items.length === 0}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
{:else}
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
{#each items as p (p.id)}
{@const text = summarize(p.content, 200)}
<li class="py-3">
<a href={postHref(p)} class="group block">
{#if p.title}
<p
class="group-hover:text-accent line-clamp-2 text-lg leading-5 text-neutral-900 dark:text-neutral-100"
>
{p.title}
</p>
{/if}
{#if text}
<p
class="mt-1 text-sm leading-5 text-neutral-600 dark:text-neutral-400"
>
{text}
</p>
{/if}
</a>
<div
class="mt-1.5 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
>
{#if MODE === "full"}
<a href="/room/{p.groupId}" class="hover:text-accent truncate"
>{getGroupName(p.groupId)}</a
>
<span aria-hidden="true">·</span>
{/if}
<time datetime={new Date(p.createdAt * 1000).toISOString()}
>{formatDate(p.createdAt)}</time
>
</div>
</li>
{/each}
</ul>
{/if}
{/snippet}
<div class="mx-auto max-w-6xl">
{#if !pubkey}
<h1 class="text-accent py-2 text-[1.65rem]">Profile</h1>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Profile not found.
</p>
{:else}
<h1 class="text-accent py-2 text-[1.65rem]">{name}</h1>
<div class="mt-2 flex items-start gap-6">
<div class="min-w-0 flex-1">
{#if entry?.nip05}
<p class="truncate text-neutral-400 dark:text-neutral-500">
{entry.nip05}
</p>
{/if}
{#if entry?.about}
<p
class="mt-2 whitespace-pre-line text-neutral-600 dark:text-neutral-400"
>
{entry.about}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
{#if entry?.website}
<a
href={websiteHref(entry.website)}
target="_blank"
rel="noopener noreferrer"
class="text-accent truncate hover:underline"
>
{websiteLabel(entry.website)}
</a>
{/if}
{#if entry?.lud16}
<span
class="text-neutral-500 dark:text-neutral-400"
title="Lightning address"
>
⚡ {entry.lud16}
</span>
{/if}
<a
href="https://njump.me/{npub}"
target="_blank"
rel="noopener noreferrer"
class="text-accent text-sm hover:underline"
>
View on njump ↗
</a>
</div>
</div>
{#if entry?.picture}
<img
src={entry.picture}
alt=""
class="h-24 w-24 shrink-0 rounded-full object-cover sm:h-32 sm:w-32"
/>
{:else}
<span
class="flex h-24 w-24 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-3xl font-semibold text-neutral-500 sm:h-32 sm:w-32 dark:bg-neutral-700 dark:text-neutral-400"
aria-hidden="true"
>
{initial}
</span>
{/if}
</div>
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
<section aria-labelledby="profile-discussions">
<h2
id="profile-discussions"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Discussions
</h2>
{#if !posts}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else}
{@render postList(posts.roots, "No discussions yet.")}
{/if}
</section>
<section aria-labelledby="profile-replies">
<h2
id="profile-replies"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Replies
</h2>
{#if !posts}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else}
{@render postList(posts.replies, "No replies yet.")}
{/if}
</section>
</div>
{/if}
</div>

View file

@ -0,0 +1,24 @@
import { redirect } from "@sveltejs/kit";
import * as nip19 from "@nostr/tools/nip19";
import { decodeProfileId } from "$lib/forum/profiles";
import { snapshot, type Profiles } from "$lib/forum/snapshot";
import type { UserPosts } from "$lib/forum/userPosts";
// nprofile and hex forms redirect to the canonical npub URL. An invalid id
// is a server 404 (via the endpoint) and an in-page message on the client.
export async function load({ fetch, params }) {
const pubkey = decodeProfileId(params.id);
if (pubkey) {
const npub = nip19.npubEncode(pubkey);
if (params.id !== npub) redirect(301, `/profile/${npub}`);
}
const data = await snapshot<{ posts: UserPosts; profiles: Profiles }>(
fetch,
`/api/profile/${params.id}`,
);
return {
pubkey,
posts: data?.posts ?? null,
profiles: data?.profiles ?? null,
};
}

View file

@ -26,6 +26,7 @@
import { setActiveGroup } from "$lib/active.svelte";
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
import { RELAY_URL, MODE } from "$lib/config";
import { profilePath } from "$lib/forum/profiles";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import MessageEditor from "$lib/components/MessageEditor.svelte";
import PostContent from "$lib/components/PostContent.svelte";
@ -373,20 +374,31 @@
)}
{@const author = resolveAuthor(p.pubkey, profiles)}
<div use:bindEl class="md:flex md:items-start md:gap-6">
<div class="hidden flex-shrink-0 md:block">
<a
href={profilePath(author.pubkey)}
class="hidden flex-shrink-0 md:block"
aria-hidden="true"
tabindex="-1"
>
{@render avatar(author)}
</div>
</a>
<div class="min-w-0 flex-1">
<div
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
>
<div class="flex min-w-0 items-center gap-3">
<div class="flex-shrink-0 md:hidden">
<a
href={profilePath(author.pubkey)}
class="flex-shrink-0 md:hidden"
aria-hidden="true"
tabindex="-1"
>
{@render avatar(author)}
</div>
<span
class="truncate font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</span
</a>
<a
href={profilePath(author.pubkey)}
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</a
>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-1">