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/components/bs-account.ts
import { html } from 'lit';
import { outbox, resource } from '@napplet/sdk';
import { editor } from '../editor-controller';
import { runtimeHasDomain } from '../domain-availability';
import { LightElement } from './light-element';

type Profile = { name: string; picture?: string };
const profiles = new Map<string, Promise<Profile>>();
function loadProfile(viewer: string): Promise<Profile> {
    const cached = profiles.get(viewer);
    if (cached) return cached;
    const request = (async () => {
        const result = await outbox.query([{ kinds: [0], authors: [viewer], limit: 1 }], {
            authors: [viewer],
            timeoutMs: 5000,
        });
        if (result.error) throw new Error(result.error);
        const event = result.events
            .map((r) => r.event)
            .filter((e) => e.kind === 0 && e.pubkey === viewer)
            .sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id))[0];
        const profile = event ? JSON.parse(event.content) : {};
        const text = (value: unknown) =>
            typeof value === 'string' ? value.trim().slice(0, 100) : '';
        return {
            name: text(profile?.display_name) || text(profile?.name),
            picture: typeof profile?.picture === 'string' ? profile.picture : undefined,
        };
    })();
    if (profiles.size >= 16) profiles.delete(profiles.keys().next().value!);
    profiles.set(viewer, request);
    request.catch(() => profiles.delete(viewer));
    return request;
}

/** Public profile metadata via OUTBOX; avatar bytes stay behind RESOURCE. */
export class BsAccount extends LightElement {
    static properties = { name: { state: true }, avatar: { state: true } };
    declare name: string;
    declare avatar?: string;
    #viewer?: string;
    #epoch = 0;
    constructor() {
        super();
        this.name = '';
        this.watch((s) => [s.viewer, s.identityLabel]);
    }
    updated() {
        const viewer = editor.state.viewer;
        if (viewer === this.#viewer) return;
        this.#viewer = viewer;
        const epoch = ++this.#epoch;
        this.name = '';
        this.#clearAvatar();
        if (viewer) void this.#load(viewer, epoch);
    }
    disconnectedCallback() {
        super.disconnectedCallback();
        this.#epoch++;
        this.#viewer = undefined;
        this.#clearAvatar();
    }
    #clearAvatar() {
        if (this.avatar) URL.revokeObjectURL(this.avatar);
        this.avatar = undefined;
    }
    async #load(viewer: string, epoch: number) {
        try {
            if (!runtimeHasDomain('outbox')) return;
            const profile = await loadProfile(viewer);
            if (epoch !== this.#epoch || !this.isConnected) return;
            this.name = profile.name;
            if (!profile.picture || !runtimeHasDomain('resource')) return;
            const blob = await resource.bytes(profile.picture);
            if (epoch !== this.#epoch || !this.isConnected) return;
            if (
                blob.size > 5 * 1024 * 1024 ||
                !['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif'].includes(
                    blob.type,
                )
            )
                return;
            this.avatar = URL.createObjectURL(blob);
        } catch {
            /* Profile and image failures retain a readable account fallback. */
        }
    }
    render() {
        const { viewer, identityLabel } = editor.state;
        if (!viewer) return html`<span class="account-status">${identityLabel}</span>`;
        const current = viewer === this.#viewer;
        const name = (current && this.name) || `${viewer.slice(0, 8)}…${viewer.slice(-4)}`;
        const initials =
            current && this.name
                ? this.name
                      .split(/\s+/)
                      .slice(0, 2)
                      .map((s) => Array.from(s)[0])
                      .join('')
                      .toUpperCase()
                : '?';
        return html`<span class="account-profile" title=${`Signed in as ${name}`}>
            <span class="account-avatar" aria-hidden="true"
                >${current && this.avatar ? html`<img src=${this.avatar} alt="" @error=${() => this.#clearAvatar()} />` : initials}</span
            >
            <span class="account-name">${name}</span>
        </span>`;
    }
}
customElements.define('bs-account', BsAccount);