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

@ -1,6 +1,7 @@
<script lang="ts">
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { tokenizeChat } from "$lib/linkify";
import { profilePath } from "$lib/forum/profiles";
import {
resolveThreadRef,
threadRefHref,
@ -39,9 +40,7 @@
<span class="break-words whitespace-pre-wrap"
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
href="https://njump.me/{t.entity}"
target="_blank"
rel="noopener noreferrer"
href={profilePath(t.pubkey)}
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
>{:else if t.type === "entity"}{@const ref = t.id
? resolvedThreads[t.id]

View file

@ -20,6 +20,7 @@
ensureMembershipChecked,
} from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import { profilePath } from "$lib/forum/profiles";
import type { NostrUser } from "@nostr/gadgets/metadata";
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
import ChatContent from "$lib/components/ChatContent.svelte";
@ -348,21 +349,30 @@
{/if}
<div>
<div class="mb-1 flex items-center gap-2">
{#if author.picture}
<img
src={author.picture}
alt=""
class="h-6 w-6 shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{author.name[0].toUpperCase()}
</span>
{/if}
<span class="font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</span
<a
href={profilePath(msg.pubkey)}
class="shrink-0"
aria-hidden="true"
tabindex="-1"
>
{#if author.picture}
<img
src={author.picture}
alt=""
class="h-6 w-6 rounded-full object-cover"
/>
{:else}
<span
class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{author.name[0].toUpperCase()}
</span>
{/if}
</a>
<a
href={profilePath(msg.pubkey)}
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
>{author.name}</a
>
<div class="ml-auto flex items-center gap-1">
<div class="relative">

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import * as nip19 from "@nostr/tools/nip19";
import { profilePath } from "$lib/forum/profiles";
import {
resolveThreadRef,
threadRefHref,
@ -611,11 +612,8 @@
>
{:else if inline.type === "mention"}
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
<a
href="https://njump.me/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:underline">@{u?.shortName ?? inline.fallback}</a
<a href={profilePath(inline.pubkey)} class="text-accent hover:underline"
>@{u?.shortName ?? inline.fallback}</a
>
{:else if inline.type === "thread-quote"}
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
@ -700,7 +698,7 @@
src={block.src}
alt={block.alt}
loading="lazy"
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
class="mx-auto block max-h-[80vh] w-full rounded object-contain"
/>
{:else if block.type === "blockquote"}
<blockquote

View file

@ -55,3 +55,21 @@ export async function fetchProfiles(
}
return out;
}
// In-app profile page, always addressed by npub.
export function profilePath(pubkey: string): string {
return `/profile/${nip19.npubEncode(pubkey)}`;
}
// The pubkey behind a profile URL segment: npub, nprofile or bare hex.
export function decodeProfileId(id: string): string | null {
if (/^[0-9a-f]{64}$/.test(id)) return id;
try {
const decoded = nip19.decode(id);
if (decoded.type === "npub") return decoded.data;
if (decoded.type === "nprofile") return decoded.data.pubkey;
} catch {
// Not a bech32 entity
}
return null;
}

View file

@ -0,0 +1,69 @@
import type { Event } from "@nostr/tools/core";
import { tag, type Query } from "./query";
export type UserPost = {
id: string;
threadId: string; // the root thread; equals `id` for a root post
title: string; // thread title, empty when the root is unreachable
groupId: string;
createdAt: number;
content: string;
};
export type UserPosts = { roots: UserPost[]; replies: UserPost[] };
export const USER_POSTS_LIMIT = 50;
function newestFirst(events: Event[]): Event[] {
return [...events].sort((a, b) => b.created_at - a.created_at);
}
// A user's latest root posts (kind 11) and replies (kind 1111). `groupId`
// scopes the lookup to one group (simple mode); otherwise every group the
// relay serves the caller counts.
export async function fetchUserPosts(
q: Query,
pubkey: string,
groupId?: string,
): Promise<UserPosts> {
const scope = groupId ? { "#h": [groupId] } : {};
const [ops, replies] = await Promise.all([
q({ kinds: [11], authors: [pubkey], ...scope, limit: USER_POSTS_LIMIT }),
q({ kinds: [1111], authors: [pubkey], ...scope, limit: USER_POSTS_LIMIT }),
]);
const rootIds = [
...new Set(
replies.map((r) => tag(r, "E")).filter((id): id is string => !!id),
),
];
const titles = new Map<string, string>();
if (rootIds.length > 0) {
const roots = await q({ kinds: [11], ids: rootIds });
for (const e of roots) titles.set(e.id, tag(e, "title") ?? "(untitled)");
}
return {
roots: newestFirst(ops).map((e) => ({
id: e.id,
threadId: e.id,
title: tag(e, "title") ?? "(untitled)",
groupId: tag(e, "h") ?? groupId ?? "",
createdAt: e.created_at,
content: e.content,
})),
replies: newestFirst(replies)
.filter((e) => tag(e, "E"))
.map((e) => {
const threadId = tag(e, "E")!;
return {
id: e.id,
threadId,
title: titles.get(threadId) ?? "",
groupId: tag(e, "h") ?? groupId ?? "",
createdAt: e.created_at,
content: e.content,
};
}),
};
}

View file

@ -305,10 +305,9 @@ export function ingestProfileEvent(evt: Event) {
if (entry) upsertProfile(entry);
}
export function ingestNostrUser(user: NostrUser) {
if (!user.pubkey) return;
export function entryFromUser(user: NostrUser): ProfileEntry {
const md = user.metadata ?? {};
const entry: ProfileEntry = {
return {
pubkey: user.pubkey,
npub: user.npub,
name: md.name,
@ -320,5 +319,9 @@ export function ingestNostrUser(user: NostrUser) {
lud16: md.lud16,
fetchedAt: user.lastUpdated || 0,
};
upsertProfile(entry);
}
export function ingestNostrUser(user: NostrUser) {
if (!user.pubkey) return;
upsertProfile(entryFromUser(user));
}

View file

@ -1,17 +1,38 @@
// Plain-text excerpt of a markdown body for description meta tags: the first
// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars.
export function excerpt(markdown: string, max = 160): string {
const lines = markdown.split("\n").map((l) => l.trim());
const line = lines.find(
(l) => l && !l.startsWith("#") && !/^https?:\/\/\S+$/.test(l),
);
if (!line) return "";
const text = line
// Markdown inline syntax reduced to plain text.
function stripInline(text: string): string {
return text
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label
.replace(/[*_`~]+/g, "")
.replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle
.replace(/\s+/g, " ")
.trim();
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
}
function isProse(line: string): boolean {
return !!line && !line.startsWith("#") && !/^https?:\/\/\S+$/.test(line);
}
// Plain-text excerpt of a markdown body for description meta tags: the first
// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars.
export function excerpt(markdown: string, max = 160): string {
const line = markdown
.split("\n")
.map((l) => l.trim())
.find(isProse);
return line ? truncate(stripInline(line), max) : "";
}
// The whole body flattened to one plain-text line, trimmed to `max` chars.
// Headings, bare media URLs, code fences and block markers are dropped.
export function summarize(markdown: string, max = 200): string {
const lines = markdown
.split("\n")
.map((l) => l.trim().replace(/^(?:>\s*|[-*+]\s+|\d+[.)]\s+)+/, ""))
.filter((l) => isProse(l) && !l.startsWith("```"));
return truncate(stripInline(lines.join(" ")), max);
}

View file

@ -0,0 +1,35 @@
import { GROUP_ID, MODE } from "$lib/config";
import { queryForum } from "$lib/relay";
import { fetchUserPosts, type UserPosts } from "$lib/forum/userPosts";
export type { UserPosts };
let pubkey = $state<string | null>(null);
let posts = $state<UserPosts | null>(null);
// Posts of the profile last loaded. Consumers check `pubkey` against the
// page's, so a stale result never shows under another user's name.
export const userPostsStore = {
get pubkey() {
return pubkey;
},
get posts() {
return posts;
},
};
export async function loadUserPosts(pk: string) {
pubkey = pk;
posts = null;
let result: UserPosts = { roots: [], replies: [] };
try {
result = await fetchUserPosts(
queryForum,
pk,
MODE === "simple" ? GROUP_ID : undefined,
);
} catch (e) {
console.error("[profile] failed to load posts", e);
}
if (pubkey === pk) posts = result;
}