Back to Browser Side Quest
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.

src/social/profiles.ts
/**
 * 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();
  }
}