Back to mini-nostr-saf
SOURCE / PINNED RELEASE

Made of little things.

mini-nostr-saf

Release
72c0506a4a1b…
Author-recorded commit
cec3876ccbdc…
License
LICENSE
Author’s source reference
nostr://npub1whs094x2p7qshzqn77uwm0kx0826f8xdad5ppqet7w9jr6ay5scs99c7wj/wss%3A%2F%2Fgit.napplet.soy%2F/n-599618f6842

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

src/App.tsx
import { SafeAreaTopScrim } from "./space-shim";
import { isNapplet } from "./napplet-env";
import { createRelaySocket, NappletRelaySocket } from "./napplet-relay";
import { storageGetItem, storageSetItem, storageRemoveItem } from "./napplet-storage";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type ReactNode,
type CSSProperties,
} from "react";
import {
Nip46AuthRequiredError,
NostrConnectClient,
parseBunkerUri,
type Nip46Session,
type SignedNostrEvent,
type UnsignedEventDraft,
} from "./nostrconnect";

/**
 * Relay socket: host-mediated NappletRelaySocket. Raw WebSocket is a
 * forbidden surface in the napplet sandbox, so there is no fallback.
 */
type RelaySocket = NappletRelaySocket;
const WS_CONNECTING = 0;
const WS_OPEN = 1;

function createSocket(url: string): RelaySocket {
  return createRelaySocket(url);
}

/** 本地持久化的 NIP-46 会话键。 */
const NIP46_SESSION_KEY = "nostr-min-nip46-session-v1";

/** 统一签名器接口:NIP-07 浏览器扩展、NIP-46 远程签名器与 napplet host 身份都实现它。 */
type AppSigner = {
  readonly signerType: "nip07" | "nip46" | "napplet";
  getPublicKey(): Promise<string>;
  signEvent(draft: UnsignedEventDraft): Promise<SignedNostrEvent>;
};

function loadNip46Session(): Nip46Session | null {
  try {
    const raw = storageGetItem(NIP46_SESSION_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw) as Partial<Nip46Session>;
    if (
      typeof parsed.clientSecretHex !== "string" ||
      typeof parsed.remotePubkey !== "string" ||
      typeof parsed.relayUrl !== "string"
    ) {
      return null;
    }
    return {
      clientSecretHex: parsed.clientSecretHex,
      remotePubkey: parsed.remotePubkey,
      relayUrl: parsed.relayUrl,
      userPubkey: typeof parsed.userPubkey === "string" ? parsed.userPubkey : null,
    };
  } catch {
    return null;
  }
}

type RelayState = "connecting" | "online" | "offline";

type RelayConfig = {
  url: string;
  enabled: boolean;
};

type NostrEvent = {
  id: string;
  pubkey: string;
  created_at: number;
  kind: number;
  tags: string[][];
  content: string;
  sig: string;
  relays: string[];
};

type SignedEvent = Omit<NostrEvent, "relays">;

type PublishStatus = {
  eventId: string;
  total: number;
  accepted: number;
  rejected: number;
  pending: number;
};

/** 作者页单独拉取的历史帖子:与主时间线的 MAX_EVENTS 上限隔离。 */
type AuthorHistory = {
  pubkey: string;
  events: NostrEvent[];
  loading: boolean;
  loadingMore: boolean;
  hasMore: boolean;
};

/** 进行中的作者历史请求:跟踪每个订阅 id 收到的条数与 EOSE。 */
type AuthorHistoryRequest = {
  pubkey: string;
  subIds: Set<string>;
  counts: Map<string, number>;
  mode: "initial" | "more";
};

/** 帖子详情里的对话 thread:该帖子的直接回复(kind-1 带 e 标签),按时间正序。 */
type ThreadReplies = {
  eventId: string;
  events: NostrEvent[];
  loading: boolean;
};

/** 进行中的 thread 拉取请求:跟踪每个订阅 id 的 EOSE。 */
type ThreadRequest = {
  eventId: string;
  subIds: Set<string>;
};

type Profile = {
  name?: string;
  display_name?: string;
  about?: string;
  picture?: string;
  nip05?: string;
  website?: string;
  lud16?: string;
};

type ProfileEntry = {
  profile: Profile;
  created_at: number;
  fetched_at: number;
};

type ContactList = {
  tags: string[][];
  content: string;
  created_at: number;
};

const DEFAULT_RELAYS: RelayConfig[] = [
  { url: "wss://lulin.org", enabled: true },
  { url: "wss://relay.gulugulu.moe", enabled: true },
  { url: "wss://relay.nostr.wirednet.jp", enabled: true },
  { url: "wss://relay-jp.nostr.wirednet.jp", enabled: true },
];

const RELAY_STORAGE_KEY = "nostr-min-relays-v1";
const PROFILE_STORAGE_KEY = "nostr-min-profiles-v1";
const FOLLOWS_STORAGE_KEY = "nostr-min-follows-v1";
const LOCAL_FOLLOWS_STORAGE_KEY = "nostr-min-follows-local-v1";
const EVENTS_STORAGE_KEY = "nostr-min-events-v1";
const PROFILE_TTL_MS = 24 * 60 * 60 * 1000;
export const MAX_EVENTS = 120;

// Persisted feed: after the first successful load the latest notes survive
// browser restarts and render instantly (even offline) before relays
// reconnect. Live events merge over the cached ones by id.
export function loadCachedEvents(): NostrEvent[] {
  try {
    const raw = storageGetItem(EVENTS_STORAGE_KEY);
    if (!raw) return [];
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed
      .filter(
        (entry): entry is NostrEvent =>
          !!entry &&
          typeof entry === "object" &&
          (entry as { kind?: unknown }).kind === 1 &&
          typeof (entry as { id?: unknown }).id === "string" &&
          typeof (entry as { pubkey?: unknown }).pubkey === "string" &&
          typeof (entry as { content?: unknown }).content === "string" &&
          typeof (entry as { created_at?: unknown }).created_at === "number",
      )
      .map((entry) => ({
        ...entry,
        tags: Array.isArray(entry.tags) ? entry.tags : [],
        relays: Array.isArray(entry.relays) ? entry.relays : [],
      }))
      .slice(0, MAX_EVENTS);
  } catch {
    return [];
  }
}

export function saveCachedEvents(events: NostrEvent[]): void {
  try {
    storageSetItem(EVENTS_STORAGE_KEY, JSON.stringify(events.slice(0, MAX_EVENTS)));
  } catch {
    // quota exceeded or private mode — the live feed still works
  }
}

// ---- 浏览模式:自动网格 / 手动 ----

export type ViewMode = "auto" | "manual";

const VIEW_MODE_STORAGE_KEY = "nostr-min-view-mode-v1";

/** 自动网格:卡片最小宽度(px),必须与 .feed-list 的 minmax 下限一致。 */
export const AUTO_GRID_CARD_MIN_W = 300;
/** 自动网格:卡片固定高度(px),必须与 .note 的 height 一致。 */
export const AUTO_GRID_CARD_H = 340;
/** 自动网格:卡片间距(px),必须与 .feed-list 的 gap 一致。 */
export const AUTO_GRID_GAP = 12;
/** 窄屏阈值:舞台宽度小于此值时,自动网格竖分三列、显示块等比缩小。 */
export const AUTO_GRID_NARROW_W = 700;
/** 窄屏列数:屏幕竖分三块。 */
export const AUTO_GRID_NARROW_COLS = 3;
/** 窄屏下限:舞台宽度小于此值视为尚未布局完成,不启用窄屏模式。 */
export const AUTO_GRID_NARROW_MIN_W = 240;

export interface AutoGridTile { cardH: number; scale: number; narrow: boolean }

/**
 * 按舞台宽度算出自动网格的显示块尺寸。
 * 窄屏(手机)时竖分三列,卡片高度按列宽相对 300px 的比例等比缩小;
 * 缩放系数经 --auto-scale 注入 CSS,块内的头像/字号/边距同步缩小。
 */
export function autoGridTile(stageW: number): AutoGridTile {
  if (stageW >= AUTO_GRID_NARROW_MIN_W && stageW < AUTO_GRID_NARROW_W) {
    const cardW = (stageW - (AUTO_GRID_NARROW_COLS - 1) * AUTO_GRID_GAP) / AUTO_GRID_NARROW_COLS;
    const scale = cardW / AUTO_GRID_CARD_MIN_W;
    return { cardH: Math.round(AUTO_GRID_CARD_H * scale), scale, narrow: true };
  }
  return { cardH: AUTO_GRID_CARD_H, scale: 1, narrow: false };
}
/**
 * 按舞台实际尺寸算出能完整放下的列数/行数。
 * 列数公式与 .feed-list 的 auto-fill 口径一致,避免渲染出放不下的半行;
 * 窄屏时固定竖分三列,行数按缩小后的卡片高度重算。
 */
export function autoGridCapacity(stageW: number, stageH: number): { cols: number; rows: number; count: number } {
  const tile = autoGridTile(stageW);
  if (tile.narrow) {
    const cols = AUTO_GRID_NARROW_COLS;
    const rows = Math.max(1, Math.floor((stageH + AUTO_GRID_GAP) / (tile.cardH + AUTO_GRID_GAP)));
    return { cols, rows, count: cols * rows };
  }
  const cols = Math.max(1, Math.floor((stageW + AUTO_GRID_GAP) / (AUTO_GRID_CARD_MIN_W + AUTO_GRID_GAP)));
  const rows = Math.max(1, Math.floor((stageH + AUTO_GRID_GAP) / (AUTO_GRID_CARD_H + AUTO_GRID_GAP)));
  return { cols, rows, count: cols * rows };
}

/**
 * 自动网格始终取时间倒序列表的前 count 条:最新帖在左上角,其余帖子按
 * 从左到右、从上到下顺排,最早的一条位于最后一个已占用格。新帖到达时
 * React 直接重排整个窗口,不播放动画,也不会循环回绕破坏时间顺序。
 */
export function autoGridWindow<T>(items: T[], count: number): T[] {
  if (items.length === 0 || count <= 0) return [];
  return items.slice(0, count);
}

declare const __APP_VERSION__: string | undefined;
/**
 * 应用版本号:构建时由 build.mjs 经 `git describe` 注入;
 * 未经构建流程(如 artifact 预览)时回退为 "dev"。
 */
export const APP_VERSION =
  typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__ ? __APP_VERSION__ : "dev";

declare const __SITE_NAME__: string | undefined;
/**
 * 网站名:构建时由 build.mjs 从 site.config.json 注入;
 * 未经构建流程(如 artifact 预览)时回退为 "绿野仙踪"。
 */
export const SITE_NAME =
  typeof __SITE_NAME__ !== "undefined" && __SITE_NAME__ ? __SITE_NAME__ : "绿野仙踪";

export interface AppUpdateEnv {
  caches?: { keys(): Promise<string[]>; delete(name: string): Promise<boolean> };
  getServiceWorkerRegistrations?: () => Promise<readonly { unregister(): Promise<boolean> }[]>;
  /**
   * 刷新浏览器 HTTP 缓存中的当前文档。index.html 带有 Cache-Control: max-age,
   * 直接 location.reload() 会命中 HTTP 缓存里的旧 HTML,导致“更新无效”。
   */
  refreshDocument?: () => Promise<void>;
  reload: () => void;
}

/**
 * “版本更新”按钮:清掉全部 Cache Storage 与已注册的 Service Worker,
 * 再强制走网络刷新 HTTP 缓存中的 index.html,最后重载页面,
 * 把应用完整重新下载一遍。
 * 无论清理成败最后都会重载,避免按钮点下没反应。
 */
export async function forceAppUpdate(env: AppUpdateEnv): Promise<void> {
  try {
    if (env.caches) {
      const keys = await env.caches.keys();
      await Promise.all(keys.map((key) => env.caches!.delete(key)));
    }
    if (env.getServiceWorkerRegistrations) {
      const regs = await env.getServiceWorkerRegistrations();
      await Promise.all(regs.map((reg) => reg.unregister()));
    }
    // max-age 下 reload() 会直接命中旧 HTML:先用 cache:"reload" 强制走网络,
    // 把 HTTP 缓存条目更新为最新 index.html,再 reload 才能真正拿到新版本。
    // (SW 已在上一步注销,且 Cache Storage 已清空,即使旧 SW 仍拦截这次
    // fetch 也会因缓存未命中而走网络,request 的 cache 模式会一并透传。)
    if (env.refreshDocument) await env.refreshDocument();
  } catch {
    // 清理失败不阻塞:照样重载,避免按钮点下没反应。
  }
  env.reload();
}

/** forceAppUpdate 在浏览器里的默认环境。Napplet 版:更新由 host/重新发布负责,应用内不做文档刷新。 */
export function defaultUpdateEnv(): AppUpdateEnv {
  const inBrowser = typeof window !== "undefined";
  return {
    caches: inBrowser && "caches" in window ? window.caches : undefined,
    getServiceWorkerRegistrations:
      typeof navigator !== "undefined" && "serviceWorker" in navigator
        ? () => navigator.serviceWorker.getRegistrations()
        : undefined,
    // Napplet 沙箱禁止 raw fetch;版本更新走重新发布,不在应用内刷新文档。
    refreshDocument: undefined,
    reload: () => window.location.reload(),
  };
}

export function loadViewMode(): ViewMode {
  try {
    return storageGetItem(VIEW_MODE_STORAGE_KEY) === "manual" ? "manual" : "auto";
  } catch {
    return "auto";
  }
}

export function saveViewMode(mode: ViewMode): void {
  try {
    storageSetItem(VIEW_MODE_STORAGE_KEY, mode);
  } catch {
    // private mode — keep the choice in memory only
  }
}

const INCOGNITO_MODE_STORAGE_KEY = "nostr-min-incognito-v1";

/** 隐身模式:true = 不自动加载远程头像;缺省 false(普通模式,自动加载)。 */
export function loadIncognitoMode(): boolean {
  try {
    return storageGetItem(INCOGNITO_MODE_STORAGE_KEY) === "1";
  } catch {
    return false;
  }
}

export function saveIncognitoMode(enabled: boolean): void {
  try {
    storageSetItem(INCOGNITO_MODE_STORAGE_KEY, enabled ? "1" : "0");
  } catch {
    // private mode — keep the choice in memory only
  }
}

// ---- 本地筛选:隐藏回复 / 关键词屏蔽(只看关注已有 全部/关注 标签页)----

export type FeedFilters = {
  hideReplies: boolean;
  mutedKeywords: string[];
};

const FILTERS_STORAGE_KEY = "nostr-min-filters-v1";

const DEFAULT_FILTERS: FeedFilters = { hideReplies: false, mutedKeywords: [] };

export function loadFilters(): FeedFilters {
  try {
    const raw = storageGetItem(FILTERS_STORAGE_KEY);
    if (!raw) return { ...DEFAULT_FILTERS, mutedKeywords: [] };
    const parsed = JSON.parse(raw) as Partial<FeedFilters>;
    return {
      hideReplies: parsed.hideReplies === true,
      mutedKeywords: Array.isArray(parsed.mutedKeywords)
        ? parsed.mutedKeywords
            .filter((keyword): keyword is string => typeof keyword === "string" && keyword.trim().length > 0)
            .map((keyword) => keyword.trim())
            .slice(0, 50)
        : [],
    };
  } catch {
    return { ...DEFAULT_FILTERS, mutedKeywords: [] };
  }
}

export function saveFilters(filters: FeedFilters): void {
  try {
    storageSetItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
  } catch {
    // private mode — keep the choice in memory only
  }
}

/** NIP-10:带有效 e 标签的 kind-1 视为回复(参与了某个帖子串)。 */
export function isReplyEvent(event: NostrEvent): boolean {
  return (event.tags ?? []).some((tag) => tag[0] === "e" && typeof tag[1] === "string" && tag[1].length > 0);
}

export function filtersActive(filters: FeedFilters): boolean {
  return filters.hideReplies || filters.mutedKeywords.length > 0;
}

/**
 * 本地筛选帖子:隐藏回复 / 屏蔽关键词(大小写不敏感的子串匹配)。
 * 纯本地过滤,不改变订阅与网络行为。
 */
export function applyFilters(events: NostrEvent[], filters: FeedFilters): NostrEvent[] {
  if (!filtersActive(filters)) return events;
  const keywords = filters.mutedKeywords.map((keyword) => keyword.toLowerCase());
  return events.filter((event) => {
    if (event.kind === LONGFORM_KIND) {
      // 长文没有回复概念;关键词对标题、摘要、正文都生效。
      if (keywords.length === 0) return true;
      const meta = parseLongformMeta(event);
      const haystack = `${meta.title}\n${meta.summary}\n${event.content}`.toLowerCase();
      return !keywords.some((keyword) => haystack.includes(keyword));
    }
    if (filters.hideReplies && isReplyEvent(event)) return false;
    if (keywords.length > 0) {
      const content = event.content.toLowerCase();
      if (keywords.some((keyword) => content.includes(keyword))) return false;
    }
    return true;
  });
}

/** 作者页历史拉取:每次最多取这么多条,relay 有更多时用 until 分页。 */
export const AUTHOR_HISTORY_LIMIT = 200;

/** 作者历史订阅的过滤器:按作者拉 kind-1;until 用于分页加载更早的帖子。 */
export function buildAuthorHistoryFilter(
  pubkey: string,
  until?: number,
): { kinds: number[]; authors: string[]; limit: number; until?: number } {
  const filter: { kinds: number[]; authors: string[]; limit: number; until?: number } = {
    kinds: [1],
    authors: [pubkey],
    limit: AUTHOR_HISTORY_LIMIT,
  };
  if (typeof until === "number") filter.until = until;
  return filter;
}

/** 合并作者历史帖子:按 id 去重,按时间倒序;不改动传入的数组。 */
export function mergeHistoryEvents(current: NostrEvent[], incoming: NostrEvent[]): NostrEvent[] {
  const seen = new Set<string>();
  const merged: NostrEvent[] = [];
  for (const event of [...current, ...incoming]) {
    if (seen.has(event.id)) continue;
    seen.add(event.id);
    merged.push(event);
  }
  merged.sort((a, b) => b.created_at - a.created_at);
  return merged;
}

/** 对话 thread 拉取:每个帖子最多取这么多条直接回复。 */
export const THREAD_REPLIES_LIMIT = 200;

/** Thread 订阅的过滤器:按 e 标签拉指向该帖子的 kind-1 回复。 */
export function buildThreadFilter(eventId: string): { kinds: number[]; "#e": string[]; limit: number } {
  return { kinds: [1], "#e": [eventId], limit: THREAD_REPLIES_LIMIT };
}

/** 合并 thread 回复:按 id 去重,按时间正序(对话从早到晚);不改动传入的数组。 */
export function mergeThreadEvents(current: NostrEvent[], incoming: NostrEvent[]): NostrEvent[] {
  const seen = new Set<string>();
  const merged: NostrEvent[] = [];
  for (const event of [...current, ...incoming]) {
    if (seen.has(event.id)) continue;
    seen.add(event.id);
    merged.push(event);
  }
  merged.sort((a, b) => a.created_at - b.created_at);
  return merged;
}

/** 关注人帖子订阅:每个中继按关注列表取最近这么多条 kind-1。 */
export const FOLLOW_POSTS_LIMIT = 30;

/** 关注人帖子在本地最多保留这么多条。 */
export const MAX_FOLLOW_EVENTS = 300;

/** 关注人订阅的过滤器:按关注列表的作者拉 kind-1。 */
export function buildFollowFilter(authors: string[]): { kinds: number[]; authors: string[]; limit: number } {
  return { kinds: [1], authors, limit: FOLLOW_POSTS_LIMIT };
}

/**
 * 关注标签页的帖子:当前信息流里关注人的帖子 + 关注人专属订阅拉到的帖子,
 * 按 id 去重、按时间倒序。专属订阅保证关注的人即使很久没出现在公共信息流里,
 * 他的最近帖子也能显示;不过滤掉已取消关注的人。
 */
export function mergeFollowingFeed(
  feedEvents: NostrEvent[],
  followEvents: NostrEvent[],
  follows: string[],
): NostrEvent[] {
  const isFollowed = (event: NostrEvent) => follows.includes(event.pubkey);
  return mergeHistoryEvents(
    feedEvents.filter(isFollowed),
    followEvents.filter(isFollowed),
  );
}

/** NIP-23 长文事件 kind。 */
export const LONGFORM_KIND = 30023;
/** 长文订阅每源最多取这么多条。 */
export const LONGFORM_LIMIT = 20;
/** 本地缓存的长文上限。 */
export const MAX_LONGFORMS = 40;
const LONGFORM_STORAGE_KEY = "nostr-min-longforms-v1";

export type LongformMeta = {
  title: string;
  summary: string;
  image: string;
  publishedAt: number | null;
  identifier: string;
};

function longformTagValue(event: NostrEvent, name: string): string {
  const tag = event.tags.find((tag) => tag[0] === name && typeof tag[1] === "string");
  return tag?.[1] ?? "";
}

/** NIP-23 长文元数据:从标签取 title / summary / image / published_at / d。 */
export function parseLongformMeta(event: NostrEvent): LongformMeta {
  const publishedRaw = longformTagValue(event, "published_at");
  const publishedNum = publishedRaw ? Number(publishedRaw) : NaN;
  return {
    title: longformTagValue(event, "title"),
    summary: longformTagValue(event, "summary"),
    image: longformTagValue(event, "image"),
    publishedAt: Number.isFinite(publishedNum) ? publishedNum : null,
    identifier: longformTagValue(event, "d"),
  };
}

/** NIP-33 可替换事件键:同 kind + pubkey + d 标签只保留最新一条。 */
export function longformKey(event: NostrEvent): string {
  return `${event.kind}:${event.pubkey}:${longformTagValue(event, "d")}`;
}

/**
 * 按 id 在多个事件源里找帖子(详情页解析用)。
 * 帖子可能只存在于某个专属订阅里(关注专属订阅、作者历史),
 * 不在主 events 中;这里按优先级依次查找。
 */
export function findNoteById(
  id: string,
  sources: Array<NostrEvent[] | ReadonlyMap<string, NostrEvent> | null | undefined>,
): NostrEvent | null {
  for (const source of sources) {
    if (!source) continue;
    if (Array.isArray(source)) {
      const found = source.find((event) => event.id === id);
      if (found) return found;
    } else {
      const found = source.get(id);
      if (found) return found;
    }
  }
  return null;
}

/** 合并长文:按 longformKey 去重,同键只保留 created_at 最大的;按时间倒序。 */
export function mergeLongformEvents(current: NostrEvent[], incoming: NostrEvent[]): NostrEvent[] {
  const byKey = new Map<string, NostrEvent>();
  for (const event of [...current, ...incoming]) {
    const key = longformKey(event);
    const existing = byKey.get(key);
    if (!existing || event.created_at > existing.created_at) {
      const relays = existing && event.created_at === existing.created_at
        ? [...new Set([...existing.relays, ...event.relays])]
        : event.relays;
      byKey.set(key, { ...event, relays });
    } else if (!existing.relays.includes(event.relays[0] ?? "")) {
      byKey.set(key, { ...existing, relays: [...existing.relays, ...event.relays] });
    }
  }
  return [...byKey.values()].sort((a, b) => b.created_at - a.created_at);
}

/** 长文摘要:优先用 summary 标签,否则取正文前 140 字。 */
export function longformExcerpt(event: NostrEvent): string {
  const meta = parseLongformMeta(event);
  if (meta.summary) return meta.summary;
  const text = event.content.replace(/\s+/g, " ").trim();
  return text.length > 140 ? `${text.slice(0, 140)}…` : text;
}

export function loadCachedLongforms(): NostrEvent[] {
  try {
    const raw = storageGetItem(LONGFORM_STORAGE_KEY);
    if (!raw) return [];
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed
      .filter(
        (entry): entry is NostrEvent =>
          !!entry &&
          typeof entry === "object" &&
          (entry as { kind?: unknown }).kind === LONGFORM_KIND &&
          typeof (entry as { id?: unknown }).id === "string" &&
          typeof (entry as { pubkey?: unknown }).pubkey === "string" &&
          typeof (entry as { content?: unknown }).content === "string" &&
          typeof (entry as { created_at?: unknown }).created_at === "number",
      )
      .map((entry) => ({
        ...entry,
        tags: Array.isArray(entry.tags) ? entry.tags : [],
        relays: Array.isArray(entry.relays) ? entry.relays : [],
      }))
      .slice(0, MAX_LONGFORMS);
  } catch {
    return [];
  }
}

export function saveCachedLongforms(events: NostrEvent[]): void {
  try {
    storageSetItem(LONGFORM_STORAGE_KEY, JSON.stringify(events.slice(0, MAX_LONGFORMS)));
  } catch {
    // quota exceeded or private mode — the live feed still works
  }
}

/** 转义 HTML:渲染器只输出白名单标签,先把原文全部转义再组装。 */
function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

/** 只允许 http(s) 链接与图片,其他协议一律按纯文本处理。 */
export function isSafeHttpUrl(url: string): boolean {
  return /^https?:\/\/[^\s"'<>]+$/i.test(url.trim());
}

/** 行内 markdown:行内代码、图片、链接、裸 URL、粗体、斜体。输入须已转义。 */
function renderLongformInline(source: string, allowImages: boolean): string {
  const slots: string[] = [];
  const stash = (html: string): string => {
    slots.push(html);
    return `\ue000${slots.length - 1}\ue000`;
  };
  let out = source;
  // 行内代码优先,避免其中的 markdown 语法被解释。
  out = out.replace(/`([^`\n]+)`/g, (_match, code: string) => stash(`<code>${code}</code>`));
  // 图片:隐身模式下不自动加载,只给链接。
  out = out.replace(/!\[([^\]\n]*)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, (_match, alt: string, url: string) => {
    if (!isSafeHttpUrl(url)) return `![${alt}](${url})`;
    return allowImages
      ? stash(`<img src="${url}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer">`)
      : stash(`<a href="${url}" target="_blank" rel="noreferrer">🖼 图片(隐身模式未加载)</a>`);
  });
  // 链接
  out = out.replace(/\[([^\]\n]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, (_match, text: string, url: string) => {
    if (!isSafeHttpUrl(url)) return text;
    return stash(`<a href="${url}" target="_blank" rel="noreferrer">${text}</a>`);
  });
  // 裸 URL 自动成链:中英文标点不算 URL 的一部分
  out = out.replace(/(^|[\s((>])((?:https?:\/\/)[^\s<>"))。,;:?!、」』\]]+)/gi, (_match, pre: string, url: string) => {
    const clean = url.replace(/[。,;:?!、」』),.!?;:)\]}]+$/, "");
    const trail = url.slice(clean.length);
    return `${pre}${stash(`<a href="${clean}" target="_blank" rel="noreferrer">${clean}</a>`)}${trail}`;
  });
  // 粗体、斜体
  out = out.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
  out = out.replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
  return out.replace(/\ue000(\d+)\ue000/g, (_match, index: string) => slots[Number(index)] ?? "");
}

function isLongformBlockStart(line: string): boolean {
  return (
    /^\s*```/.test(line) ||
    /^(#{1,6})\s+/.test(line) ||
    /^\s*([-*_]\s*){3,}$/.test(line) ||
    /^\s*&gt;/.test(line) ||
    /^(\s*)([-*+]|\d+[.)])\s+/.test(line)
  );
}

/**
 * 长文 markdown 子集渲染(标题、分隔线、引用、列表、代码围栏、段落)。
 * 先转义全部 HTML,只输出白名单标签;链接与图片只放行 http(s)。
 * 表格等复杂语法暂不支持,会按段落原文显示。
 */
export function renderMarkdownHtml(source: string, allowImages: boolean): string {
  // 统一换行:CRLF 的 \r 会残留在行尾,而 JS 的 . 不匹配 \r,
  // 会导致带 $ 锚点的块正则失效、行循环无法推进。先全部转成 \n。
  const lines = escapeHtml(source).replace(/\r\n?/g, "\n").split("\n");
  const blocks: string[] = [];
  let i = 0;
  while (i < lines.length) {
    const line = lines[i] ?? "";
    // 代码围栏
    if (/^\s*```/.test(line)) {
      const code: string[] = [];
      i++;
      while (i < lines.length && !/^\s*```/.test(lines[i] ?? "")) {
        code.push(lines[i] ?? "");
        i++;
      }
      i++;
      blocks.push(`<pre><code>${code.join("\n")}</code></pre>`);
      continue;
    }
    // 标题
    const heading = line.match(/^(#{1,6})\s+(.*)$/);
    if (heading && heading[1] && heading[2] !== undefined) {
      const level = heading[1].length;
      blocks.push(`<h${level}>${renderLongformInline(heading[2], allowImages)}</h${level}>`);
      i++;
      continue;
    }
    // 分隔线
    if (/^\s*([-*_]\s*){3,}$/.test(line)) {
      blocks.push("<hr>");
      i++;
      continue;
    }
    // 引用
    if (/^\s*&gt;/.test(line)) {
      const quoted: string[] = [];
      while (i < lines.length && /^\s*&gt;/.test(lines[i] ?? "")) {
        quoted.push((lines[i] ?? "").replace(/^\s*&gt;\s?/, ""));
        i++;
      }
      blocks.push(`<blockquote>${renderLongformInline(quoted.join("<br>"), allowImages)}</blockquote>`);
      continue;
    }
    // 列表
    const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
    if (listMatch && listMatch[2] && listMatch[3] !== undefined) {
      const ordered = /^\d/.test(listMatch[2]);
      const items: string[] = [];
      let itemMatch: RegExpMatchArray | null;
      while (i < lines.length && (itemMatch = (lines[i] ?? "").match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/))) {
        items.push(`<li>${renderLongformInline(itemMatch[3] ?? "", allowImages)}</li>`);
        i++;
      }
      blocks.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`);
      continue;
    }
    // 空行:段落分隔
    if (/^\s*$/.test(line)) {
      i++;
      continue;
    }
    // 段落:连续非空行合并,行内换行保留为 <br>
    const paragraph: string[] = [];
    while (i < lines.length && !/^\s*$/.test(lines[i] ?? "") && !isLongformBlockStart(lines[i] ?? "")) {
      paragraph.push(lines[i] ?? "");
      i++;
    }
    if (paragraph.length > 0) {
      blocks.push(`<p>${renderLongformInline(paragraph.join("<br>"), allowImages)}</p>`);
    } else {
      // 兜底:任何行都必须推进,避免未知输入造成死循环。
      i++;
    }
  }
  return blocks.join("\n");
}

/** NIP-10 回复标签:e 标签带资讯源提示与 reply 标记,p 标签指向原作者。 */
export function buildReplyTags(parent: { id: string; pubkey: string; relays: string[] }): string[][] {
  const hint = parent.relays.find((relay) => relay.startsWith("ws")) ?? "";
  return [
    ["e", parent.id, hint, "reply"],
    ["p", parent.pubkey],
  ];
}

function createUuid(): string {
  if (typeof crypto.randomUUID === "function") return crypto.randomUUID();

  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
  bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
  const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
  return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}

export function loadRelays(): RelayConfig[] {
  try {
    const raw = storageGetItem(RELAY_STORAGE_KEY);
    if (!raw) return DEFAULT_RELAYS;
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed)) return DEFAULT_RELAYS;
    const valid = parsed.filter(
      (entry): entry is RelayConfig =>
        typeof entry === "object" &&
        entry !== null &&
        typeof (entry as RelayConfig).url === "string" &&
        typeof (entry as RelayConfig).enabled === "boolean",
    );
    if (valid.length === 0) return DEFAULT_RELAYS;

    // Carry the community relay into existing installations without
    // resetting any relay choices the viewer has already saved. The old
    // ws://lulin.org entry is respected as-is — no forced migration.
    if (!valid.some((relay) => relay.url === "wss://lulin.org" || relay.url === "ws://lulin.org")) {
      return [{ url: "wss://lulin.org", enabled: true }, ...valid];
    }
    return valid;
  } catch {
    return DEFAULT_RELAYS;
  }
}

/** 每日推荐中继:抓取逻辑见 src/relay-picks.ts(可注入抓取器、带超时、永不悬挂)。 */
import {
  RELAY_PICKS_URL,
  relayPicksUrl,
  isRelayPicks,
  fetchRelayPicks,
} from "./relay-picks.js";
import type { RelayPick, RelayPicks } from "./relay-picks.js";
export { RELAY_PICKS_URL, relayPicksUrl, isRelayPicks, fetchRelayPicks };
export type { RelayPick, RelayPicks };

export function normalizeRelay(value: string): string | null {
  const trimmed = value.trim();
  try {
    const parsed = new URL(trimmed);
    if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") return null;
    if (!parsed.hostname) return null;
    parsed.hash = "";
    parsed.search = "";
    const normalized = parsed.toString();
    return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
  } catch {
    return null;
  }
}

export function shortKey(value: string): string {
  if (value.length <= 16) return value;
  return `${value.slice(0, 8)}…${value.slice(-6)}`;
}

const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";

function bech32Polymod(values: number[]): number {
  const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
  let checksum = 1;
  for (const value of values) {
    const top = checksum >>> 25;
    checksum = ((checksum & 0x1ffffff) << 5) ^ value;
    generators.forEach((generator, index) => {
      if (((top >>> index) & 1) === 1) checksum ^= generator;
    });
  }
  return checksum;
}

export function encodeNip19(prefix: "npub" | "note", hex: string): string {
  if (!/^[0-9a-f]{64}$/i.test(hex)) return hex;
  const bytes = Array.from({ length: 32 }, (_, index) => Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16));
  const words: number[] = [];
  let accumulator = 0;
  let bits = 0;
  for (const byte of bytes) {
    accumulator = (accumulator << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      bits -= 5;
      words.push((accumulator >>> bits) & 31);
    }
  }
  if (bits > 0) words.push((accumulator << (5 - bits)) & 31);
  const expanded = [...prefix].flatMap((character) => [character.charCodeAt(0) >>> 5]);
  expanded.push(0);
  expanded.push(...[...prefix].map((character) => character.charCodeAt(0) & 31));
  const polymod = bech32Polymod([...expanded, ...words, 0, 0, 0, 0, 0, 0]) ^ 1;
  const checksum = Array.from({ length: 6 }, (_, index) => (polymod >>> (5 * (5 - index))) & 31);
  return `${prefix}1${[...words, ...checksum].map((value) => BECH32_CHARSET[value] ?? "q").join("")}`;
}

export function parseProfileContent(content: string): Profile | null {
  try {
    const data: unknown = JSON.parse(content);
    if (typeof data !== "object" || data === null) return null;
    const record = data as Record<string, unknown>;
    const pick = (key: string): string | undefined => {
      const value = record[key];
      return typeof value === "string" && value.trim() ? value.trim() : undefined;
    };
    const profile: Profile = {
      name: pick("name"),
      display_name: pick("display_name"),
      about: pick("about"),
      picture: pick("picture"),
      nip05: pick("nip05"),
      website: pick("website"),
      lud16: pick("lud16"),
    };
    return Object.values(profile).some(Boolean) ? profile : null;
  } catch {
    return null;
  }
}

export function loadProfileCache(): Record<string, ProfileEntry> {
  try {
    const raw = storageGetItem(PROFILE_STORAGE_KEY);
    if (!raw) return {};
    const parsed: unknown = JSON.parse(raw);
    return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, ProfileEntry>) : {};
  } catch {
    return {};
  }
}

export function loadStoredFollows(pubkey: string | null): string[] {
  try {
    const raw = storageGetItem(pubkey ? FOLLOWS_STORAGE_KEY : LOCAL_FOLLOWS_STORAGE_KEY);
    if (!raw) return [];
    const parsed: unknown = JSON.parse(raw);
    if (pubkey) {
      if (typeof parsed !== "object" || parsed === null) return [];
      const list = (parsed as Record<string, unknown>)[pubkey];
      return Array.isArray(list) ? list.filter((item): item is string => typeof item === "string") : [];
    }
    return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
  } catch {
    return [];
  }
}

export function profileName(pubkey: string, cache: Record<string, ProfileEntry>): string {
  const entry = cache[pubkey];
  const name = entry?.profile.display_name || entry?.profile.name;
  if (name) return name;
  return shortKey(encodeNip19("npub", pubkey));
}

export function profilePicture(pubkey: string, cache: Record<string, ProfileEntry>): string | undefined {
  return cache[pubkey]?.profile.picture;
}

export type AvatarDisplay = { kind: "image"; src: string } | { kind: "initials" };

/**
 * 头像展示决策:隐身模式下永远不自动加载远程头像;普通模式仅当资料里有
 * 合法 http(s) 头像地址时才加载,否则显示首字母。
 */
export function avatarDisplay(pubkey: string, cache: Record<string, ProfileEntry>, incognito: boolean): AvatarDisplay {
  if (!incognito) {
    const picture = cache[pubkey]?.profile.picture;
    if (picture && /^https?:\/\//i.test(picture)) return { kind: "image", src: picture };
  }
  return { kind: "initials" };
}

/** Merge a new follow set into an existing kind-3 tag list: keep non-p tags, replace p tags. */
export function mergeContactTags(existing: string[][] | undefined, follows: string[]): string[][] {
  const kept = (existing ?? []).filter((tag) => tag[0] !== "p" && typeof tag[0] === "string");
  return [...kept, ...follows.map((pubkey) => ["p", pubkey])];
}

export function relativeTime(timestamp: number): string {
  const date = new Date(timestamp * 1000);
  const delta = Date.now() - date.getTime();
  if (!Number.isFinite(delta) || Math.abs(delta) > 7 * 24 * 60 * 60 * 1000) {
    return new Intl.DateTimeFormat(undefined, {
      month: "short",
      day: "numeric",
      hour: "2-digit",
      minute: "2-digit",
    }).format(date);
  }
  const future = delta < 0;
  const seconds = Math.floor(Math.abs(delta) / 1000);
  const value = seconds < 60 ? seconds : seconds < 3600 ? Math.floor(seconds / 60) : seconds < 86400 ? Math.floor(seconds / 3600) : Math.floor(seconds / 86400);
  const unit = seconds < 60 ? "秒" : seconds < 3600 ? "分钟" : seconds < 86400 ? "小时" : "天";
  return future ? `${value} ${unit}后` : `${value} ${unit}前`;
}

function relayLabel(url: string): string {
  try {
    return new URL(url).hostname;
  } catch {
    return url;
  }
}

function relayProtocol(url: string): "WS" | "WSS" {
  return url.startsWith("ws://") ? "WS" : "WSS";
}

/**
 * 浏览器是否会拦截这个 ws:// 连接(HTTPS 页面中的混合内容)。
 * pageProtocol 仅供测试注入;默认取当前页面的协议。
 */
export function isMixedContentBlocked(url: string, pageProtocol?: string): boolean {
  const protocol = pageProtocol ?? (typeof window === "undefined" ? "" : window.location.protocol);
  return protocol === "https:" && url.startsWith("ws://");
}

/**
 * 把 WebSocket 关闭翻译成中文原因。
 * 浏览器出于安全不暴露网络细节(拒绝 / DNS 失败 / 超时都表现为 code 1006),
 * 只能按“是否曾经连通过”分类给出最可能的原因。
 */
export function describeRelayClose(url: string, code: number, hadOpened: boolean, pageProtocol?: string): string {
  if (!hadOpened && isMixedContentBlocked(url, pageProtocol)) {
    return "浏览器拦截了未加密的 ws:// 连接(混合内容);HTTPS 页面只能连接 wss://";
  }
  if (code === 1006) {
    return hadOpened ? "连接异常中断(1006)" : "无法建立连接:被拒绝、不可达或超时(1006)";
  }
  if (code === 1015) return "TLS 握手失败(1015)";
  if (code === 1001) return "资讯源主动断开(1001)";
  return `${hadOpened ? "连接关闭" : "连接失败"}(代码 ${code})`;
}

const INLINE_TOKEN = /(https?:\/\/[^\s<]+|(?:nostr:)?(?:npub|note|nevent|nprofile|naddr)1[0-9a-z]+|#[\p{L}\p{N}_]+)/giu;
const TRAILING_PUNCTUATION = /[.,!?,。!?;;::))\]}]+$/;

export function renderInline(text: string, keyPrefix: string): ReactNode[] {
  return text.split(INLINE_TOKEN).filter(Boolean).flatMap((part, index) => {
    const key = `${keyPrefix}-${index}`;
    if (/^https?:\/\//i.test(part)) {
      const trailing = part.match(TRAILING_PUNCTUATION)?.[0] ?? "";
      const href = trailing ? part.slice(0, -trailing.length) : part;
      let label = href;
      try {
        const parsed = new URL(href);
        label = `${parsed.hostname}${parsed.pathname === "/" ? "" : parsed.pathname}`;
      } catch {
        // Keep the original text if a relay supplied a malformed URL.
      }
      const link = <a className="note-link" href={href} target="_blank" rel="noreferrer" key={`${key}-link`}>{label}</a>;
      return trailing ? [link, <span key={`${key}-tail`}>{trailing}</span>] : [link];
    }
    if (/^(?:nostr:)?(?:npub|note|nevent|nprofile|naddr)1/i.test(part)) {
      return <span className="nostr-token" title={part} key={key}>{shortKey(part.replace(/^nostr:/i, ""))}</span>;
    }
    if (part.startsWith("#")) return <span className="hashtag" key={key}>{part}</span>;
    return part;
  });
}

export function FormattedNote({ content }: { content: string }) {
  if (!content) return <span className="muted">(空文本)</span>;
  const blocks = content.replace(/\r\n?/g, "\n").trim().split(/\n{2,}/);
  return (
    <div className="note-prose">
      {blocks.map((block, blockIndex) => {
        const lines = block.split("\n");
        const bulletLines = lines.every((line) => /^\s*[-*•]\s+/.test(line));
        const quoteLines = lines.every((line) => /^\s*>\s?/.test(line));
        if (bulletLines) {
          return <ul key={`block-${blockIndex}`}>{lines.map((line, lineIndex) => <li key={`line-${lineIndex}`}>{renderInline(line.replace(/^\s*[-*•]\s+/, ""), `b${blockIndex}l${lineIndex}`)}</li>)}</ul>;
        }
        if (quoteLines) {
          return <blockquote key={`block-${blockIndex}`}>{lines.map((line, lineIndex) => <span key={`line-${lineIndex}`}>{renderInline(line.replace(/^\s*>\s?/, ""), `q${blockIndex}l${lineIndex}`)}{lineIndex < lines.length - 1 && <br />}</span>)}</blockquote>;
        }
        return <p key={`block-${blockIndex}`}>{lines.map((line, lineIndex) => <span key={`line-${lineIndex}`}>{renderInline(line, `p${blockIndex}l${lineIndex}`)}{lineIndex < lines.length - 1 && <br />}</span>)}</p>;
      })}
    </div>
  );
}

export function isNostrEvent(value: unknown, kind: number): value is SignedEvent {
  if (typeof value !== "object" || value === null) return false;
  const event = value as Partial<SignedEvent>;
  return (
    typeof event.id === "string" &&
    typeof event.pubkey === "string" &&
    typeof event.created_at === "number" &&
    event.kind === kind &&
    typeof event.content === "string" &&
    typeof event.sig === "string" &&
    Array.isArray(event.tags)
  );
}

function Icon({ name }: { name: "relay" | "refresh" | "edit" | "key" | "close" | "plus" | "trash" | "reply" | "download" | "help" | "incognito" | "filter" }) {
  const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true };
  if (name === "relay") return <svg {...common}><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="12" r="7.5"/><path d="M4.7 4.7 7 7M17 17l2.3 2.3M19.3 4.7 17 7M7 17l-2.3 2.3"/></svg>;
  if (name === "refresh") return <svg {...common}><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M18.2 9A7 7 0 0 0 6.4 6.4L4 11M20 13l-2.4 4.6A7 7 0 0 1 5.8 15"/></svg>;
  if (name === "edit") return <svg {...common}><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/></svg>;
  if (name === "key") return <svg {...common}><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8M15 8l2 2M17 6l2 2"/></svg>;
  if (name === "close") return <svg {...common}><path d="m6 6 12 12M18 6 6 18"/></svg>;
  if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14"/></svg>;
  if (name === "reply") return <svg {...common}><path d="M8 7 3 12l5 5"/><path d="M3 12h11a7 7 0 0 1 7 7v1"/></svg>;
  if (name === "download") return <svg {...common}><path d="M12 4v10"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>;
  if (name === "help") return <svg {...common}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.6a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1.1.9-1.1 1.9"/><path d="M12 17h.01"/></svg>;
  if (name === "incognito") return <svg {...common}><path d="M3 3l18 18"/><path d="M10.6 5.2A10.6 10.6 0 0 1 12 5c7 0 10 7 10 7a17 17 0 0 1-2.9 3.9"/><path d="M6.6 6.6A16.5 16.5 0 0 0 2 12s3 7 10 7c1.5 0 2.9-.3 4.1-.8"/></svg>;
  if (name === "filter") return <svg {...common}><path d="M4 5h16l-6.2 7.2V19l-3.6 2v-8.8L4 5z"/></svg>;
  return <svg {...common}><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg>;
}

/** Renders note content clamped to the card; shows an expand hint only when text actually overflows. */
function ClampedNote({ content }: { content: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [clamped, setClamped] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (el) setClamped(el.scrollHeight > el.clientHeight + 6);
  }, [content]);
  return (
    <>
      <div className="note-content" ref={ref}>
        <FormattedNote content={content} />
      </div>
      {clamped && <span className="expand-hint">内容较长,点击展开全文 →</span>}
    </>
  );
}

/** 远程头像展示:隐身模式只显示首字母,普通模式自动加载资料头像。 */
function AvatarMark({ pubkey, profileCache, incognito, large, onClick, ariaLabel }: {
  pubkey: string;
  profileCache: Record<string, ProfileEntry>;
  incognito: boolean;
  large?: boolean;
  onClick?: () => void;
  ariaLabel?: string;
}) {
  const display = avatarDisplay(pubkey, profileCache, incognito);
  const inner = display.kind === "image"
    ? <img src={display.src} alt="" loading="lazy" referrerPolicy="no-referrer" />
    : <>{pubkey.slice(0, 2).toUpperCase()}</>;
  const className = `avatar-mark${large ? " large-avatar" : ""}`;
  if (onClick) return <button className={className} onClick={onClick} aria-label={ariaLabel}>{inner}</button>;
  return <span className={className}>{inner}</span>;
}

/** 帖子卡片的共享主体:手动网格与自动网格共用,页脚带回复按钮。 */
function LongformCard({ item, profileCache, incognito, onOpenProfile, onOpen }: {
  item: NostrEvent;
  profileCache: Record<string, ProfileEntry>;
  incognito: boolean;
  onOpenProfile: (pubkey: string) => void;
  onOpen: (event: NostrEvent) => void;
}) {
  const meta = parseLongformMeta(item);
  const publishedAt = meta.publishedAt ?? item.created_at;
  return (
    <>
      <div className="note-meta">
        <AvatarMark
          pubkey={item.pubkey}
          profileCache={profileCache}
          incognito={incognito}
          onClick={() => onOpenProfile(item.pubkey)}
          ariaLabel={`查看作者 ${profileName(item.pubkey, profileCache)}`}
        />
        <button className="author" onClick={() => onOpenProfile(item.pubkey)} title={item.pubkey}>{profileName(item.pubkey, profileCache)}</button>
        <time dateTime={new Date(publishedAt * 1000).toISOString()}>{relativeTime(publishedAt)}</time>
        <span className="longform-badge">长文</span>
      </div>
      <button className="longform-open" onClick={() => onOpen(item)} aria-label={`阅读长文 ${meta.title || "无标题"}`}>
        <strong>{meta.title || "(无标题)"}</strong>
        <span>{longformExcerpt(item) || "(空)"}</span>
      </button>
      <div className="note-footer">
        <span className="note-relays" title={item.relays.join("\n")}><span className="tiny-signal" />{item.relays.length === 1 ? relayLabel(item.relays[0] ?? "") : `${item.relays.length} 个资讯源`}</span>
      </div>
    </>
  );
}

function NoteCard({ item, profileCache, incognito, onOpenProfile, onOpenNote, onReply }: {
  item: NostrEvent;
  profileCache: Record<string, ProfileEntry>;
  incognito: boolean;
  onOpenProfile: (pubkey: string) => void;
  onOpenNote: (eventId: string) => void;
  onReply: (item: NostrEvent) => void;
}) {
  return (
    <>
      <div className="note-meta">
        <AvatarMark
          pubkey={item.pubkey}
          profileCache={profileCache}
          incognito={incognito}
          onClick={() => onOpenProfile(item.pubkey)}
          ariaLabel={`查看作者 ${profileName(item.pubkey, profileCache)}`}
        />
        <button className="author" onClick={() => onOpenProfile(item.pubkey)} title={item.pubkey}>{profileName(item.pubkey, profileCache)}</button>
        <time dateTime={new Date(item.created_at * 1000).toISOString()}>{relativeTime(item.created_at)}</time>
      </div>
      <button className="note-open" onClick={() => onOpenNote(item.id)} aria-label={`查看帖子 ${shortKey(encodeNip19("note", item.id))}`}>
        <ClampedNote content={item.content} />
      </button>
      <div className="note-footer">
        <span className="note-relays" title={item.relays.join("\n")}><span className="tiny-signal" />{item.relays.length === 1 ? relayLabel(item.relays[0] ?? "") : `${item.relays.length} 个资讯源`}</span>
        <span className="note-actions">
          <button className="reply-button" onClick={() => onReply(item)} aria-label={`回复 ${profileName(item.pubkey, profileCache)}`}><Icon name="reply" />回复</button>
          <button className="view-detail" onClick={() => onOpenNote(item.id)}>查看详情 →</button>
        </span>
      </div>
    </>
  );
}


export function App() {
  const [relays, setRelays] = useState<RelayConfig[]>(loadRelays);
  const [relayStates, setRelayStates] = useState<Record<string, RelayState>>({});
  const [relayProblems, setRelayProblems] = useState<Record<string, string>>({});
  const [events, setEvents] = useState<NostrEvent[]>(loadCachedEvents);
  const [longforms, setLongforms] = useState<NostrEvent[]>(loadCachedLongforms);
  const [longformDetailId, setLongformDetailId] = useState<string | null>(null);
  const [panelOpen, setPanelOpen] = useState(false);
  const [helpOpen, setHelpOpen] = useState(false);
  const [composerOpen, setComposerOpen] = useState(false);
  const [newRelay, setNewRelay] = useState("");
  const [relayError, setRelayError] = useState("");
  const [relayPicks, setRelayPicks] = useState<RelayPicks | null>(null);
  const [relayPicksLoading, setRelayPicksLoading] = useState(false);
  const [relayPicksError, setRelayPicksError] = useState(false);
  const [relayPicksAttempt, setRelayPicksAttempt] = useState(0);
  const [draft, setDraft] = useState("");
  const [pubkey, setPubkey] = useState<string | null>(null);
  const [signerError, setSignerError] = useState("");
  const [signerType, setSignerType] = useState<"nip07" | "nip46" | "napplet" | null>(null);
  const [identityOpen, setIdentityOpen] = useState(false);
  const [nip46Uri, setNip46Uri] = useState("");
  const [nip46Error, setNip46Error] = useState("");
  const [nip46Busy, setNip46Busy] = useState(false);
  const nip46ClientRef = useRef<NostrConnectClient | null>(null);
  const nip46SecretRef = useRef<string | null>(null);
  const [publishStatus, setPublishStatus] = useState<PublishStatus | null>(null);
  const [networkMessage, setNetworkMessage] = useState("");
  const [connectionEpoch, setConnectionEpoch] = useState(0);
  const [detailEventId, setDetailEventId] = useState<string | null>(null);
  const [profilePubkey, setProfilePubkey] = useState<string | null>(null);
  const [authorHistory, setAuthorHistory] = useState<AuthorHistory | null>(null);
  const authorHistoryRef = useRef<AuthorHistoryRequest | null>(null);
  const [threadReplies, setThreadReplies] = useState<ThreadReplies | null>(null);
  const threadRequestRef = useRef<ThreadRequest | null>(null);
  // 钻取回复时详情可能指向主 events 之外的事件,这里缓存所有见过的 thread 回复供详情查找。
  const seenThreadEventsRef = useRef(new Map<string, NostrEvent>());
  const [copyMessage, setCopyMessage] = useState("");
  const [profileCache, setProfileCache] = useState<Record<string, ProfileEntry>>(loadProfileCache);
  const [follows, setFollows] = useState<string[]>(() => loadStoredFollows(null));
  // 关注人专属订阅拉到的帖子:公共信息流只保留最新 N 条,关注的人若很久没发帖
  // 就不在其中;这个订阅按作者取他们的最近帖子,保证「关注」标签页有内容。
  const [followEvents, setFollowEvents] = useState<NostrEvent[]>([]);
  const followsRef = useRef<string[]>(follows);
  // 正在生效的关注人订阅 id(onmessage 路由用)与每个中继的订阅 id(换关注列表时 CLOSE 用)。
  const followSubIdsRef = useRef<Set<string>>(new Set());
  const followSubByRelayRef = useRef<Map<string, string>>(new Map());
  const [feedTab, setFeedTab] = useState<"all" | "longform" | "following">("all");
  const [followsOpen, setFollowsOpen] = useState(false);
  const [followMessage, setFollowMessage] = useState("");
  const [viewMode, setViewMode] = useState<ViewMode>(loadViewMode);
  const [incognitoMode, setIncognitoMode] = useState<boolean>(loadIncognitoMode);
  const [filters, setFilters] = useState<FeedFilters>(loadFilters);
  const [filterOpen, setFilterOpen] = useState(false);
  const [keywordDraft, setKeywordDraft] = useState("");
  const [manualEvents, setManualEvents] = useState<NostrEvent[]>(() => loadCachedEvents());
  const [replyTarget, setReplyTarget] = useState<NostrEvent | null>(null);
  const socketsRef = useRef<Map<string, RelaySocket>>(new Map());
  const publishAcksRef = useRef<Map<string, Set<string>>>(new Map());
  const profileSubsRef = useRef<Set<string>>(new Set());
  const profileRequestedRef = useRef<Set<string>>(new Set());
  const contactSubRef = useRef<string | null>(null);
  const contactBaseRef = useRef<ContactList | null>(null);
  const eventsRef = useRef(events);
  eventsRef.current = events;
  const lastPersistRef = useRef(0);
  const longformsRef = useRef(longforms);
  longformsRef.current = longforms;
  const lastLongformPersistRef = useRef(0);

  // Persist the feed (throttled): bursts of incoming notes would otherwise
  // stringify on every event; pagehide flushes the latest state so nothing
  // is lost when the browser closes.
  useEffect(() => {
    const now = Date.now();
    if (now - lastPersistRef.current < 3000) return;
    lastPersistRef.current = now;
    saveCachedEvents(events);
  }, [events]);

  useEffect(() => {
    const flush = () => saveCachedEvents(eventsRef.current);
    window.addEventListener("pagehide", flush);
    return () => window.removeEventListener("pagehide", flush);
  }, []);

  // 长文缓存与主时间线分开存,离线也能看已收到的长文。
  useEffect(() => {
    const now = Date.now();
    if (now - lastLongformPersistRef.current < 3000) return;
    lastLongformPersistRef.current = now;
    saveCachedLongforms(longforms);
  }, [longforms]);

  useEffect(() => {
    const flush = () => saveCachedLongforms(longformsRef.current);
    window.addEventListener("pagehide", flush);
    return () => window.removeEventListener("pagehide", flush);
  }, []);

  const enabledRelays = useMemo(() => relays.filter((relay) => relay.enabled), [relays]);
  const onlineCount = enabledRelays.filter((relay) => relayStates[relay.url] === "online").length;
  // 详情既可能来自主时间线,也可能来自手动模式的冻结快照(manualEvents)、
  // 关注专属订阅(followEvents)、作者历史(authorHistory),
  // 还可能来自某条回复的钻取(thread 回复不在主 events 里)。
  const detailEvent = detailEventId
    ? findNoteById(detailEventId, [
        events,
        manualEvents,
        followEvents,
        authorHistory?.events,
        seenThreadEventsRef.current,
      ])
    : null;
  const profileEvents = useMemo(() => {
    if (!profilePubkey) return [];
    const history = authorHistory && authorHistory.pubkey === profilePubkey ? authorHistory.events : [];
    const fromFeed = events.filter((event) => event.pubkey === profilePubkey);
    return mergeHistoryEvents(history, fromFeed);
  }, [profilePubkey, events, authorHistory]);
  const feedEvents = viewMode === "manual" ? manualEvents : events;
  const visibleEvents = feedTab === "following" ? mergeFollowingFeed(feedEvents, followEvents, follows) : feedEvents;
  // 本地筛选(隐藏回复 / 关键词屏蔽):只影响展示,不改变订阅。
  const displayEvents = useMemo(() => applyFilters(visibleEvents, filters), [visibleEvents, filters]);
  const hiddenByFilters = visibleEvents.length - displayEvents.length;
  // 长文列表:独立订阅、独立展示,关键词筛选对标题/摘要/正文生效。
  const longformDisplay = useMemo(() => applyFilters(longforms, filters), [longforms, filters]);
  const longformDetail = longformDetailId ? longforms.find((event) => event.id === longformDetailId) ?? null : null;
  const longformDetailMeta = longformDetail ? parseLongformMeta(longformDetail) : null;
  const longformDetailHtml = useMemo(
    () => (longformDetail ? renderMarkdownHtml(longformDetail.content, !incognitoMode) : ""),
    [longformDetail, incognitoMode],
  );
  const manualIds = useMemo(() => new Set(manualEvents.map((event) => event.id)), [manualEvents]);
  const manualPendingCount = events.reduce((count, event) => count + (manualIds.has(event.id) ? 0 : 1), 0);

  // 自动网格保持严格时间倒序:最新在左上角,之后从左到右、从上到下。
  // 视口只显示能完整容纳的格数;新帖到达时列表原地重排,不做动画或循环轮换。
  const [gridCapacity, setGridCapacity] = useState(() => autoGridCapacity(960, 700));
  const [gridTile, setGridTile] = useState<AutoGridTile>(() => autoGridTile(960));
  const autoGridWrapRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    if (viewMode !== "auto") return;
    const el = autoGridWrapRef.current;
    if (!el) return;
    const measure = () => {
      const w = el.clientWidth;
      setGridCapacity(autoGridCapacity(w, el.clientHeight));
      setGridTile(autoGridTile(w));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [viewMode]);
  const autoGridItems = autoGridWindow(displayEvents, gridCapacity.count);


  // 浏览模式持久化;切到手动模式时冻结当前快照。
  useEffect(() => {
    saveViewMode(viewMode);
  }, [viewMode]);

  // 网站名是可配置项(site.config.json):运行时写入 document.title。
  useEffect(() => {
    document.title = SITE_NAME;
  }, []);

  // 隐身模式持久化:缺省普通模式(自动加载头像)。
  useEffect(() => {
    saveIncognitoMode(incognitoMode);
  }, [incognitoMode]);

  // 筛选条件持久化。
  useEffect(() => {
    saveFilters(filters);
  }, [filters]);

  useEffect(() => {
    if (viewMode === "manual") setManualEvents(eventsRef.current);
  }, [viewMode]);

  function refreshManualFeed() {
    setManualEvents(eventsRef.current);
    setConnectionEpoch((value) => value + 1);
  }
  const profileEntry = profilePubkey ? profileCache[profilePubkey] : undefined;
  const profileDetail = profileEntry?.profile;
  const externalProfilePicture = profileDetail?.picture && /^https?:\/\//i.test(profileDetail.picture) ? profileDetail.picture : null;
  const isFollowing = profilePubkey ? follows.includes(profilePubkey) : false;

  function openNote(eventId: string) {
    setProfilePubkey(null);
    setDetailEventId(eventId);
    setCopyMessage("");
    requestThreadReplies(eventId);
  }

  function addMutedKeyword() {
    const keyword = keywordDraft.trim();
    if (!keyword) return;
    setFilters((prev) =>
      prev.mutedKeywords.some((existing) => existing.toLowerCase() === keyword.toLowerCase())
        ? prev
        : { ...prev, mutedKeywords: [...prev.mutedKeywords, keyword].slice(0, 50) },
    );
    setKeywordDraft("");
  }

  function removeMutedKeyword(keyword: string) {
    setFilters((prev) => ({ ...prev, mutedKeywords: prev.mutedKeywords.filter((existing) => existing !== keyword) }));
  }

  function clearFilters() {
    setFilters({ hideReplies: false, mutedKeywords: [] });
  }

  function openProfile(key: string) {
    setDetailEventId(null);
    setProfilePubkey(key);
    setCopyMessage("");
    requestAuthorHistory(key);
  }

  function closeAuthorHistorySubs(): void {
    const request = authorHistoryRef.current;
    if (!request) return;
    for (const subId of request.subIds) {
      for (const socket of socketsRef.current.values()) {
        if (socket.readyState === WS_OPEN) {
          try {
            socket.send(JSON.stringify(["CLOSE", subId]));
          } catch {
            // Ignore close failures; the relay will time the sub out.
          }
        }
      }
    }
    authorHistoryRef.current = null;
  }

  /** 作者页打开后单独订阅该作者的 kind-1;until 用于分页加载更早。 */
  function requestAuthorHistory(pubkey: string, until?: number): void {
    closeAuthorHistorySubs();
    const mode = typeof until === "number" ? "more" : "initial";
    if (mode === "initial") {
      setAuthorHistory({ pubkey, events: [], loading: true, loadingMore: false, hasMore: false });
    } else {
      setAuthorHistory((prev) => (prev && prev.pubkey === pubkey ? { ...prev, loadingMore: true } : prev));
    }
    const filter = buildAuthorHistoryFilter(pubkey, until);
    const subIds = new Set<string>();
    const counts = new Map<string, number>();
    for (const socket of socketsRef.current.values()) {
      if (socket.readyState !== WS_OPEN) continue;
      const subId = `author-${createUuid().slice(0, 8)}`;
      try {
        socket.send(JSON.stringify(["REQ", subId, filter]));
        subIds.add(subId);
        counts.set(subId, 0);
      } catch {
        // Skip a socket that refuses the request.
      }
    }
    const request: AuthorHistoryRequest = { pubkey, subIds, counts, mode };
    authorHistoryRef.current = request;
    if (subIds.size === 0) {
      finishAuthorHistory(pubkey);
      return;
    }
    // 兜底:个别资讯源不回 EOSE 时,15 秒后也结束 loading,避免一直转圈。
    window.setTimeout(() => {
      if (authorHistoryRef.current === request) finishAuthorHistory(pubkey);
    }, 15000);
  }

  /** 收齐(或超时)后结束一次作者历史请求;有源返回满 limit 即认为还有更早。 */
  function finishAuthorHistory(pubkey: string): void {
    const request = authorHistoryRef.current;
    if (!request || request.pubkey !== pubkey) return;
    const hitLimit = [...request.counts.values()].some((count) => count >= AUTHOR_HISTORY_LIMIT);
    authorHistoryRef.current = null;
    setAuthorHistory((prev) =>
      prev && prev.pubkey === pubkey
        ? { ...prev, loading: false, loadingMore: false, hasMore: prev.hasMore || hitLimit }
        : prev,
    );
  }

  // 作者页关闭时收回历史订阅并清空历史,避免后台继续收该作者的事件。
  useEffect(() => {
    if (profilePubkey) return;
    closeAuthorHistorySubs();
    setAuthorHistory(null);
  }, [profilePubkey]);

  /** 「加载更早」:以当前最老一条的 created_at 为 until 继续往前翻。 */
  function loadMoreAuthorHistory(): void {
    if (!profilePubkey) return;
    const oldest = profileEvents.length > 0 ? profileEvents[profileEvents.length - 1] : undefined;
    if (oldest) requestAuthorHistory(profilePubkey, oldest.created_at - 1);
  }

  function closeThreadSubs(): void {
    const request = threadRequestRef.current;
    if (!request) return;
    for (const subId of request.subIds) {
      for (const socket of socketsRef.current.values()) {
        if (socket.readyState === WS_OPEN) {
          try {
            socket.send(JSON.stringify(["CLOSE", subId]));
          } catch {
            // Ignore close failures; the relay will time the sub out.
          }
        }
      }
    }
    threadRequestRef.current = null;
  }

  /** 帖子详情打开后单独订阅指向它的 kind-1 回复(e 标签)。 */
  function requestThreadReplies(eventId: string): void {
    closeThreadSubs();
    setThreadReplies({ eventId, events: [], loading: true });
    const filter = buildThreadFilter(eventId);
    const subIds = new Set<string>();
    for (const socket of socketsRef.current.values()) {
      if (socket.readyState !== WS_OPEN) continue;
      const subId = `thread-${createUuid().slice(0, 8)}`;
      try {
        socket.send(JSON.stringify(["REQ", subId, filter]));
        subIds.add(subId);
      } catch {
        // Skip a socket that refuses the request.
      }
    }
    const request: ThreadRequest = { eventId, subIds };
    threadRequestRef.current = request;
    if (subIds.size === 0) {
      finishThreadReplies(eventId);
      return;
    }
    // 兜底:个别资讯源不回 EOSE 时,15 秒后也结束 loading,避免一直转圈。
    window.setTimeout(() => {
      if (threadRequestRef.current === request) finishThreadReplies(eventId);
    }, 15000);
  }

  /** 收齐(或超时)后结束一次 thread 拉取。 */
  function finishThreadReplies(eventId: string): void {
    const request = threadRequestRef.current;
    if (!request || request.eventId !== eventId) return;
    threadRequestRef.current = null;
    setThreadReplies((prev) => (prev && prev.eventId === eventId ? { ...prev, loading: false } : prev));
  }

  // 帖子详情关闭时收回 thread 订阅并清空回复,避免后台继续收该帖的事件。
  useEffect(() => {
    if (detailEventId) return;
    closeThreadSubs();
    setThreadReplies(null);
    seenThreadEventsRef.current.clear();
  }, [detailEventId]);

  async function copyValue(value: string, label: string) {
    try {
      await navigator.clipboard.writeText(value);
      setCopyMessage(`${label}已复制`);
    } catch {
      setCopyMessage("复制失败,请长按文字手动复制。");
    }
  }

  useEffect(() => {
    storageSetItem(RELAY_STORAGE_KEY, JSON.stringify(relays));
  }, [relays]);

  const addIncomingEvent = useCallback((incoming: SignedEvent, relayUrl: string) => {
    setEvents((current) => {
      const existing = current.find((event) => event.id === incoming.id);
      if (existing) {
        if (existing.relays.includes(relayUrl)) return current;
        return current.map((event) => event.id === incoming.id ? { ...event, relays: [...event.relays, relayUrl] } : event);
      }
      return [{ ...incoming, relays: [relayUrl] }, ...current]
        .sort((a, b) => b.created_at - a.created_at)
        .slice(0, MAX_EVENTS);
    });
  }, []);

  /** 长文入库:NIP-33 可替换语义,同 kind+pubkey+d 只保留最新版。 */
  const addLongformEvent = useCallback((incoming: SignedEvent, relayUrl: string) => {
    setLongforms((current) =>
      mergeLongformEvents(current, [{ ...incoming, relays: [relayUrl] }]).slice(0, MAX_LONGFORMS),
    );
  }, []);

  const addProfile = useCallback((incoming: SignedEvent) => {
    const parsed = parseProfileContent(incoming.content);
    if (!parsed) return;
    setProfileCache((current) => {
      const existing = current[incoming.pubkey];
      if (existing && existing.created_at >= incoming.created_at) return current;
      return { ...current, [incoming.pubkey]: { profile: parsed, created_at: incoming.created_at, fetched_at: Date.now() } };
    });
  }, []);

  const addContactList = useCallback((incoming: SignedEvent) => {
    const base: ContactList = { tags: incoming.tags, content: incoming.content, created_at: incoming.created_at };
    if (contactBaseRef.current && contactBaseRef.current.created_at >= incoming.created_at) return;
    contactBaseRef.current = base;
    const list = incoming.tags
      .filter((tag) => tag[0] === "p" && typeof tag[1] === "string")
      .map((tag) => tag[1] as string);
    setFollows((current) => {
      const merged = [...current];
      for (const pubkey of list) if (!merged.includes(pubkey)) merged.push(pubkey);
      return merged;
    });
  }, []);

  useEffect(() => {
    try {
      storageSetItem(PROFILE_STORAGE_KEY, JSON.stringify(profileCache));
    } catch {
      // Storage quota or privacy mode: keep profiles in memory only.
    }
  }, [profileCache]);

  // Fetch kind-0 profiles for authors we have not seen recently. Runs on new
  // authors and on reconnect (requested set is cleared when epoch changes).
  const wantedAuthors = useMemo(() => [...new Set([
    ...events.map((event) => event.pubkey),
    ...followEvents.map((event) => event.pubkey),
    ...(threadReplies?.events.map((event) => event.pubkey) ?? []),
  ])], [events, followEvents, threadReplies]);
  const profileEpochRef = useRef(connectionEpoch);
  useEffect(() => {
    if (profileEpochRef.current !== connectionEpoch) {
      profileEpochRef.current = connectionEpoch;
      profileRequestedRef.current.clear();
    }
    const now = Date.now();
    const missing = wantedAuthors.filter((author) => {
      const hit = profileCache[author];
      if (hit && now - hit.fetched_at < PROFILE_TTL_MS) return false;
      return !profileRequestedRef.current.has(author);
    });
    if (missing.length === 0) return;
    const liveSockets = [...socketsRef.current.values()].filter((socket) => socket.readyState === WS_OPEN);
    if (liveSockets.length === 0) return;
    for (let index = 0; index < missing.length; index += 60) {
      const chunk = missing.slice(index, index + 60);
      const subId = `profile-${createUuid().slice(0, 8)}`;
      profileSubsRef.current.add(subId);
      chunk.forEach((author) => profileRequestedRef.current.add(author));
      for (const socket of liveSockets) {
        socket.send(JSON.stringify(["REQ", subId, { kinds: [0], authors: chunk }]));
      }
    }
  }, [wantedAuthors, profileCache, connectionEpoch]);

  // When the signer is connected, load our own kind-3 contact list.
  useEffect(() => {
    if (!pubkey) return;
    setFollows(loadStoredFollows(pubkey));
    const liveSockets = [...socketsRef.current.values()].filter((socket) => socket.readyState === WS_OPEN);
    if (liveSockets.length === 0) return;
    const subId = `contacts-${createUuid().slice(0, 8)}`;
    contactSubRef.current = subId;
    for (const socket of liveSockets) {
      socket.send(JSON.stringify(["REQ", subId, { kinds: [3], authors: [pubkey], limit: 1 }]));
    }
  }, [pubkey, connectionEpoch]);

  useEffect(() => {
    const active = relays.filter((relay) => relay.enabled);
    const timeout = window.setTimeout(() => {
      for (const socket of socketsRef.current.values()) socket.close();
      socketsRef.current.clear();
      // 旧连接的关注人订阅随 socket 一起失效,清掉路由记录(新连接的 onopen 会重建)。
      followSubIdsRef.current.clear();
      followSubByRelayRef.current.clear();

      const nextStates: Record<string, RelayState> = {};
      for (const relay of relays) nextStates[relay.url] = relay.enabled ? "connecting" : "offline";
      setRelayStates(nextStates);
      setRelayProblems({});
      setNetworkMessage(active.length === 0 ? "请至少启用一个资讯源。" : "");

      // 本轮连接中曾经成功打开过的资讯源(用于把关闭翻译成准确的原因)。
      const opened = new Set<string>();

      for (const relay of active) {
        const subId = `feed-${createUuid().slice(0, 8)}`;
        const longformSubId = `longform-${createUuid().slice(0, 8)}`;
        try {
          const socket = createSocket(relay.url);
          socketsRef.current.set(relay.url, socket);
          // 15 秒还没握手成功就判超时:浏览器不会自己报连接超时。
          const connectTimer = window.setTimeout(() => {
            if (socket.readyState === WS_CONNECTING) {
              setRelayStates((current) => ({ ...current, [relay.url]: "offline" }));
              setRelayProblems((current) => ({ ...current, [relay.url]: "连接超时(15 秒无响应)" }));
              socket.close();
            }
          }, 15000);
          socket.onopen = () => {
            window.clearTimeout(connectTimer);
            opened.add(relay.url);
            setRelayProblems((current) => {
              if (!(relay.url in current)) return current;
              const next = { ...current };
              delete next[relay.url];
              return next;
            });
            setRelayStates((current) => ({ ...current, [relay.url]: "online" }));
            socket.send(JSON.stringify(["REQ", subId, { kinds: [1], limit: 60 }]));
            socket.send(JSON.stringify(["REQ", longformSubId, { kinds: [LONGFORM_KIND], limit: LONGFORM_LIMIT }]));
            // 关注人专属订阅:公共信息流只保留最新 60 条/中继,关注的人若不在其中,
            // 「关注」标签页就是空的;按作者单独拉一次,保证关注的人有帖子可看。
            // 保持开启可收到关注人的新帖子;关注列表变化时由下面的 effect 换订阅。
            const followAuthors = followsRef.current;
            if (followAuthors.length > 0) {
              const followSubId = `following-${createUuid().slice(0, 8)}`;
              followSubIdsRef.current.add(followSubId);
              followSubByRelayRef.current.set(relay.url, followSubId);
              socket.send(JSON.stringify(["REQ", followSubId, buildFollowFilter(followAuthors)]));
            }          };
          socket.onmessage = (message: { data: unknown }) => {
            try {
              const frame: unknown = JSON.parse(String(message.data));
              if (!Array.isArray(frame)) return;
              if (frame[0] === "EVENT" && typeof frame[1] === "string") {
                const incoming = frame[2];
                const historyRequest = authorHistoryRef.current;
                if (historyRequest && historyRequest.subIds.has(frame[1]) && isNostrEvent(incoming, 1)) {
                  historyRequest.counts.set(frame[1], (historyRequest.counts.get(frame[1]) ?? 0) + 1);
                  const historyEvent: NostrEvent = { ...incoming, relays: [relay.url] };
                  const wanted = historyRequest.pubkey;
                  setAuthorHistory((prev) => (prev && prev.pubkey === wanted
                    ? { ...prev, events: mergeHistoryEvents(prev.events, [historyEvent]) }
                    : prev));
                } else if (frame[1] === subId && isNostrEvent(incoming, 1)) {
                  addIncomingEvent(incoming, relay.url);
                } else if (frame[1] === longformSubId && isNostrEvent(incoming, LONGFORM_KIND)) {
                  addLongformEvent(incoming, relay.url);
                } else if (followSubIdsRef.current.has(frame[1]) && isNostrEvent(incoming, 1)) {
                  const followEvent: NostrEvent = { ...incoming, relays: [relay.url] };
                  setFollowEvents((prev) => mergeHistoryEvents(prev, [followEvent]).slice(0, MAX_FOLLOW_EVENTS));
                } else if (profileSubsRef.current.has(frame[1]) && isNostrEvent(incoming, 0)) {
                  addProfile(incoming);
                } else if (frame[1] === contactSubRef.current && isNostrEvent(incoming, 3)) {
                  addContactList(incoming);
                } else {
                  const threadRequest = threadRequestRef.current;
                  if (threadRequest && threadRequest.subIds.has(frame[1]) && isNostrEvent(incoming, 1)) {
                    const threadEvent: NostrEvent = { ...incoming, relays: [relay.url] };
                    const wanted = threadRequest.eventId;
                    seenThreadEventsRef.current.set(threadEvent.id, threadEvent);
                    setThreadReplies((prev) => (prev && prev.eventId === wanted
                      ? { ...prev, events: mergeThreadEvents(prev.events, [threadEvent]) }
                      : prev));
                  }
                }
              }
              if (frame[0] === "EOSE" && typeof frame[1] === "string") {
                const historyRequest = authorHistoryRef.current;
                if (historyRequest && historyRequest.subIds.has(frame[1])) {
                  historyRequest.subIds.delete(frame[1]);
                  if (historyRequest.subIds.size === 0) finishAuthorHistory(historyRequest.pubkey);
                }
                const threadRequest = threadRequestRef.current;
                if (threadRequest && threadRequest.subIds.has(frame[1])) {
                  threadRequest.subIds.delete(frame[1]);
                  if (threadRequest.subIds.size === 0) finishThreadReplies(threadRequest.eventId);
                }
              }
              if (frame[0] === "OK" && typeof frame[1] === "string" && typeof frame[2] === "boolean") {
                const eventId = frame[1];
                const seen = publishAcksRef.current.get(eventId) ?? new Set<string>();
                if (seen.has(relay.url)) return;
                seen.add(relay.url);
                publishAcksRef.current.set(eventId, seen);
                setPublishStatus((current) => current?.eventId === eventId ? {
                  ...current,
                  accepted: current.accepted + (frame[2] ? 1 : 0),
                  rejected: current.rejected + (frame[2] ? 0 : 1),
                  pending: Math.max(0, current.pending - 1),
                } : current);
              }
            } catch {
              // Ignore malformed relay frames without interrupting the feed.
            }
          };
          // 注意:onerror 不携带错误细节(浏览器安全限制),关闭原因统一在 onclose 里按 code 分类。
          socket.onclose = (event: { code: number }) => {
            window.clearTimeout(connectTimer);
            setRelayStates((current) => ({ ...current, [relay.url]: "offline" }));
            setRelayProblems((current) => ({ ...current, [relay.url]: describeRelayClose(relay.url, event.code, opened.has(relay.url)) }));
          };
        } catch (error) {
          setRelayStates((current) => ({ ...current, [relay.url]: "offline" }));
          setRelayProblems((current) => ({ ...current, [relay.url]: error instanceof Error ? `地址无效:${error.message}` : "地址无效" }));
        }
      }
    }, 500);

    return () => {
      window.clearTimeout(timeout);
      for (const socket of socketsRef.current.values()) socket.close();
      socketsRef.current.clear();
    };
  }, [relays, connectionEpoch, addIncomingEvent, addLongformEvent]);

  // 关注列表变化时,在已连上的中继上换掉关注人订阅(先 CLOSE 旧的再 REQ 新的),
  // 不必重建整个连接。followsRef 供新连接的 onopen 读取,保证重连后订阅也是最新的。
  useEffect(() => {
    followsRef.current = follows;
    for (const [url, socket] of socketsRef.current) {
      if (socket.readyState !== WS_OPEN) continue;
      const oldSubId = followSubByRelayRef.current.get(url);
      if (oldSubId) {
        socket.send(JSON.stringify(["CLOSE", oldSubId]));
        followSubIdsRef.current.delete(oldSubId);
        followSubByRelayRef.current.delete(url);
      }
      if (follows.length > 0) {
        const followSubId = `following-${createUuid().slice(0, 8)}`;
        followSubIdsRef.current.add(followSubId);
        followSubByRelayRef.current.set(url, followSubId);
        socket.send(JSON.stringify(["REQ", followSubId, buildFollowFilter(follows)]));
      }
    }
    if (follows.length === 0) setFollowEvents([]);
  }, [follows]);

  /** 当前生效的签名器(napplet host 身份),未连接时为 null。 */
  function activeSigner(): AppSigner | null {
    // Napplet 沙箱:签名由 host 用 viewer 身份完成,napplet 永远接触不到私钥。
    // signEvent 在此模式下不应被调用——发布走 relayPublish 的 napplet 专用路径。
    if (signerType === "napplet") {
      return {
        signerType: "napplet",
        getPublicKey: async () => {
          const { identity } = await import("@napplet/sdk");
          return identity.getPublicKey();
        },
        signEvent: () => Promise.reject(new Error("napplet 模式下签名由 host 完成")),
      };
    }
    return null;
  }

  function persistNip46Session(session: Nip46Session): void {
    try {
      storageSetItem(NIP46_SESSION_KEY, JSON.stringify(session));
    } catch {
      /* 存储失败不阻塞使用 */
    }
  }

  function clearNip46Session(): void {
    try {
      storageRemoveItem(NIP46_SESSION_KEY);
    } catch {
      /* ignore */
    }
  }

  async function connectSigner(): Promise<string | null> {
    setSignerError("");
    // Napplet 沙箱:直接用 host 的 viewer 身份,无需任何外部签名器。
    if (isNapplet()) {
      try {
        const { identity } = await import("@napplet/sdk");
        const key = await identity.getPublicKey();
        setPubkey(key);
        setSignerType("napplet");
        return key;
      } catch {
        setSignerError("未能获取 host 身份。你仍可匿名浏览帖子。");
        return null;
      }
    }
    // Napplet 版只有 host 身份一种连接方式;NIP-07/NIP-46 分支已移除。
    setSignerError("Napplet 版请使用 host 身份连接。");
    return null;
  }

  /** 用 bunker:// 连接串配对 NIP-46 远端签名器。 */
  async function connectNip46(uri: string): Promise<boolean> {
    setNip46Error("");
    setNip46Busy(true);
    let parsed;
    try {
      parsed = parseBunkerUri(uri);
    } catch (error) {
      setNip46Error(error instanceof Error ? error.message : "连接串无法解析");
      setNip46Busy(false);
      return false;
    }
    const relayUrl = parsed.relays[0];
    if (!relayUrl) {
      setNip46Error("连接串中没有可用的 relay 地址");
      setNip46Busy(false);
      return false;
    }
    const session = NostrConnectClient.newSession(parsed.remotePubkey, relayUrl);
    const client = new NostrConnectClient(session);
    try {
      await client.connect();
      const userPubkey = await client.getPublicKey(parsed.secret);
      nip46ClientRef.current = client;
      nip46SecretRef.current = parsed.secret;
      setPubkey(userPubkey);
      setSignerType("nip46");
      persistNip46Session({ ...session, userPubkey });
      setNip46Uri("");
      setIdentityOpen(false);
      return true;
    } catch (error) {
      client.close();
      if (error instanceof Nip46AuthRequiredError) {
        setNip46Error(`签名器要求额外验证,请在新页面完成:${error.authUrl}`);
      } else {
        setNip46Error(error instanceof Error ? error.message : "连接远端签名器失败");
      }
      return false;
    } finally {
      setNip46Busy(false);
    }
  }

  function disconnectSigner(): void {
    if (nip46ClientRef.current) {
      try {
        nip46ClientRef.current.close();
      } catch {
        /* ignore */
      }
      nip46ClientRef.current = null;
    }
    nip46SecretRef.current = null;
    clearNip46Session();
    setSignerType(null);
    setPubkey(null);
    setIdentityOpen(false);
  }

  // 启动时恢复上次的 NIP-46 会话:只重建通道与身份,不自动握手,
  // 首次签名时再完成 connect(签名器那边已授权过该 client keypair)。
  // Napplet 版跳过:签名一律走 host viewer 身份。
  useEffect(() => {
    if (isNapplet()) return;
    const session = loadNip46Session();
    if (!session) return;
    let cancelled = false;
    try {
      const client = new NostrConnectClient(session);
      nip46ClientRef.current = client;
      nip46SecretRef.current = null;
      if (session.userPubkey) {
        setPubkey(session.userPubkey);
        setSignerType("nip46");
      }
      void client.connect().catch(() => {
        if (!cancelled) {
          // relay 暂时连不上:保持身份显示,用户点发帖时会重试。
        }
      });
    } catch {
      clearNip46Session();
    }
    return () => {
      cancelled = true;
    };
  }, []);

  /**
   * Napplet 发布路径:模板交给 host,由 host 用 viewer 身份签名并广播。
   * 私钥永不进入 napplet;返回的已签名事件直接用于本地展示。
   */
  async function publishNoteNapplet(content: string, tags: string[][]): Promise<boolean> {
    setSignerError("");
    try {
      const currentPubkey = pubkey ?? await connectSigner();
      if (!currentPubkey) return false;
      const { relayPublish } = await import("@napplet/sdk");
      const signed = await relayPublish(
        { kind: 1, created_at: Math.floor(Date.now() / 1000), tags, content },
        { relay: true },
      );
      if (!isNostrEvent(signed, 1)) throw new Error("invalid published event");
      setPublishStatus({ eventId: signed.id, total: 1, accepted: 1, rejected: 0, pending: 0 });
      setEvents((current) => [{ ...signed, relays: ["host"] }, ...current.filter((item) => item.id !== signed.id)].slice(0, MAX_EVENTS));
      return true;
    } catch {
      setSignerError("发布未完成,帖子没有发送。");
      return false;
    }
  }

  /** 签名并发布一条 kind-1 帖子;tags 为空是普通帖子,带 e/p 标签即为回复。 */
  async function publishNote(content: string, tags: string[][]): Promise<boolean> {
    // Napplet 沙箱:host 用 viewer 身份签名并发布,不经过本地 signer/socket。
    if (isNapplet()) return publishNoteNapplet(content, tags);
    setSignerError("");
    const currentPubkey = pubkey ?? await connectSigner();
    const signer = activeSigner();
    if (!currentPubkey || !signer) return false;
    const liveSockets = [...socketsRef.current.entries()].filter(([, socket]) => socket.readyState === WS_OPEN);
    if (liveSockets.length === 0) {
      setNetworkMessage("当前没有在线资讯源,无法发布。请检查资讯源面板后重试。");
      return false;
    }
    try {
      const signed = await signer.signEvent({
        kind: 1,
        created_at: Math.floor(Date.now() / 1000),
        tags,
        content,
      });
      if (!isNostrEvent(signed, 1)) throw new Error("invalid signed event");
      publishAcksRef.current.set(signed.id, new Set());
      setPublishStatus({ eventId: signed.id, total: liveSockets.length, accepted: 0, rejected: 0, pending: liveSockets.length });
      setEvents((current) => [{ ...signed, relays: ["本地发布"] }, ...current.filter((item) => item.id !== signed.id)].slice(0, MAX_EVENTS));
      for (const [, socket] of liveSockets) socket.send(JSON.stringify(["EVENT", signed]));
      return true;
    } catch {
      setSignerError("签名未完成,帖子没有发送。");
      return false;
    }
  }

  async function publish(event: FormEvent) {
    event.preventDefault();
    const content = draft.trim();
    if (!content) return;
    const target = replyTarget;
    const ok = await publishNote(content, target ? buildReplyTags(target) : []);
    if (!ok) return;
    setDraft("");
    setComposerOpen(false);
    setReplyTarget(null);
  }

  function openReply(item: NostrEvent) {
    setDetailEventId(null);
    setProfilePubkey(null);
    setReplyTarget(item);
    setDraft("");
    setComposerOpen(true);
  }

  /** 关注列表本地持久化(按身份分键)。 */
  function persistFollows(next: string[]): void {
    try {
      if (pubkey) {
        const raw = storageGetItem(FOLLOWS_STORAGE_KEY);
        const parsed: unknown = raw ? JSON.parse(raw) : {};
        const record = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, string[]>) : {};
        storageSetItem(FOLLOWS_STORAGE_KEY, JSON.stringify({ ...record, [pubkey]: next }));
      } else {
        storageSetItem(LOCAL_FOLLOWS_STORAGE_KEY, JSON.stringify(next));
      }
    } catch {
      // Keep the in-memory follow set when storage is unavailable.
    }
  }

  async function toggleFollow(target: string) {
    setFollowMessage("");
    const following = follows.includes(target);
    const next = following ? follows.filter((item) => item !== target) : [...follows, target];
    setFollows(next);
    // Napplet 沙箱:关注列表走 host 签名发布。
    if (isNapplet()) {
      if (pubkey) {
        try {
          const base = contactBaseRef.current;
          const { relayPublish } = await import("@napplet/sdk");
          const signed = await relayPublish(
            {
              kind: 3,
              created_at: Math.floor(Date.now() / 1000),
              tags: mergeContactTags(base?.tags, next),
              content: base?.content ?? "",
            },
            { relay: true },
          );
          if (signed.kind !== 3 || !Array.isArray(signed.tags)) throw new Error("invalid contact list");
          contactBaseRef.current = { tags: signed.tags, content: signed.content, created_at: signed.created_at };
          setFollowMessage(following ? "已取消关注,关注列表已发布。" : "已关注,关注列表已发布。");
        } catch {
          setFollowMessage("发布未完成,关注列表没有同步;本地状态已更新。");
        }
      }
      persistFollows(next);
      return;
    }
    const signer = activeSigner();
    if (pubkey && signer) {
      try {
        const base = contactBaseRef.current;
        const signed = await signer.signEvent({
          kind: 3,
          created_at: Math.floor(Date.now() / 1000),
          tags: mergeContactTags(base?.tags, next),
          content: base?.content ?? "",
        });
        if (signed.kind !== 3 || !Array.isArray(signed.tags)) throw new Error("invalid contact list");
        contactBaseRef.current = { tags: signed.tags, content: signed.content, created_at: signed.created_at };
        const liveSockets = [...socketsRef.current.values()].filter((socket) => socket.readyState === WS_OPEN);
        for (const socket of liveSockets) socket.send(JSON.stringify(["EVENT", signed]));
        persistFollows(next);
        setFollowMessage(following ? "已取消关注,关注列表已发布到资讯源。" : "已关注,关注列表已发布到资讯源。");
      } catch {
        setFollowMessage("签名未完成,关注列表没有发布;本地状态已更新。");
      }
      return;
    }
    persistFollows(next);
    setFollowMessage(following ? "已取消本地关注。" : "已加入本地关注(连接签名器后可发布到链上)。");
  }

  function addRelay(event: FormEvent) {
    event.preventDefault();
    if (addRelayUrl(newRelay)) setNewRelay("");
  }

  /** 把一个中继地址加入列表;供表单与每日推荐共用。成功返回 true。 */
  function addRelayUrl(url: string): boolean {
    const normalized = normalizeRelay(url);
    if (!normalized) {
      setRelayError("请输入有效的 wss:// 或 ws:// 资讯源地址。");
      return false;
    }
    if (relays.some((relay) => relay.url === normalized)) {
      setRelayError("这个资讯源已经在列表中。");
      return false;
    }
    setRelays((current) => [...current, { url: normalized, enabled: true }]);
    setRelayError("");
    return true;
  }

  // 每日推荐:打开中继面板时拉取(每天换一批,date 对上才算新鲜)。
  // 失败时显示错误+重试,不让“正在获取”无限转下去(resource.bytes 可能悬挂)。
  useEffect(() => {
    if (!panelOpen) return;
    const today = new Date().toISOString().slice(0, 10);
    if (relayPicks && relayPicks.date === today) return;
    if (relayPicksLoading) return;
    let cancelled = false;
    setRelayPicksLoading(true);
    setRelayPicksError(false);
    void fetchRelayPicks().then((picks) => {
      if (cancelled) return;
      if (picks) setRelayPicks(picks);
      else setRelayPicksError(true);
      setRelayPicksLoading(false);
    });
    return () => {
      cancelled = true;
    };
  }, [panelOpen, relayPicks, relayPicksLoading, relayPicksAttempt]);

  return (
    <div className={`app-shell ${viewMode === "auto" ? "auto-mode" : "manual-mode"}`}>
      <SafeAreaTopScrim backgroundColor="var(--bg)" />

      <header className="utility-bar">
        <button className="relay-summary" onClick={() => setPanelOpen(true)} aria-label="打开资讯中继管理" title="打开资讯中继管理">
          <span className={`signal ${onlineCount > 0 ? "signal-live" : ""}`} />
          <span>{onlineCount}/{enabledRelays.length} 中继在线</span>
        </button>
        <div className="utility-actions">
          <button
            className={`icon-button${incognitoMode ? " active" : ""}`}
            onClick={() => setIncognitoMode((enabled) => !enabled)}
            aria-pressed={incognitoMode}
            aria-label={incognitoMode ? "关闭隐身模式(恢复自动加载头像)" : "打开隐身模式(不自动加载头像)"}
            title={incognitoMode ? "隐身模式:不自动加载远程头像" : "普通模式:自动加载远程头像"}
          >
            <Icon name="incognito" />
          </button>
          <button
            className={`icon-button${filtersActive(filters) ? " active" : ""}`}
            onClick={() => setFilterOpen(true)}
            aria-label="筛选帖子"
            title={filtersActive(filters) ? "筛选已开启:隐藏回复 / 屏蔽关键词" : "筛选帖子:隐藏回复、屏蔽关键词"}
          >
            <Icon name="filter" />
          </button>
          <button className="icon-button" onClick={() => setHelpOpen(true)} aria-label="使用说明" title="使用说明"><Icon name="help" /></button>
          <button className="icon-button" onClick={() => void forceAppUpdate(defaultUpdateEnv())} aria-label="版本更新,重新下载" title="版本更新,重新下载"><Icon name="download" /></button>
          <button className="icon-button" onClick={viewMode === "manual" ? refreshManualFeed : () => setConnectionEpoch((value) => value + 1)} aria-label={viewMode === "manual" ? "手动刷新帖子" : "重新连接资讯源"} title={viewMode === "manual" ? "手动刷新帖子" : "重新连接资讯源"}><Icon name="refresh" /></button>
          <button className="identity-button" onClick={() => { setSignerError(""); setNip46Error(""); setIdentityOpen(true); }} aria-label={pubkey ? "查看已连接身份" : isNapplet() ? "连接 host 身份" : "连接签名器"} title={pubkey ? `已连接(${signerType === "napplet" ? "host 身份" : signerType === "nip46" ? "远程签名器" : "浏览器扩展"}),点击管理` : isNapplet() ? "连接 host 身份" : "连接签名器"}>
            <Icon name="key" />
            <span>{pubkey ? shortKey(pubkey) : isNapplet() ? "连接 host 身份" : "连接签名器"}</span>
          </button>
        </div>
      </header>

      <main className={`feed-column${viewMode === "auto" ? " auto-feed" : ""}`}>
        <section className="feed-intro" aria-labelledby="feed-heading">
          <div>
            <p className="section-index">PUBLIC NOTES / KIND 1</p>
            <h1 id="feed-heading">{SITE_NAME} <small className="app-version">{APP_VERSION}</small></h1>
          </div>
          <button className="compose-button desktop-compose" onClick={() => { setReplyTarget(null); setDraft(""); setComposerOpen(true); }}><Icon name="edit" />发帖子</button>
        </section>

        {networkMessage && <div className="inline-notice" role="status">{networkMessage}</div>}
        {signerError && <div className="inline-notice warning" role="status">{signerError}</div>}
        {publishStatus && (
          <div className="publish-strip" role="status">
            <span>发布结果</span>
            <strong>{publishStatus.accepted} 接收</strong>
            {publishStatus.rejected > 0 && <span>{publishStatus.rejected} 拒绝</span>}
            {publishStatus.pending > 0 && <span>{publishStatus.pending} 等待确认</span>}
          </div>
        )}

        <div className="feed-tabs" role="tablist" aria-label="帖子筛选">
          <button role="tab" aria-selected={feedTab === "all"} className={`feed-tab${feedTab === "all" ? " active" : ""}`} onClick={() => setFeedTab("all")}>全部</button>
          <button role="tab" aria-selected={feedTab === "longform"} className={`feed-tab${feedTab === "longform" ? " active" : ""}`} onClick={() => setFeedTab("longform")}>长文{longforms.length > 0 ? ` · ${longforms.length}` : ""}</button>
          <button role="tab" aria-selected={feedTab === "following"} className={`feed-tab${feedTab === "following" ? " active" : ""}`} onClick={() => setFeedTab("following")}>关注{follows.length > 0 ? ` · ${follows.length}` : ""}</button>
          {feedTab === "following" && follows.length > 0 && (
            <button className="manage-follows" onClick={() => setFollowsOpen(true)}>管理关注</button>
          )}
          {viewMode === "manual" && (
            <button className="manual-refresh" onClick={refreshManualFeed} aria-label="手动刷新帖子">
              <Icon name="refresh" />
              <span>{manualPendingCount > 0 ? `刷新 · ${manualPendingCount} 条新帖` : "刷新"}</span>
            </button>
          )}
          <div className="view-switch" role="group" aria-label="浏览模式">
            <button className={`view-option${viewMode === "auto" ? " active" : ""}`} aria-pressed={viewMode === "auto"} onClick={() => setViewMode("auto")}>自动</button>
            <button className={`view-option${viewMode === "manual" ? " active" : ""}`} aria-pressed={viewMode === "manual"} onClick={() => setViewMode("manual")}>手动</button>
          </div>
        </div>

        {feedTab === "longform" ? (
          longformDisplay.length === 0 ? (
            <section className="empty-state">
              <div className="empty-signal"><span /><span /><span /></div>
              <h2>还没有收到长文</h2>
              <p>{onlineCount > 0 ? "连接已建立,资讯源里的长文会显示在这里。" : "还没有连上资讯源,打开资讯源面板检查连接。"}</p>
              {onlineCount === 0 && <button onClick={() => setPanelOpen(true)}>管理资讯源</button>}
            </section>
          ) : (
            <ol className="feed-list longform-list" aria-live="polite">
              {longformDisplay.map((item) => (
                <li className="note longform-note" key={item.id}>
                  <LongformCard item={item} profileCache={profileCache} incognito={incognitoMode} onOpenProfile={openProfile} onOpen={(event) => setLongformDetailId(event.id)} />
                </li>
              ))}
            </ol>
          )
        ) : displayEvents.length === 0 ? (
          <section className="empty-state">
            <div className="empty-signal"><span /><span /><span /></div>
            <h2>{hiddenByFilters > 0 ? "筛选隐藏了全部帖子" : feedTab === "following" ? (follows.length > 0 ? "关注的人还没有新帖子" : "还没有关注的人") : onlineCount > 0 ? "正在等待帖子" : "还没有连上资讯源"}</h2>
            <p>{hiddenByFilters > 0 ? `${hiddenByFilters} 条帖子被当前筛选条件隐藏。` : feedTab === "following" ? (follows.length > 0 ? "已经向资讯源请求了你关注的人的最近帖子,他们发了新帖子会显示在这里。" : "在帖子或作者页点「关注」,这里只显示你关注的人的帖子。") : onlineCount > 0 ? "连接已建立,新帖子会直接出现在这里。" : "打开资讯源面板查看每个地址的状态,或添加一个可用资讯源。"}</p>
            <button onClick={() => hiddenByFilters > 0 ? setFilters({ hideReplies: false, mutedKeywords: [] }) : feedTab === "following" ? setFeedTab("all") : onlineCount > 0 ? (viewMode === "manual" ? refreshManualFeed() : setConnectionEpoch((value) => value + 1)) : setPanelOpen(true)}>
              {hiddenByFilters > 0 ? "清除筛选" : feedTab === "following" ? "浏览全部帖子" : onlineCount > 0 ? (viewMode === "manual" ? "手动刷新" : "重新订阅") : "管理资讯源"}
            </button>
          </section>
        ) : viewMode === "manual" ? (
          <ol className="feed-list" aria-live="polite">
            {displayEvents.map((item) => (
              <li className="note" key={item.id}>
                <NoteCard item={item} profileCache={profileCache} incognito={incognitoMode} onOpenProfile={openProfile} onOpenNote={openNote} onReply={openReply} />
              </li>
            ))}
          </ol>
        ) : (
          <section className="auto-stage" aria-label="帖子自动网格">
            <div className="auto-grid-wrap" ref={autoGridWrapRef}>
              <ol
                className={`feed-list auto-grid${gridTile.narrow ? " narrow" : ""}`}
                style={gridTile.narrow ? {
                  gridTemplateColumns: `repeat(${AUTO_GRID_NARROW_COLS}, minmax(0, 1fr))`,
                  "--auto-card-h": `${gridTile.cardH}px`,
                  "--auto-scale": String(gridTile.scale),
                } as CSSProperties : undefined}
              >
                {autoGridItems.map((item) => (
                  <li className="note" key={item.id}>
                    <NoteCard item={item} profileCache={profileCache} incognito={incognitoMode} onOpenProfile={openProfile} onOpenNote={openNote} onReply={openReply} />
                  </li>
                ))}
              </ol>
            </div>
            <div className="auto-bar">
              <span className="auto-count">{displayEvents.length} 条帖子{hiddenByFilters > 0 ? `(${hiddenByFilters} 条被筛选隐藏)` : ""}</span>
              <span className="auto-hint">新帖自动进入左上角</span>
            </div>
          </section>
        )}
      </main>

      <button className="compose-fab" onClick={() => { setReplyTarget(null); setDraft(""); setComposerOpen(true); }} aria-label="发帖子"><Icon name="edit" /></button>

      {panelOpen && (
        <div className="sheet-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setPanelOpen(false); }}>
          <aside className="relay-sheet" aria-label="资讯中继管理" role="dialog" aria-modal="true">
            <div className="sheet-heading">
              <div><p className="section-index">SOURCE POOL</p><h2>资讯中继管理</h2></div>
              <button className="icon-button" onClick={() => setPanelOpen(false)} aria-label="关闭资讯中继管理"><Icon name="close" /></button>
            </div>
            <p className="sheet-copy">支持加密的 wss:// 与不加密的 ws:// 资讯源;修改后会自动重连。</p>
            <ul className="relay-list">
              {relays.map((relay) => (
                <li key={relay.url} className="relay-row">
                  <button
                    className={`relay-toggle ${relay.enabled ? "enabled" : ""}`}
                    onClick={() => setRelays((current) => current.map((item) => item.url === relay.url ? { ...item, enabled: !item.enabled } : item))}
                    role="switch"
                    aria-checked={relay.enabled}
                    aria-label={`${relay.enabled ? "停用" : "启用"} ${relay.url}`}
                  ><span /></button>
                  <div className="relay-address"><strong>{relayLabel(relay.url)} <span className={`protocol-badge ${relayProtocol(relay.url) === "WS" ? "insecure" : ""}`}>{relayProtocol(relay.url)}</span></strong><code>{relay.url}</code></div>
                  <span className={`status-dot ${relay.enabled ? relayStates[relay.url] ?? "connecting" : "offline"}`} aria-label={relay.enabled ? relayStates[relay.url] ?? "连接中" : "已停用"} title={relayProblems[relay.url] ?? ""} />
                  <button className="trash-button" onClick={() => setRelays((current) => current.filter((item) => item.url !== relay.url))} aria-label={`删除 ${relay.url}`}><Icon name="trash" /></button>
                  {relayProblems[relay.url] && (
                    <p className="relay-problem" role="status">{relayProblems[relay.url]}</p>
                  )}
                </li>
              ))}
            </ul>
            <form className="add-relay" onSubmit={addRelay}>
              <label htmlFor="new-relay">添加资讯源</label>
              <div><input id="new-relay" value={newRelay} onChange={(event) => setNewRelay(event.target.value)} placeholder="wss:// 或 ws://" inputMode="url" autoCapitalize="none" autoCorrect="off" /><button type="submit" aria-label="添加资讯源"><Icon name="plus" /></button></div>
              <p className="field-hint">在 HTTPS 页面中,浏览器可能会拦截不加密的 ws:// 连接;客户端仍会保留该资讯源并显示实际连接状态。</p>
              {relayError && <p className="field-error">{relayError}</p>}
            </form>
            {(relayPicks || relayPicksLoading || relayPicksError) && (
              <section className="relay-picks" aria-label="每日推荐中继">
                <h3>每日推荐 <small>{relayPicks ? `${relayPicks.date} · 池中共 ${relayPicks.pool_size} 个` : ""}</small></h3>
                <p className="field-hint">每天从收集中继池里随机推荐 3 个可能感兴趣的中继,一键加入。</p>
                {relayPicksLoading && !relayPicks ? (
                  <p className="muted">正在获取今日推荐…</p>
                ) : relayPicksError && !relayPicks ? (
                  <p className="muted">
                    今日推荐暂时获取失败。
                    <button
                      type="button"
                      className="link-button"
                      onClick={() => setRelayPicksAttempt((n) => n + 1)}
                    >
                      重试
                    </button>
                  </p>
                ) : (
                  <ul className="relay-picks-list">
                    {(relayPicks?.picks ?? []).map((pick) => {
                      const added = relays.some((relay) => relay.url === pick.url);
                      return (
                        <li key={pick.url} className="relay-pick">
                          <div className="relay-pick-info">
                            <strong>{pick.name || relayLabel(pick.url)}</strong>
                            <code>{pick.url}</code>
                            {pick.description && <p>{pick.description}</p>}
                          </div>
                          {added
                            ? <span className="pick-added">已在列表</span>
                            : <button className="pick-add" onClick={() => void addRelayUrl(pick.url)}>加入</button>}
                        </li>
                      );
                    })}
                  </ul>
                )}
              </section>
            )}
          </aside>
        </div>
      )}

      {identityOpen && (
        <div className="sheet-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setIdentityOpen(false); }}>
          <aside className="relay-sheet" aria-label="身份与签名器" role="dialog" aria-modal="true">
            <div className="sheet-heading">
              <div><p className="section-index">IDENTITY</p><h2>身份与签名器</h2></div>
              <button className="icon-button" onClick={() => setIdentityOpen(false)} aria-label="关闭身份面板"><Icon name="close" /></button>
            </div>
            {isNapplet() ? (
              /* Napplet 版:签名一律走 host viewer 身份,无外部签名器选项。 */
              pubkey ? (
                <>
                  <p className="sheet-copy">已连接 host 身份,发帖、回复与关注将使用该身份签名。私钥永不进入本应用。</p>
                  <div className="relay-row">
                    <div className="relay-address"><strong>{shortKey(encodeNip19("npub", pubkey))}</strong><code>{encodeNip19("npub", pubkey)}</code></div>
                  </div>
                  <form className="add-relay" onSubmit={(event) => { event.preventDefault(); disconnectSigner(); }}>
                    <button type="submit">断开连接</button>
                  </form>
                </>
              ) : (
                <>
                  <p className="sheet-copy">发帖、回复与关注需要身份。本应用使用 host 提供的 viewer 身份签名,私钥始终不出 host。</p>
                  <form className="add-relay" onSubmit={(event) => { event.preventDefault(); void connectSigner().then((key) => { if (key) setIdentityOpen(false); }); }}>
                    <button type="submit">连接 host 身份</button>
                  </form>
                  {signerError && <p className="field-error">{signerError}</p>}
                </>
              )
            ) : pubkey ? (
              <>
                <p className="sheet-copy">已连接{signerType === "nip46" ? "远程签名器(NIP-46)" : "浏览器扩展(NIP-07)"},发帖、回复与关注将使用该身份签名。</p>
                <div className="relay-row">
                  <div className="relay-address"><strong>{shortKey(encodeNip19("npub", pubkey))}</strong><code>{encodeNip19("npub", pubkey)}</code></div>
                </div>
                <form className="add-relay" onSubmit={(event) => { event.preventDefault(); disconnectSigner(); }}>
                  <button type="submit">断开连接</button>
                </form>
              </>
            ) : (
              <>
                <p className="sheet-copy">发帖、回复与关注需要签名器。私钥始终不出签名器。</p>
                <section aria-label="浏览器扩展">
                  <h3>浏览器扩展(NIP-07)</h3>
                  <form className="add-relay" onSubmit={(event) => { event.preventDefault(); void connectSigner().then((key) => { if (key) setIdentityOpen(false); }); }}>
                    <button type="submit">连接扩展签名器</button>
                  </form>
                  {signerError && <p className="field-error">{signerError}</p>}
                </section>
                <section aria-label="远程签名器">
                  <h3>远程签名器(NIP-46)</h3>
                  <p className="field-hint">手机浏览器装不了插件时用这个:在签名器 App(如 nsec.app)里生成 bunker:// 连接串,粘贴到下面。</p>
                  <form className="add-relay" onSubmit={(event) => { event.preventDefault(); void connectNip46(nip46Uri); }}>
                    <label htmlFor="nip46-uri">签名器连接串</label>
                    <div><input id="nip46-uri" value={nip46Uri} onChange={(event) => setNip46Uri(event.target.value)} placeholder="bunker://…" inputMode="url" autoCapitalize="none" autoCorrect="off" autoComplete="off" /><button type="submit" aria-label="连接远程签名器" disabled={nip46Busy}><Icon name="plus" /></button></div>
                    {nip46Busy && <p className="field-hint">正在连接签名器…</p>}
                    {nip46Error && <p className="field-error">{nip46Error}</p>}
                  </form>
                </section>
              </>
            )}
          </aside>
        </div>
      )}

      {helpOpen && (
        <div className="sheet-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setHelpOpen(false); }}>
          <aside className="relay-sheet" aria-label="使用说明" role="dialog" aria-modal="true">
            <div className="sheet-heading">
              <div><p className="section-index">GUIDE</p><h2>使用说明</h2></div>
              <button className="icon-button" onClick={() => setHelpOpen(false)} aria-label="关闭使用说明"><Icon name="close" /></button>
            </div>
            <div className="help-body">
              <p>{SITE_NAME}是一个极简的 Nostr 帖子浏览器,从多个资讯源拉取公开帖子,去重后展示。你的私钥永远不会经过页面。</p>
              <h3>自动模式</h3>
              <p>帖子按时间倒序铺成固定网格:新帖子进入第一排第一列,其余内容依次向右、向下顺移,最早的一条在最后一格。页面不滚动,也没有切换动画。手机等窄屏上会自动竖分三列、整块等比缩小,点小块进入详情。</p>
              <h3>手动模式</h3>
              <p>自由滚动浏览全部帖子,点「刷新」获取新帖子。浏览模式的选择会自动记住。</p>
              <h3>资讯源</h3>
              <p>点左上角的在线状态打开资讯中继管理:可以开关、添加、删除地址,支持加密的 wss:// 与不加密的 ws://,修改后自动重连。连接失败时会在该资讯源下方显示原因(如被浏览器拦截、超时等)。</p>
              <h3>发帖与回复</h3>
              <p>需要连接签名器:桌面浏览器可安装 NIP-07 扩展(如 nos2x、Alby),手机浏览器装不了插件时可用「远程签名器(NIP-46)」——在签名器 App 里生成 bunker:// 连接串粘贴进来。点右上角钥匙图标连接。发帖和回复都经签名器签名后发布。点帖子进入详情可以看到它的对话:下面的回复按时间正序排列,点某条回复可以继续钻进去看它的回复。</p>
              <h3>关注</h3>
              <p>在帖子或作者页点「关注」,「关注」标签页只显示你关注的人的帖子,打开标签页时会自动向资讯源请求他们的最近帖子。打开作者页会自动向资讯源拉取他的历史帖子(每次最多 200 条),点「加载更早」可继续往前翻,能拉多少取决于资讯源保留了多少。</p>
              <h3>长文</h3>
              <p>「长文」标签页显示资讯源里的 NIP-23 长文(kind 30023),每源最多取 20 篇;同作者同标识的长文只保留最新版。点卡片进入全文阅读,支持标题、粗斜体、链接、列表、引用、代码块等排版,表格暂不支持。隐身模式下文章内的图片不会自动加载。</p>
              <h3>筛选</h3>
              <p>点标题栏的漏斗图标打开筛选:可以隐藏回复、按关键词屏蔽帖子,只影响本机展示,不改变订阅。只看关注的人请用「全部 / 关注」标签页。筛选条件会自动记住。</p>
              <h3>版本更新</h3>
              <p>点标题栏的下载图标会清空本地缓存并重新下载最新版,页面会自动重载。</p>
              <h3>离线使用</h3>
              <p>页面加载一次后会被完整缓存,断网或关闭浏览器后重新打开也能继续使用。</p>
              <h3>隐身模式</h3>
              <p>点标题栏的面具图标打开隐身模式:远程头像不再自动加载,只显示首字母,避免向头像服务器暴露你的浏览行为。缺省是普通模式,头像自动加载。选择会自动记住。</p>
            </div>
          </aside>
        </div>
      )}

      {filterOpen && (
        <div className="sheet-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setFilterOpen(false); }}>
          <aside className="relay-sheet" aria-label="筛选帖子" role="dialog" aria-modal="true">
            <div className="sheet-heading">
              <div><p className="section-index">FILTERS</p><h2>筛选</h2></div>
              <button className="icon-button" onClick={() => setFilterOpen(false)} aria-label="关闭筛选"><Icon name="close" /></button>
            </div>
            <p className="sheet-copy">只影响本机展示,不改变订阅。只看关注请用「全部 / 关注」标签页。</p>
            <label className="filter-row">
              <input
                type="checkbox"
                checked={filters.hideReplies}
                onChange={(event) => setFilters((prev) => ({ ...prev, hideReplies: event.target.checked }))}
              />
              <span><strong>隐藏回复</strong><small>不显示参与帖子串的回复(带 e 标签的帖子)</small></span>
            </label>
            <div className="filter-section">
              <strong>屏蔽关键词</strong>
              <small>帖子内容包含以下任意词(不区分大小写)即隐藏</small>
              <form
                className="keyword-add"
                onSubmit={(event) => { event.preventDefault(); addMutedKeyword(); }}
              >
                <input
                  type="text"
                  value={keywordDraft}
                  onChange={(event) => setKeywordDraft(event.target.value)}
                  placeholder="输入关键词,回车添加"
                  aria-label="屏蔽关键词"
                  maxLength={40}
                />
                <button type="submit">添加</button>
              </form>
              {filters.mutedKeywords.length > 0 ? (
                <ul className="keyword-list">
                  {filters.mutedKeywords.map((keyword) => (
                    <li key={keyword} className="keyword-chip">
                      <span>{keyword}</span>
                      <button onClick={() => removeMutedKeyword(keyword)} aria-label={`移除屏蔽词 ${keyword}`}>×</button>
                    </li>
                  ))}
                </ul>
              ) : (
                <p className="muted">还没有屏蔽词。</p>
              )}
            </div>
            {filtersActive(filters) && (
              <button className="filter-clear" onClick={clearFilters}>清除全部筛选</button>
            )}
          </aside>
        </div>
      )}

      {composerOpen && (
        <div className="sheet-backdrop composer-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) { setComposerOpen(false); setReplyTarget(null); } }}>
          <section className="composer-sheet" role="dialog" aria-modal="true" aria-labelledby="composer-title">
            <div className="sheet-heading">
              <div><p className="section-index">{replyTarget ? "REPLY" : "SIGNED NOTE"}</p><h2 id="composer-title">{replyTarget ? "回复帖子" : "发一条帖子"}</h2></div>
              <button className="icon-button" onClick={() => { setComposerOpen(false); setReplyTarget(null); }} aria-label="关闭发布器"><Icon name="close" /></button>
            </div>
            {replyTarget && (
              <div className="reply-context">
                <span className="reply-context-label">回复 {profileName(replyTarget.pubkey, profileCache)}</span>
                <p>{replyTarget.content.trim().slice(0, 140) || "(空文本)"}</p>
                <button className="reply-context-cancel" onClick={() => setReplyTarget(null)}>改为发普通帖子</button>
              </div>
            )}
            <form onSubmit={(event) => void publish(event)}>
              <label htmlFor="post-content">帖子内容</label>
              <textarea id="post-content" value={draft} onChange={(event) => setDraft(event.target.value)} maxLength={4000} autoFocus placeholder="写点什么…" />
              <div className="composer-footer">
                <span>{draft.length}/4000</span>
                <button className="publish-button" type="submit" disabled={!draft.trim()}>{pubkey ? "签名并发布" : "连接签名器并发布"}</button>
              </div>
              <p className="signing-note">{signerType === "nip46" ? "使用远程签名器(NIP-46);私钥不会交给此页面。" : "使用浏览器中的 NIP-07 签名器;私钥不会交给此页面。"}</p>
              {signerError && <p className="field-error">{signerError}</p>}
            </form>
          </section>
        </div>
      )}

      {detailEvent && (
        <div className="sheet-backdrop detail-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setDetailEventId(null); }}>
          <article className="detail-sheet" role="dialog" aria-modal="true" aria-labelledby="note-detail-title">
            <div className="sheet-heading">
              <div><p className="section-index">KIND 1 EVENT</p><h2 id="note-detail-title">帖子详情</h2></div>
              <button className="icon-button" onClick={() => setDetailEventId(null)} aria-label="关闭帖子详情"><Icon name="close" /></button>
            </div>
            <button className="detail-author" onClick={() => openProfile(detailEvent.pubkey)}>
              <AvatarMark pubkey={detailEvent.pubkey} profileCache={profileCache} incognito={incognitoMode} />
              <span><strong>{shortKey(encodeNip19("npub", detailEvent.pubkey))}</strong><small>查看这个作者的帖子</small></span>
            </button>
            <div className="detail-content"><FormattedNote content={detailEvent.content} /></div>
            <dl className="event-facts">
              <div><dt>发布时间</dt><dd>{new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(detailEvent.created_at * 1000))}</dd></div>
              <div><dt>来源资讯源</dt><dd>{detailEvent.relays.join("、")}</dd></div>
              <div><dt>事件 ID</dt><dd><code>{encodeNip19("note", detailEvent.id)}</code><button onClick={() => void copyValue(encodeNip19("note", detailEvent.id), "事件 ID")}>复制</button></dd></div>
              <div><dt>作者公钥</dt><dd><code>{encodeNip19("npub", detailEvent.pubkey)}</code><button onClick={() => void copyValue(encodeNip19("npub", detailEvent.pubkey), "作者公钥")}>复制</button></dd></div>
            </dl>
            <div className="detail-actions">
              <button className="reply-button" onClick={() => openReply(detailEvent)}><Icon name="reply" />回复这条帖子</button>
            </div>
            <section className="thread-section" aria-label="对话回复">
              <h3>对话{threadReplies && threadReplies.eventId === detailEvent.id && !threadReplies.loading ? `(${threadReplies.events.length} 条回复)` : ""}</h3>
              {!threadReplies || threadReplies.eventId !== detailEvent.id || threadReplies.loading ? (
                <p className="muted">{threadReplies && threadReplies.events.length > 0 ? `正在拉取回复…(已收到 ${threadReplies.events.length} 条)` : "正在从中继拉取回复…"}</p>
              ) : threadReplies.events.length === 0 ? (
                <p className="muted">暂无回复,来抢沙发。</p>
              ) : (
                <ol className="thread-list">
                  {threadReplies.events.map((reply) => (
                    <li key={reply.id} className="thread-reply">
                      <div className="thread-reply-meta">
                        <AvatarMark
                          pubkey={reply.pubkey}
                          profileCache={profileCache}
                          incognito={incognitoMode}
                          onClick={() => openProfile(reply.pubkey)}
                          ariaLabel={`查看作者 ${profileName(reply.pubkey, profileCache)}`}
                        />
                        <button className="author" onClick={() => openProfile(reply.pubkey)} title={reply.pubkey}>{profileName(reply.pubkey, profileCache)}</button>
                        <time dateTime={new Date(reply.created_at * 1000).toISOString()}>{relativeTime(reply.created_at)}</time>
                      </div>
                      <button className="thread-reply-body" onClick={() => openNote(reply.id)} aria-label={`查看回复 ${shortKey(encodeNip19("note", reply.id))}`}>
                        <FormattedNote content={reply.content} />
                      </button>
                      <div className="thread-reply-actions">
                        <button className="reply-button" onClick={() => openReply(reply)} aria-label={`回复 ${profileName(reply.pubkey, profileCache)}`}><Icon name="reply" />回复</button>
                      </div>
                    </li>
                  ))}
                </ol>
              )}
            </section>
            {copyMessage && <p className="copy-status" role="status">{copyMessage}</p>}
          </article>
        </div>
      )}

      {longformDetail && longformDetailMeta && (
        <div className="sheet-backdrop detail-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setLongformDetailId(null); }}>
          <article className="detail-sheet longform-sheet" role="dialog" aria-modal="true" aria-labelledby="longform-detail-title">
            <div className="sheet-heading">
              <div><p className="section-index">KIND 30023 LONGFORM</p><h2 id="longform-detail-title">{longformDetailMeta.title || "长文"}</h2></div>
              <button className="icon-button" onClick={() => setLongformDetailId(null)} aria-label="关闭长文"><Icon name="close" /></button>
            </div>
            <button className="detail-author" onClick={() => { setLongformDetailId(null); openProfile(longformDetail.pubkey); }}>
              <AvatarMark pubkey={longformDetail.pubkey} profileCache={profileCache} incognito={incognitoMode} />
              <span><strong>{profileName(longformDetail.pubkey, profileCache)}</strong><small>查看这个作者的帖子</small></span>
            </button>
            {longformDetailMeta.image && isSafeHttpUrl(longformDetailMeta.image) && (
              incognitoMode ? (
                <p className="longform-cover-hint"><a href={longformDetailMeta.image} target="_blank" rel="noreferrer">🖼 查看封面图(隐身模式未加载)</a></p>
              ) : (
                <img className="longform-cover" src={longformDetailMeta.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
              )
            )}
            <div className="longform-body" dangerouslySetInnerHTML={{ __html: longformDetailHtml }} />
            <dl className="event-facts">
              <div><dt>发布时间</dt><dd>{new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date((longformDetailMeta.publishedAt ?? longformDetail.created_at) * 1000))}</dd></div>
              <div><dt>来源资讯源</dt><dd>{longformDetail.relays.join("、")}</dd></div>
              <div><dt>事件 ID</dt><dd><code>{encodeNip19("note", longformDetail.id)}</code><button onClick={() => void copyValue(encodeNip19("note", longformDetail.id), "事件 ID")}>复制</button></dd></div>
              <div><dt>作者公钥</dt><dd><code>{encodeNip19("npub", longformDetail.pubkey)}</code><button onClick={() => void copyValue(encodeNip19("npub", longformDetail.pubkey), "作者公钥")}>复制</button></dd></div>
            </dl>
            {copyMessage && <p className="copy-status" role="status">{copyMessage}</p>}
          </article>
        </div>
      )}

      {profilePubkey && (
        <div className="sheet-backdrop detail-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setProfilePubkey(null); }}>
          <section className="detail-sheet" role="dialog" aria-modal="true" aria-labelledby="profile-title">
            <div className="sheet-heading">
              <div><p className="section-index">PUBLIC KEY</p><h2 id="profile-title">作者</h2></div>
              <button className="icon-button" onClick={() => setProfilePubkey(null)} aria-label="关闭作者详情"><Icon name="close" /></button>
            </div>
            <div className="profile-identity">
              <AvatarMark pubkey={profilePubkey} profileCache={profileCache} incognito={incognitoMode} large />
              <div className="profile-names">
                <strong>{profileName(profilePubkey, profileCache)}</strong>
                {profileDetail?.nip05 && <small className="profile-nip05">{profileDetail.nip05}</small>}
                {externalProfilePicture && <a className="profile-picture-link" href={externalProfilePicture} target="_blank" rel="noreferrer">查看外部头像</a>}
              </div>
              {profileDetail?.about && <p className="profile-about">{profileDetail.about}</p>}
              <code>{encodeNip19("npub", profilePubkey)}</code>
              <div className="profile-actions">
                <button className={`follow-button${isFollowing ? " following" : ""}`} onClick={() => void toggleFollow(profilePubkey)}>
                  {isFollowing ? "✓ 已关注" : "+ 关注"}
                </button>
                <button onClick={() => void copyValue(encodeNip19("npub", profilePubkey), "公钥")}>复制完整 npub</button>
              </div>
              {followMessage && <p className="copy-status" role="status">{followMessage}</p>}
            </div>
            {copyMessage && <p className="copy-status" role="status">{copyMessage}</p>}
            <div className="profile-posts">
              <h3>帖子 <span>{profileEvents.length}</span></h3>
              {profilePubkey && authorHistory?.pubkey === profilePubkey && authorHistory.loading && profileEvents.length === 0 ? (
                <p className="profile-history-hint">正在从资讯源拉取他的历史帖子…</p>
              ) : (
                profileEvents.map((item) => (
                  <button key={item.id} onClick={() => openNote(item.id)}>
                    <span>{item.content.trim() || "(空文本)"}</span>
                    <small>{relativeTime(item.created_at)} · {shortKey(encodeNip19("note", item.id))}</small>
                  </button>
                ))
              )}
              {profilePubkey && authorHistory?.pubkey === profilePubkey && !authorHistory.loading && authorHistory.hasMore && (
                <button
                  className="load-more-button"
                  disabled={authorHistory.loadingMore}
                  onClick={loadMoreAuthorHistory}
                >
                  {authorHistory.loadingMore ? "加载中…" : "加载更早的帖子"}
                </button>
              )}
            </div>
          </section>
        </div>
      )}

      {followsOpen && (
        <div className="sheet-backdrop detail-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setFollowsOpen(false); }}>
          <section className="detail-sheet" role="dialog" aria-modal="true" aria-labelledby="follows-title">
            <div className="sheet-heading">
              <div><p className="section-index">FOLLOWING</p><h2 id="follows-title">关注列表</h2></div>
              <button className="icon-button" onClick={() => setFollowsOpen(false)} aria-label="关闭关注列表"><Icon name="close" /></button>
            </div>
            <p className="sheet-copy">{pubkey ? "修改会经签名器签名,发布 kind-3 关注列表到资讯源。" : "未连接签名器,关注仅保存在本浏览器。"}</p>
            {followMessage && <p className="copy-status" role="status">{followMessage}</p>}
            {follows.length === 0 ? (
              <p className="muted">还没有关注任何人。在帖子或作者页点「关注」即可添加。</p>
            ) : (
              <ul className="follow-list">
                {follows.map((followed) => (
                  <li key={followed} className="follow-row">
                    <button
                      className="follow-identity"
                      onClick={() => { setFollowsOpen(false); openProfile(followed); }}
                      aria-label={`查看 ${profileName(followed, profileCache)}`}
                    >
                      <AvatarMark pubkey={followed} profileCache={profileCache} incognito={incognitoMode} />
                      <span className="follow-names">
                        <strong>{profileName(followed, profileCache)}</strong>
                        <small>{shortKey(encodeNip19("npub", followed))}</small>
                      </span>
                    </button>
                    <button className="unfollow-button" onClick={() => void toggleFollow(followed)}>取消关注</button>
                  </li>
                ))}
              </ul>
            )}
          </section>
        </div>
      )}
    </div>
  );
}