spa: avatar + name für kommentar-authoren via kind:0-profil

- loadProfile(pubkey?) akzeptiert jetzt optional einen Pubkey, default
  weiterhin AUTHOR_PUBKEY_HEX.
- Neuer profileCache.ts: sessionsweiter Cache, Promise-Memoization —
  paralleles Nachladen derselben Pubkey teilt dieselbe Request.
- ReplyItem lädt das kind:0-Profil des Kommentar-Authors on mount,
  zeigt Avatar (32px rund) + display_name/name. Fallback bei fehlendem
  Profil: Pubkey-Hex-Prefix (wie bisher).
- Home-Page nutzt getProfile(AUTHOR_PUBKEY_HEX) statt loadProfile()
  direkt — gleicher Cache, kein doppeltes Fetchen.

npm run check: 0 errors. E2E 3/3 grün. Deploy live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-15 17:58:44 +02:00
parent 22935d6737
commit eb400a8a6a
4 changed files with 84 additions and 18 deletions

View File

@ -1,5 +1,7 @@
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import { onMount } from 'svelte';
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
import { getProfile } from '$lib/nostr/profileCache';
interface Props {
event: NostrEvent;
@ -7,15 +9,33 @@
let { event }: Props = $props();
const date = $derived(new Date(event.created_at * 1000).toLocaleString('de-DE'));
const authorNpub = $derived(event.pubkey.slice(0, 12) + '…');
const npubPrefix = $derived(event.pubkey.slice(0, 12) + '…');
let profile = $state<Profile | null>(null);
onMount(async () => {
try {
profile = await getProfile(event.pubkey);
} catch {
profile = null;
}
});
const displayName = $derived(profile?.display_name || profile?.name || npubPrefix);
</script>
<li class="reply">
<div class="header">
{#if profile?.picture}
<img class="avatar" src={profile.picture} alt={displayName} />
{:else}
<div class="avatar avatar-placeholder" aria-hidden="true"></div>
{/if}
<div class="meta">
<span class="author">{authorNpub}</span>
<span class="sep">·</span>
<span class="name">{displayName}</span>
<span class="date">{date}</span>
</div>
</div>
<div class="content">{event.content}</div>
</li>
@ -25,20 +45,43 @@
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.header {
display: flex;
gap: 0.6rem;
align-items: center;
margin-bottom: 0.4rem;
}
.avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
}
.avatar-placeholder {
display: block;
}
.meta {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.3rem;
display: flex;
flex-direction: column;
line-height: 1.3;
}
.author {
font-family: monospace;
}
.sep {
margin: 0 0.4rem;
opacity: 0.5;
.name {
color: var(--fg);
font-weight: 500;
word-break: break-word;
}
.content {
white-space: pre-wrap;
word-wrap: break-word;
margin-left: calc(32px + 0.6rem);
}
@media (max-width: 479px) {
.content {
margin-left: 0;
}
}
</style>

View File

@ -103,12 +103,16 @@ export async function loadPost(dtag: string): Promise<NostrEvent | null> {
);
}
/** Profil-Event kind:0 (neueste Version) */
export async function loadProfile(): Promise<Profile | null> {
/**
* Profil-Event kind:0 (neueste Version).
* Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für
* die Anzeige fremder Kommentar-Autoren.
*/
export async function loadProfile(pubkey: string = AUTHOR_PUBKEY_HEX): Promise<Profile | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [0],
authors: [AUTHOR_PUBKEY_HEX],
authors: [pubkey],
limit: 1
});
if (events.length === 0) return null;

View File

@ -0,0 +1,17 @@
import type { Profile } from './loaders';
import { loadProfile } from './loaders';
/**
* Sessionsweiter Cache für kind:0-Profile.
* Jeder Pubkey wird maximal einmal angefragt; mehrfache parallele
* Aufrufe teilen sich dieselbe Promise.
*/
const cache = new Map<string, Promise<Profile | null>>();
export function getProfile(pubkey: string): Promise<Profile | null> {
const existing = cache.get(pubkey);
if (existing) return existing;
const pending = loadProfile(pubkey);
cache.set(pubkey, pending);
return pending;
}

View File

@ -1,7 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
import { loadPostList, loadProfile } from '$lib/nostr/loaders';
import { loadPostList } from '$lib/nostr/loaders';
import { getProfile } from '$lib/nostr/profileCache';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import ProfileCard from '$lib/components/ProfileCard.svelte';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
@ -13,7 +15,7 @@
onMount(async () => {
try {
const [p, list] = await Promise.all([loadProfile(), loadPostList()]);
const [p, list] = await Promise.all([getProfile(AUTHOR_PUBKEY_HEX), loadPostList()]);
profile = p;
posts = list;
loading = false;