Add Contacts page

This commit is contained in:
dtonon 2026-05-15 11:47:01 +01:00
parent 04ef78b8a5
commit d4982dff0c
5 changed files with 141 additions and 10 deletions

View file

@ -59,10 +59,6 @@
> >
More More
</p> </p>
<a
href="/settings"
class="block py-1 text-neutral-500 hover:text-neutral-900">Settings</a
>
<a <a
href="/contacts" href="/contacts"
class="block py-1 text-neutral-500 hover:text-neutral-900">Contacts</a class="block py-1 text-neutral-500 hover:text-neutral-900">Contacts</a

View file

@ -22,7 +22,4 @@
{/if} {/if}
<span class="text-[1.7rem] font-medium text-neutral-900">{name}</span> <span class="text-[1.7rem] font-medium text-neutral-900">{name}</span>
</div> </div>
<button class="text-sm text-neutral-500 hover:text-neutral-900"
>Create your own community</button
>
</header> </header>

View file

@ -7,6 +7,7 @@ export type GroupMetadata = {
about?: string; about?: string;
isPrivate: boolean; isPrivate: boolean;
isClosed: boolean; isClosed: boolean;
admins: string[];
}; };
let group = $state<GroupMetadata | null>(null); let group = $state<GroupMetadata | null>(null);
@ -21,18 +22,22 @@ export async function loadGroup() {
const pool = new SimplePool(); const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await pool.querySync([RELAY_URL], {
kinds: [39000], kinds: [39000, 39001],
"#d": [GROUP_ID], "#d": [GROUP_ID],
limit: 1,
}); });
const event = events[0]; const event = events.find((e) => e.kind === 39000);
if (!event) return; if (!event) return;
// Admins live in the NIP-29 kind 39001 event as `p` tags.
const adminsEvent = events.find((e) => e.kind === 39001);
const admins =
adminsEvent?.tags.filter((t) => t[0] === "p").map((t) => t[1]) ?? [];
group = { group = {
name: event.tags.find((t) => t[0] === "name")?.[1] ?? GROUP_ID, name: event.tags.find((t) => t[0] === "name")?.[1] ?? GROUP_ID,
picture: event.tags.find((t) => t[0] === "picture")?.[1], picture: event.tags.find((t) => t[0] === "picture")?.[1],
about: event.tags.find((t) => t[0] === "about")?.[1], about: event.tags.find((t) => t[0] === "about")?.[1],
isPrivate: event.tags.some((t) => t[0] === "private"), isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"), isClosed: event.tags.some((t) => t[0] === "closed"),
admins,
}; };
} finally { } finally {
pool.close([RELAY_URL]); pool.close([RELAY_URL]);

View file

@ -12,6 +12,8 @@ export type ProfileEntry = {
nip05?: string; nip05?: string;
picture?: string; picture?: string;
about?: string; about?: string;
website?: string;
lud16?: string;
fetchedAt: number; fetchedAt: number;
}; };
@ -56,6 +58,8 @@ function parseProfileEvent(evt: Event): ProfileEntry | null {
nip05?: string; nip05?: string;
picture?: string; picture?: string;
about?: string; about?: string;
website?: string;
lud16?: string;
} = {}; } = {};
try { try {
md = JSON.parse(evt.content); md = JSON.parse(evt.content);
@ -70,6 +74,8 @@ function parseProfileEvent(evt: Event): ProfileEntry | null {
nip05: md.nip05, nip05: md.nip05,
picture: md.picture, picture: md.picture,
about: md.about, about: md.about,
website: md.website,
lud16: md.lud16,
fetchedAt: evt.created_at, fetchedAt: evt.created_at,
}; };
} }
@ -315,6 +321,8 @@ export function ingestNostrUser(user: NostrUser) {
nip05: md.nip05, nip05: md.nip05,
picture: md.picture ?? user.image, picture: md.picture ?? user.image,
about: md.about, about: md.about,
website: md.website,
lud16: md.lud16,
fetchedAt: user.lastUpdated || 0, fetchedAt: user.lastUpdated || 0,
}; };
upsertProfile(entry); upsertProfile(entry);

View file

@ -0,0 +1,125 @@
<script lang="ts">
import * as nip19 from "@nostr/tools/nip19";
import { groupStore } from "$lib/group.svelte";
import {
profileStore,
ensureProfile,
type ProfileEntry,
} from "$lib/profiles.svelte";
const adminPubkeys = $derived(groupStore.data?.admins ?? []);
// Group data is loaded by the layout; profiles stream in reactively.
$effect(() => {
for (const pk of adminPubkeys) ensureProfile(pk);
});
type Contact = { pubkey: string; npub: string; entry?: ProfileEntry };
const contacts = $derived<Contact[]>(
adminPubkeys.map((pk) => {
const entry = profileStore.profiles.get(pk);
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
}),
);
function displayName(c: Contact): string {
return c.entry?.displayName || c.entry?.name || `${c.npub.slice(0, 12)}…`;
}
function websiteHref(url: string): string {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function websiteLabel(url: string): string {
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
}
</script>
<svelte:head>
<title>Contacts</title>
<meta name="description" content="Admins and contacts for this community." />
</svelte:head>
<div class="mx-auto max-w-3xl">
<h1 class="py-2 text-[1.65rem] text-brand">Contacts</h1>
{#if !groupStore.data}
<p class="py-6 text-center text-sm text-neutral-400">Loading…</p>
{:else if contacts.length === 0}
<p class="py-6 text-center text-sm text-neutral-400">No admins listed.</p>
{:else}
<ul class="mt-2 space-y-3">
{#each contacts as c (c.pubkey)}
<li
class="flex gap-4 rounded-lg border border-neutral-100 p-4 shadow-sm"
>
{#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"
>
{displayName(c)[0].toUpperCase()}
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-medium text-neutral-900">
{displayName(c)}
</p>
{#if c.entry?.nip05}
<p class="truncate text-sm text-neutral-400">
{c.entry.nip05}
</p>
{/if}
</div>
<a
href="https://njump.me/{c.npub}"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-sm text-brand hover:underline"
>
View profile ↗
</a>
</div>
{#if c.entry?.about}
<p class="mt-2 text-sm whitespace-pre-line text-neutral-600">
{c.entry.about}
</p>
{/if}
{#if c.entry?.website || c.entry?.lud16}
<div
class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
>
{#if c.entry?.website}
<a
href={websiteHref(c.entry.website)}
target="_blank"
rel="noopener noreferrer"
class="truncate text-brand hover:underline"
>
{websiteLabel(c.entry.website)}
</a>
{/if}
{#if c.entry?.lud16}
<span class="text-neutral-500" title="Lightning address">
{c.entry.lud16}
</span>
{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>