Back to Black Site Map Editor
SOURCE / PINNED RELEASE

Made of little things.

Black Site Map Editor

Release
32a57733b7e9…
Author-recorded commit
3f54890f542f…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor

Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/map-social.ts
import { outbox } from '@napplet/sdk';
import type { NostrEvent } from '@napplet/sdk';
import type { MapRecord } from './map-host';
import { parseMapEvent } from './vendor/map-event.mjs';

const tagged = (e: NostrEvent, name: string, value: string) =>
    e.tags.some((t) => t[0] === name && t[1] === value);

export async function followedAuthors(viewer: string): Promise<string[]> {
    const result = await outbox.query([{ kinds: [3], authors: [viewer], limit: 1 }], {
        authors: [viewer],
        timeoutMs: 8000,
    });
    if (result.error || result.incomplete)
        throw new Error('Could not load your follows. Please retry.');
    const latest = result.events
        .map((r) => r.event)
        .filter((e) => e.kind === 3 && e.pubkey === viewer)
        .sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id))[0];
    return [
        ...new Set(
            (latest?.tags ?? [])
                .filter((t) => t[0] === 'p' && /^[a-f0-9]{64}$/.test(t[1]))
                .map((t) => t[1]),
        ),
    ];
}

export interface MapDiscussion {
    comments: NostrEvent[];
    reactions: { label: string; count: number }[];
    remixes: MapRecord[];
    notice: string;
}

export async function mapDiscussion(source: MapRecord): Promise<MapDiscussion> {
    const address = source.metadata.address;
    const queries = await Promise.allSettled([
        outbox.query(
            [
                { kinds: [1111], '#A': [address], limit: 100 },
                { kinds: [1111], '#E': [source.event.id], limit: 100 },
            ],
            { limit: 200, timeoutMs: 8000 },
        ),
        outbox.query(
            [
                { kinds: [7], '#a': [address], limit: 200 },
                { kinds: [7], '#e': [source.event.id], limit: 200 },
            ],
            { limit: 400, timeoutMs: 8000 },
        ),
        outbox.query([{ kinds: [35858], '#a': [address], limit: 100 }], {
            limit: 100,
            timeoutMs: 8000,
        }),
    ]);
    let partial = false;
    const rows = queries.map((q) => {
        if (q.status === 'rejected') {
            partial = true;
            return [];
        }
        if (q.value.error || q.value.incomplete) partial = true;
        return [...new Map(q.value.events.map((r) => [r.event.id, r])).values()];
    });
    const comments = rows[0]
        .map((r) => r.event)
        .filter(
            (e) =>
                e.kind === 1111 &&
                tagged(e, 'K', '35858') &&
                (tagged(e, 'A', address) || tagged(e, 'E', source.event.id)),
        )
        .sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
    // Count each person's latest vote per reaction value; repeated relay copies never inflate it.
    const votes = new Map<string, NostrEvent>();
    for (const { event: e } of rows[1]) {
        if (
            e.kind !== 7 ||
            !(
                tagged(e, 'a', address) ||
                e.tags.filter((t) => t[0] === 'e').at(-1)?.[1] === source.event.id
            )
        )
            continue;
        const value = e.content === '' ? '+' : e.content;
        const key = `${e.pubkey}:${value}`;
        const prior = votes.get(key);
        if (!prior || e.created_at > prior.created_at) votes.set(key, e);
    }
    const totals = new Map<string, number>();
    for (const e of votes.values()) {
        const label =
            e.content === '' || e.content === '+'
                ? 'Upvotes'
                : e.content === '-'
                  ? 'Downvotes'
                  : e.content;
        totals.set(label, (totals.get(label) ?? 0) + 1);
    }
    const newest = new Map<string, MapRecord>();
    for (const r of rows[2]) {
        const parsed = parseMapEvent(r.event);
        if (!parsed.ok || parsed.map.lineage?.parent.address !== address) continue;
        const prior = newest.get(parsed.map.address);
        if (
            !prior ||
            r.event.created_at > prior.event.created_at ||
            (r.event.created_at === prior.event.created_at && r.event.id < prior.event.id)
        )
            newest.set(parsed.map.address, {
                event: r.event,
                metadata: parsed.map,
                relayHints: r.sidecar?.relayHints ?? [],
            });
    }
    return {
        comments,
        reactions: [...totals].map(([label, count]) => ({ label, count })),
        remixes: [...newest.values()].sort((a, b) => b.event.created_at - a.event.created_at),
        notice: `${partial ? 'Some activity could not be loaded. Retry to refresh. ' : ''}Activity reflects available relays and may be incomplete (up to 200 comments, 400 reactions and 100 remixes).`,
    };
}

export async function postMapComment(source: MapRecord, content: string) {
    const text = content.trim();
    if (!text || text.length > 4000) throw new Error('Write a comment of 1–4000 characters.');
    const result = await outbox.publish({
        kind: 1111,
        content: text,
        created_at: Math.floor(Date.now() / 1000),
        tags: [
            ['A', source.metadata.address],
            ['K', '35858'],
            ['P', source.event.pubkey],
            ['a', source.metadata.address],
            ['e', source.event.id],
            ['k', '35858'],
            ['p', source.event.pubkey],
        ],
    });
    if (!result.ok || !result.event)
        throw new Error(result.error ?? 'Comment could not be published.');
    return result.event;
}