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">
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue