Add the profile page for users
This commit is contained in:
parent
bcbc70ecb8
commit
f40680cbf9
13 changed files with 517 additions and 76 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import { tokenizeChat } from "$lib/linkify";
|
import { tokenizeChat } from "$lib/linkify";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import {
|
import {
|
||||||
resolveThreadRef,
|
resolveThreadRef,
|
||||||
threadRefHref,
|
threadRefHref,
|
||||||
|
|
@ -39,9 +40,7 @@
|
||||||
<span class="break-words whitespace-pre-wrap"
|
<span class="break-words whitespace-pre-wrap"
|
||||||
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
||||||
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
||||||
href="https://njump.me/{t.entity}"
|
href={profilePath(t.pubkey)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
class="text-accent hover:underline">@{u?.shortName ?? t.fallback}</a
|
||||||
>{:else if t.type === "entity"}{@const ref = t.id
|
>{:else if t.type === "entity"}{@const ref = t.id
|
||||||
? resolvedThreads[t.id]
|
? resolvedThreads[t.id]
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
ensureMembershipChecked,
|
ensureMembershipChecked,
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { activeGroup } from "$lib/active.svelte";
|
import { activeGroup } from "$lib/active.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
import ChatContent from "$lib/components/ChatContent.svelte";
|
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||||
|
|
@ -348,21 +349,30 @@
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex items-center gap-2">
|
<div class="mb-1 flex items-center gap-2">
|
||||||
{#if author.picture}
|
<a
|
||||||
<img
|
href={profilePath(msg.pubkey)}
|
||||||
src={author.picture}
|
class="shrink-0"
|
||||||
alt=""
|
aria-hidden="true"
|
||||||
class="h-6 w-6 shrink-0 rounded-full object-cover"
|
tabindex="-1"
|
||||||
/>
|
>
|
||||||
{:else}
|
{#if author.picture}
|
||||||
<span
|
<img
|
||||||
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"
|
src={author.picture}
|
||||||
>
|
alt=""
|
||||||
{author.name[0].toUpperCase()}
|
class="h-6 w-6 rounded-full object-cover"
|
||||||
</span>
|
/>
|
||||||
{/if}
|
{:else}
|
||||||
<span class="font-medium text-neutral-500 dark:text-neutral-400"
|
<span
|
||||||
>{author.name}</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="ml-auto flex items-center gap-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
import { loadNostrUser, type NostrUser } from "$lib/gadgets";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import {
|
import {
|
||||||
resolveThreadRef,
|
resolveThreadRef,
|
||||||
threadRefHref,
|
threadRefHref,
|
||||||
|
|
@ -611,11 +612,8 @@
|
||||||
>
|
>
|
||||||
{:else if inline.type === "mention"}
|
{:else if inline.type === "mention"}
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
<a
|
<a href={profilePath(inline.pubkey)} class="text-accent hover:underline"
|
||||||
href="https://njump.me/{inline.entity}"
|
>@{u?.shortName ?? inline.fallback}</a
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent hover:underline">@{u?.shortName ?? inline.fallback}</a
|
|
||||||
>
|
>
|
||||||
{:else if inline.type === "thread-quote"}
|
{:else if inline.type === "thread-quote"}
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
|
|
@ -700,7 +698,7 @@
|
||||||
src={block.src}
|
src={block.src}
|
||||||
alt={block.alt}
|
alt={block.alt}
|
||||||
loading="lazy"
|
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"}
|
{:else if block.type === "blockquote"}
|
||||||
<blockquote
|
<blockquote
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,21 @@ export async function fetchProfiles(
|
||||||
}
|
}
|
||||||
return out;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
69
src/lib/forum/userPosts.ts
Normal file
69
src/lib/forum/userPosts.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -305,10 +305,9 @@ export function ingestProfileEvent(evt: Event) {
|
||||||
if (entry) upsertProfile(entry);
|
if (entry) upsertProfile(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestNostrUser(user: NostrUser) {
|
export function entryFromUser(user: NostrUser): ProfileEntry {
|
||||||
if (!user.pubkey) return;
|
|
||||||
const md = user.metadata ?? {};
|
const md = user.metadata ?? {};
|
||||||
const entry: ProfileEntry = {
|
return {
|
||||||
pubkey: user.pubkey,
|
pubkey: user.pubkey,
|
||||||
npub: user.npub,
|
npub: user.npub,
|
||||||
name: md.name,
|
name: md.name,
|
||||||
|
|
@ -320,5 +319,9 @@ export function ingestNostrUser(user: NostrUser) {
|
||||||
lud16: md.lud16,
|
lud16: md.lud16,
|
||||||
fetchedAt: user.lastUpdated || 0,
|
fetchedAt: user.lastUpdated || 0,
|
||||||
};
|
};
|
||||||
upsertProfile(entry);
|
}
|
||||||
|
|
||||||
|
export function ingestNostrUser(user: NostrUser) {
|
||||||
|
if (!user.pubkey) return;
|
||||||
|
upsertProfile(entryFromUser(user));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,38 @@
|
||||||
// Plain-text excerpt of a markdown body for description meta tags: the first
|
// Markdown inline syntax reduced to plain text.
|
||||||
// paragraph that isn't a heading or a bare media URL, trimmed to `max` chars.
|
function stripInline(text: string): string {
|
||||||
export function excerpt(markdown: string, max = 160): string {
|
return text
|
||||||
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
|
|
||||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
|
||||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → label
|
||||||
.replace(/[*_`~]+/g, "")
|
.replace(/[*_`~]+/g, "")
|
||||||
.replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle
|
.replace(/nostr:([a-z0-9]{12})[a-z0-9]*/g, "@$1…") // keep a short handle
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
35
src/lib/userPosts.svelte.ts
Normal file
35
src/lib/userPosts.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
20
src/routes/api/profile/[id]/+server.ts
Normal file
20
src/routes/api/profile/[id]/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,10 @@
|
||||||
import {
|
import {
|
||||||
profileStore,
|
profileStore,
|
||||||
ensureProfile,
|
ensureProfile,
|
||||||
|
entryFromUser,
|
||||||
type ProfileEntry,
|
type ProfileEntry,
|
||||||
} from "$lib/profiles.svelte";
|
} from "$lib/profiles.svelte";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import { partialsStore } from "$lib/partials.svelte";
|
import { partialsStore } from "$lib/partials.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
@ -58,20 +60,7 @@
|
||||||
// Server-rendered profile, used until the live one is fetched.
|
// Server-rendered profile, used until the live one is fetched.
|
||||||
function snapshotEntry(pk: string): ProfileEntry | undefined {
|
function snapshotEntry(pk: string): ProfileEntry | undefined {
|
||||||
const u = page.data.profiles?.[pk];
|
const u = page.data.profiles?.[pk];
|
||||||
if (!u) return undefined;
|
return u ? entryFromUser(u) : 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(c: Contact): string {
|
function displayName(c: Contact): string {
|
||||||
|
|
@ -113,26 +102,35 @@
|
||||||
class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
|
class="rounded-lg border border-neutral-100 p-4 shadow-sm dark:border-neutral-800"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
{#if c.entry?.picture}
|
<a
|
||||||
<img
|
href={profilePath(c.pubkey)}
|
||||||
src={c.entry.picture}
|
class="shrink-0"
|
||||||
alt=""
|
aria-hidden="true"
|
||||||
class="h-14 w-14 shrink-0 rounded-full object-cover"
|
tabindex="-1"
|
||||||
/>
|
>
|
||||||
{:else}
|
{#if c.entry?.picture}
|
||||||
<span
|
<img
|
||||||
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"
|
src={c.entry.picture}
|
||||||
>
|
alt=""
|
||||||
{displayName(c)[0].toUpperCase()}
|
class="h-14 w-14 rounded-full object-cover"
|
||||||
</span>
|
/>
|
||||||
{/if}
|
{: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="flex min-w-0 flex-1 items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p
|
<p
|
||||||
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100"
|
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>
|
</p>
|
||||||
{#if c.entry?.nip05}
|
{#if c.entry?.nip05}
|
||||||
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
<p class="truncate text-neutral-400 dark:text-neutral-500">
|
||||||
|
|
@ -141,12 +139,10 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{c.npub}"
|
href={profilePath(c.pubkey)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent shrink-0 text-sm hover:underline"
|
class="text-accent shrink-0 text-sm hover:underline"
|
||||||
>
|
>
|
||||||
View profile ↗
|
View profile
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
236
src/routes/profile/[id]/+page.svelte
Normal file
236
src/routes/profile/[id]/+page.svelte
Normal 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>
|
||||||
24
src/routes/profile/[id]/+page.ts
Normal file
24
src/routes/profile/[id]/+page.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
import { setActiveGroup } from "$lib/active.svelte";
|
import { setActiveGroup } from "$lib/active.svelte";
|
||||||
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
|
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
|
||||||
import { RELAY_URL, MODE } from "$lib/config";
|
import { RELAY_URL, MODE } from "$lib/config";
|
||||||
|
import { profilePath } from "$lib/forum/profiles";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
|
|
@ -373,20 +374,31 @@
|
||||||
)}
|
)}
|
||||||
{@const author = resolveAuthor(p.pubkey, profiles)}
|
{@const author = resolveAuthor(p.pubkey, profiles)}
|
||||||
<div use:bindEl class="md:flex md:items-start md:gap-6">
|
<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)}
|
{@render avatar(author)}
|
||||||
</div>
|
</a>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex items-center justify-between md:mb-3 md:items-baseline"
|
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 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)}
|
{@render avatar(author)}
|
||||||
</div>
|
</a>
|
||||||
<span
|
<a
|
||||||
class="truncate font-medium text-neutral-500 dark:text-neutral-400"
|
href={profilePath(author.pubkey)}
|
||||||
>{author.name}</span
|
class="hover:text-accent truncate font-medium text-neutral-500 dark:text-neutral-400"
|
||||||
|
>{author.name}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue