Add Contacts page
This commit is contained in:
parent
04ef78b8a5
commit
d4982dff0c
5 changed files with 141 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
125
src/routes/contacts/+page.svelte
Normal file
125
src/routes/contacts/+page.svelte
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue