Support resources with kind:30023 articles

This commit is contained in:
dtonon 2026-05-19 15:14:53 +01:00
parent f6bea700f0
commit b901215e1a
13 changed files with 285 additions and 75 deletions

View file

@ -1,5 +1,6 @@
import { SimplePool } from "@nostr/tools";
import { RELAY_URL } from "$lib/config";
import { RELAY_URL, MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte";
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
let byRoom = $state<Record<string, string[]>>({});
@ -15,6 +16,19 @@ export const roomAdminsStore = {
},
};
// Single source of truth for "who is an admin": the union of every room's
// admins in full mode, or the one group's admins in simple mode.
export const adminPubkeys = {
get list(): string[] {
return MODE === "full"
? [...new Set(Object.values(byRoom).flat())]
: (groupStore.data?.admins ?? []);
},
get loaded(): boolean {
return MODE === "full" ? loaded : groupStore.loaded;
},
};
export async function loadRoomAdmins(roomIds: string[]) {
if (roomIds.length === 0) return;
const key = [...roomIds].sort().join(",");

View file

@ -1,8 +1,19 @@
<script lang="ts">
import { overviewStore } from "$lib/overview.svelte";
import { overviewStore, loadOverview } from "$lib/overview.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { MODE, GROUP_ID } from "$lib/config";
import type { NostrUser } from "@nostr/gadgets/metadata";
// Self-load so the panel works wherever it's mounted (home, article pages).
// Simple mode has the single configured group; full mode spans every room.
// loadOverview dedupes on the room-id set, so this is safe alongside the
// landing page's own call.
$effect(() => {
const ids =
MODE === "full" ? groupsStore.list.map((g) => g.id) : [GROUP_ID];
if (ids.length > 0) loadOverview(ids);
});
function roomName(id: string) {
return groupsStore.list.find((g) => g.id === id)?.name ?? id;
}
@ -51,8 +62,10 @@
{author.name[0].toUpperCase()}
</span>
{/if}
<span aria-hidden="true">in</span>
<span class="truncate">{roomName(t.groupId)}</span>
{#if MODE === "full"}
<span aria-hidden="true">in</span>
<span class="truncate">{roomName(t.groupId)}</span>
{/if}
</div>
</a>
</li>

View file

@ -2,6 +2,7 @@
import { auth, openLogin, logout } from "$lib/auth.svelte";
import { groupStore } from "$lib/group.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { resourcesStore } from "$lib/resources.svelte";
import { draftState, resumeDraft } from "$lib/draft.svelte";
type Props = {
@ -54,27 +55,33 @@
</nav>
{#if activeAbout}
<div class="mt-6">
<p
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
>
About
</p>
<p class="text-sm text-neutral-500">{activeAbout}</p>
</div>
{/if}
{/if}
<nav class="flex-auto mt-6">
<div class="">
<p
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
>
More
</p>
<nav class="flex-auto mt-6" aria-label="Resources">
<p
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
>
Resources
</p>
{#each resourcesStore.list as r (r.slug)}
<a
href="/about"
class="block py-1 text-neutral-500 hover:text-neutral-900">About</a
href="/resource/{r.slug}"
class="block py-1 text-neutral-500 hover:text-neutral-900"
>{r.title}</a
>
<a
href="/contacts"
class="block py-1 text-neutral-500 hover:text-neutral-900">Contacts</a
>
</div>
{/each}
<a
href="/contacts"
class="block py-1 text-neutral-500 hover:text-neutral-900">Contacts</a
>
</nav>
</div>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { fly, fade } from "svelte/transition";
import { groupsStore } from "$lib/groups.svelte";
import { resourcesStore } from "$lib/resources.svelte";
import { auth, openLogin, logout } from "$lib/auth.svelte";
import { draftState, resumeDraft } from "$lib/draft.svelte";
@ -115,7 +116,7 @@
{#if mode === "full"}
<nav class="mt-4" aria-label="Rooms">
<p
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
class="pb-1 font-semibold uppercase tracking-wider text-neutral-400"
>
Rooms
</p>
@ -126,7 +127,7 @@
<a
href="/room/{room.id}"
onclick={onClose}
class="block py-1.5 text-lg
class="block py-1.5 text-xl
{activeRoom === room.id ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
>
{room.name}
@ -135,16 +136,27 @@
</nav>
{/if}
<a
href="/about"
onclick={onClose}
class="py-2 text-xl text-neutral-700 hover:text-brand">About</a
>
<a
href="/contacts"
onclick={onClose}
class="py-2 text-xl text-neutral-700 hover:text-brand">Contacts</a
>
<nav class="mt-4" aria-label="Resources">
<p
class="pb-1 font-semibold uppercase tracking-wider text-neutral-400"
>
Resources
</p>
{#each resourcesStore.list as r (r.slug)}
<a
href="/resource/{r.slug}"
onclick={onClose}
class="block py-1.5 text-xl text-neutral-700 hover:text-brand"
>{r.title}</a
>
{/each}
<a
href="/contacts"
onclick={onClose}
class="block py-1.5 text-lg text-neutral-700 hover:text-brand"
>Contacts</a
>
</nav>
<div class="mt-4 flex flex-col gap-2 border-t border-neutral-100 pt-4">
{#if draftState.iconized}
@ -177,9 +189,12 @@
{auth.user.shortName.slice(0, 1).toUpperCase()}
</span>
{/if}
<span class="truncate text-lg font-medium">{auth.user.shortName}</span
<span class="truncate text-lg font-medium"
>{auth.user.shortName}</span
>
<span class="ml-auto shrink-0 text-sm text-neutral-400"
>Log out</span
>
<span class="ml-auto shrink-0 text-sm text-neutral-400">Log out</span>
</button>
{:else}
<button

View file

@ -1,11 +1,15 @@
<script lang="ts">
import { groupStore } from "$lib/group.svelte";
import { GROUP_ID, TITLE } from "$lib/config";
import { GROUP_ID, TITLE, MODE } from "$lib/config";
type Props = { onMenuToggle: () => void };
let { onMenuToggle }: Props = $props();
const name = $derived(TITLE || groupStore.data?.name || GROUP_ID);
// PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's
// own name; full mode has no single room, so it falls back to GROUP_ID.
const name = $derived(
TITLE || (MODE === "simple" ? (groupStore.data?.name ?? GROUP_ID) : GROUP_ID),
);
</script>
<header

View file

@ -15,9 +15,18 @@
content: string;
profiles?: Record<string, NostrUser>;
threadEventAuthors?: Record<string, string>;
// Added to each heading level. Thread posts demote by 1 (the post title is
// a separate h1, so content headings start at h2); standalone articles
// pass 0 to keep their real levels.
headingOffset?: number;
};
let { content, profiles = {}, threadEventAuthors = {} }: Props = $props();
let {
content,
profiles = {},
threadEventAuthors = {},
headingOffset = 1,
}: Props = $props();
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
const TLDS = [
@ -675,7 +684,7 @@
{@render renderBlocks(block.blocks)}
</blockquote>
{:else if block.type === "heading"}
{@const tag = `h${Math.min(block.level + 2, 6)}`}
{@const tag = `h${Math.max(1, Math.min(block.level + headingOffset, 6))}`}
<svelte:element this={tag}>
{@render renderInlines(block.inlines)}
</svelte:element>

View file

@ -11,11 +11,15 @@ export type GroupMetadata = {
};
let group = $state<GroupMetadata | null>(null);
let loaded = $state(false);
export const groupStore = {
get data() {
return group;
},
get loaded() {
return loaded;
},
};
export async function loadGroup() {
@ -40,6 +44,7 @@ export async function loadGroup() {
admins,
};
} finally {
loaded = true;
pool.close([RELAY_URL]);
}
}

View file

@ -0,0 +1,74 @@
import { SimplePool } from "@nostr/tools";
import { RELAY_URL } from "$lib/config";
import { adminPubkeys } from "$lib/admins.svelte";
export type Resource = {
id: string;
slug: string; // NIP-23 `d` tag — also the resource URL slug
title: string;
content: string; // markdown body
position?: number;
pubkey: string;
createdAt: number;
};
let all = $state<Resource[]>([]);
let loaded = $state(false);
export const resourcesStore = {
// Only resources authored by an admin are surfaced; the relay query is
// open, so the trusted admin set is the gate.
get list() {
const admins = new Set(adminPubkeys.list);
return all.filter((r) => admins.has(r.pubkey));
},
get loaded() {
return loaded;
},
};
// Positioned resources come first by ascending `position`; the rest follow
// alphabetically by title. Equal positions fall back to title order.
function compare(a: Resource, b: Resource): number {
const ap = a.position;
const bp = b.position;
if (ap !== undefined && bp !== undefined)
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
if (ap !== undefined) return -1;
if (bp !== undefined) return 1;
return a.title.localeCompare(b.title);
}
// Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"].
export async function loadResources() {
const pool = new SimplePool();
try {
const events = await pool.querySync([RELAY_URL], {
kinds: [30023],
"#t": ["squalk-resource"],
});
// Addressable events: keep the newest per `d` slug.
const bySlug = new Map<string, Resource>();
for (const e of events) {
const slug = e.tags.find((t) => t[0] === "d")?.[1];
if (!slug) continue;
const existing = bySlug.get(slug);
if (existing && existing.createdAt >= e.created_at) continue;
const posTag = e.tags.find((t) => t[0] === "position")?.[1];
const pos = posTag !== undefined ? Number(posTag) : NaN;
bySlug.set(slug, {
id: e.id,
slug,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slug,
content: e.content,
position: Number.isFinite(pos) ? pos : undefined,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
all = [...bySlug.values()].sort(compare);
} finally {
loaded = true;
pool.close([RELAY_URL]);
}
}