SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* Nostr identity and profile lookups. Every domain is optional: without
* identity the player types a name; without common/resource the player list
* shows names only. Pubkeys announced by other players are self-claimed.
*/
import {
COMMON_DOMAIN,
IDENTITY_DOMAIN,
RESOURCE_DOMAIN,
common,
identity,
resource,
type Subscription,
} from '@napplet/sdk';
import { runtimeHasDomain } from '../domain-availability.js';
export interface ProfileView {
pubkey: string;
name: string;
npub: string;
avatarUrl: string | null;
}
const HEX64 = /^[0-9a-f]{64}$/;
async function withTimeout<T>(work: () => Promise<T>, ms: number): Promise<T> {
let timer = 0;
try {
return await Promise.race([
work(),
new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error('timed out')), ms);
}),
]);
} finally {
window.clearTimeout(timer);
}
}
export function shortKey(pubkey: string): string {
return pubkey.length > 16 ? `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}` : pubkey;
}
/** Max character name length, as the original game. */
export const MAX_NAME_LENGTH = 15;
/** Make a Nostr display name usable as a character name: one line, no control characters, 15 chars. */
export function characterName(raw: string): string {
const clean = raw
.replace(/\s+/g, ' ')
.replace(/[\p{Cc}\p{Cf}]/gu, '')
.trim();
return Array.from(clean).slice(0, MAX_NAME_LENGTH).join('').trim();
}
/** The best display name in kind-0 metadata (shells may pass `display_name` through raw). */
function profileName(profile: Record<string, unknown> | null | undefined): string {
if (!profile) return '';
for (const key of ['displayName', 'display_name', 'name']) {
const value = profile[key];
if (typeof value === 'string' && value.trim()) return value;
}
const nip05 = profile.nip05;
if (typeof nip05 === 'string' && nip05.includes('@')) {
const local = nip05.split('@')[0];
if (local && local !== '_') return local;
}
return '';
}
async function npubName(pubkey: string): Promise<string> {
if (!runtimeHasDomain(COMMON_DOMAIN)) return '';
try {
const encoded = await withTimeout(() => common.encodeNip19({ type: 'npub', hex: pubkey }), 4000);
return encoded.ok && encoded.value ? encoded.value.slice(0, 13) : '';
} catch {
return '';
}
}
/**
* The signed-in user's pubkey and a character name from their Nostr profile:
* the identity profile, then a common profile lookup, then a short npub, so a
* signed-in player never has to type a name.
*/
export interface NostrIdentity {
pubkey: string;
name: string;
/** True when the name is the short-npub fallback because no profile was found (yet). */
provisional?: boolean;
}
export async function readIdentity(): Promise<NostrIdentity> {
if (!runtimeHasDomain(IDENTITY_DOMAIN)) return { pubkey: '', name: '' };
let pubkey = '';
try {
pubkey = await withTimeout(() => identity.getPublicKey(), 5000);
} catch {
return { pubkey: '', name: '' };
}
if (!pubkey || !HEX64.test(pubkey)) return { pubkey: '', name: '' };
let name = '';
try {
name = characterName(profileName(await withTimeout(() => identity.getProfile(), 5000)));
} catch {
// A profile is a nicety; fall through to the other sources.
}
if (!name && runtimeHasDomain(COMMON_DOMAIN)) {
try {
const result = await withTimeout(() => common.getProfile(pubkey), 8000);
if (result.ok) name = characterName(profileName(result.profile));
} catch {
// keep looking
}
}
if (name) return { pubkey, name };
return { pubkey, name: await npubName(pubkey), provisional: true };
}
export function onIdentityChanged(handler: (pubkey: string) => void): Subscription | null {
if (!runtimeHasDomain(IDENTITY_DOMAIN)) return null;
try {
return identity.onChanged(handler);
} catch {
return null;
}
}
export class ProfileCache {
private readonly views = new Map<string, ProfileView>();
private readonly pending = new Map<string, Promise<ProfileView>>();
private readonly revokers: (() => void)[] = [];
/** Cached view if already known (never triggers a lookup). */
peek(pubkey: string): ProfileView | undefined {
return this.views.get(pubkey);
}
get(pubkey: string): Promise<ProfileView> {
const known = this.views.get(pubkey);
if (known) return Promise.resolve(known);
const inflight = this.pending.get(pubkey);
if (inflight) return inflight;
const work = this.lookup(pubkey).finally(() => this.pending.delete(pubkey));
this.pending.set(pubkey, work);
return work;
}
private async lookup(pubkey: string): Promise<ProfileView> {
const view: ProfileView = { pubkey, name: '', npub: shortKey(pubkey), avatarUrl: null };
if (!HEX64.test(pubkey)) return view;
if (runtimeHasDomain(COMMON_DOMAIN)) {
try {
const encoded = await withTimeout(() => common.encodeNip19({ type: 'npub', hex: pubkey }), 4000);
if (encoded.ok && encoded.value) view.npub = shortKey(encoded.value);
} catch {
// keep the short hex
}
try {
const result = await withTimeout(() => common.getProfile(pubkey), 8000);
const profile = result.ok ? result.profile : null;
if (profile) {
view.name = profileName(profile).trim().slice(0, 40);
const picture = typeof profile.picture === 'string' ? profile.picture : '';
if (picture && runtimeHasDomain(RESOURCE_DOMAIN) && /^https:\/\//.test(picture)) {
try {
const handle = resource.bytesAsObjectURL(picture);
view.avatarUrl = handle.url;
this.revokers.push(handle.revoke);
} catch {
// No avatar; the list falls back to a placeholder.
}
}
}
} catch {
// Profile lookups are best-effort.
}
}
this.views.set(pubkey, view);
return view;
}
dispose(): void {
for (const revoke of this.revokers) revoke();
this.revokers.length = 0;
this.views.clear();
}
}
