index.html raw

   1  <!DOCTYPE html>
   2  <html lang="en">
   3  <head>
   4  <meta charset="utf-8">
   5  <meta name="viewport" content="width=device-width, initial-scale=1">
   6  <title>smesh</title>
   7  <link rel="icon" href="./favicon.ico" sizes="48x48" />
   8  <link rel="icon" href="./favicon.png" sizes="256x256" type="image/png" />
   9  <link rel="icon" href="./favicon-96x96.png" sizes="96x96" type="image/png" />
  10  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
  11  <style>
  12  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  13  
  14  :root {
  15    --bg: #111; --bg2: #1a1a1a; --fg: #e0e0e0; --fg2: #888;
  16    --accent: #f59e0b; --border: #333; --radius: 6px;
  17    --font: system-ui, -apple-system, sans-serif;
  18    --mono: 'SF Mono', 'Fira Code', monospace;
  19  }
  20  
  21  @media (prefers-color-scheme: light) {
  22    :root {
  23      --bg: #fff; --bg2: #f5f5f5; --fg: #111; --fg2: #666;
  24      --accent: #d97706; --border: #ddd;
  25    }
  26  }
  27  
  28  body { font-family: var(--font); background: var(--bg); color: var(--fg); height: 100dvh; overflow: hidden; }
  29  
  30  /* layout */
  31  #app { display: flex; height: 100dvh; }
  32  .sidebar { width: auto; min-width: 0; background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
  33  .main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
  34  
  35  /* sidebar */
  36  .profile-area { padding: 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; cursor: pointer; }
  37  .profile-area:hover { background: var(--border); }
  38  .avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--border); object-fit: cover; }
  39  .profile-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  40  .profile-npub { font-size: 11px; color: var(--fg2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  41  
  42  .feed-list { flex: 1; overflow-y: auto; padding: 8px; }
  43  .feed-item { padding: 8px 12px; border-radius: var(--radius); cursor: pointer; font-size: 14px; margin-bottom: 2px; }
  44  .feed-item:hover { background: var(--border); }
  45  .feed-item.active { background: var(--accent); color: #000; font-weight: 600; }
  46  
  47  .sidebar-logout { padding: 8px 12px; font-size: 13px; color: var(--fg2); cursor: pointer; border-top: 1px solid var(--border); }
  48  .sidebar-logout:hover { color: var(--fg); background: var(--border); }
  49  .relay-status { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px; color: var(--fg2); }
  50  .relay-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; }
  51  .relay-dot.on { background: #22c55e; }
  52  .relay-dot.off { background: #ef4444; }
  53  
  54  /* main content */
  55  .toolbar { padding: 12px; border-bottom: 1px solid var(--border); font-size: 16px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
  56  .toolbar-reload { background: none; border: none; color: var(--fg2); cursor: pointer; font-size: 18px; padding: 4px; line-height: 1; }
  57  .toolbar-reload:hover { color: var(--fg); }
  58  
  59  .feed { flex: 1; overflow-y: auto; padding: 0; }
  60  .note { padding: 12px 16px; border-bottom: 1px solid var(--border); }
  61  .note-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
  62  .note-author { font-weight: 600; font-size: 14px; }
  63  .note-time { font-size: 12px; color: var(--fg2); margin-left: auto; }
  64  .note-content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
  65  .note-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--border); object-fit: cover; flex-shrink: 0; }
  66  
  67  .compose { padding: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
  68  .compose textarea { flex: 1; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-family: var(--font); font-size: 14px; resize: none; min-height: 40px; max-height: 120px; }
  69  .compose textarea:focus { outline: none; border-color: var(--accent); }
  70  .compose button { background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 8px 16px; font-weight: 600; cursor: pointer; font-size: 14px; align-self: flex-end; }
  71  .compose button:hover { opacity: 0.9; }
  72  .compose button:disabled { opacity: 0.4; cursor: not-allowed; }
  73  
  74  /* login */
  75  .login-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100dvh; gap: 16px; }
  76  .login-screen button { background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 12px 24px; font-size: 16px; font-weight: 600; cursor: pointer; }
  77  
  78  /* smesh loader animation */
  79  .smesh-loader { width: 180px; height: 180px; }
  80  .smesh-loader-edge { stroke-linecap: round; opacity: 0; animation: smeshEdgeFade 0.4s ease forwards; }
  81  .smesh-loader-center { opacity: 0; animation: smeshNodePop 0.3s ease forwards; transform-origin: 400px 400px; }
  82  @keyframes smeshEdgeFade { to { opacity: 1; } }
  83  @keyframes smeshNodePop { 0% { opacity: 0; transform: scale(0); } 70% { transform: scale(1.2); } 100% { opacity: 1; transform: scale(1); } }
  84  
  85  /* settings */
  86  .settings { padding: 16px; overflow-y: auto; flex: 1; }
  87  .settings h2 { font-size: 16px; margin-bottom: 12px; }
  88  .settings section { margin-bottom: 24px; }
  89  .relay-input { display: flex; gap: 8px; margin-bottom: 8px; }
  90  .relay-input input { flex: 1; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-size: 14px; }
  91  .relay-input button { background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 8px 12px; font-weight: 600; cursor: pointer; }
  92  .relay-list-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; font-size: 14px; font-family: var(--mono); }
  93  .relay-list-item button { background: none; border: none; color: var(--fg2); cursor: pointer; font-size: 16px; }
  94  
  95  /* thread view */
  96  .thread-back { padding: 8px 12px; cursor: pointer; color: var(--accent); font-size: 14px; border-bottom: 1px solid var(--border); }
  97  .thread-back:hover { background: var(--bg2); }
  98  .thread-root { border-left: 3px solid var(--accent); }
  99  .thread-reply { border-left: 2px solid var(--border); }
 100  .note-actions { display: flex; gap: 16px; margin-top: 6px; }
 101  .note-action { font-size: 12px; color: var(--fg2); cursor: pointer; display: flex; align-items: center; gap: 4px; }
 102  .note-action:hover { color: var(--accent); }
 103  .header-action { margin-left: 0; }
 104  
 105  /* reply compose */
 106  .reply-compose { margin-top: 8px; }
 107  .reply-input { width: 100%; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-family: var(--font); font-size: 14px; resize: none; min-height: 60px; max-height: 120px; }
 108  .reply-input:focus { outline: none; border-color: var(--accent); }
 109  .reply-buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
 110  .reply-cancel { background: none; border: 1px solid var(--border); color: var(--fg2); border-radius: var(--radius); padding: 4px 12px; font-size: 13px; cursor: pointer; }
 111  .reply-cancel:hover { border-color: var(--fg2); }
 112  .reply-submit { background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 4px 12px; font-size: 13px; font-weight: 600; cursor: pointer; }
 113  .reply-submit:hover { opacity: 0.9; }
 114  .reply-submit:disabled { opacity: 0.4; cursor: not-allowed; }
 115  .orly-badge { display: inline-block; background: var(--accent); color: #000; font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 700; margin-left: 6px; vertical-align: middle; }
 116  
 117  /* snackbar */
 118  .snackbar { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--fg); color: var(--bg); padding: 10px 20px; border-radius: var(--radius); font-size: 14px; z-index: 999; animation: fadein 0.3s; }
 119  @keyframes fadein { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
 120  
 121  /* new notes pill */
 122  .new-notes-pill { position: sticky; top: 8px; left: 50%; transform: translateX(-50%); width: fit-content; background: var(--accent); color: #000; padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; cursor: pointer; z-index: 5; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
 123  .new-notes-pill:hover { opacity: 0.9; }
 124  
 125  /* rich content */
 126  .rich-link { color: var(--accent); text-decoration: none; word-break: break-all; }
 127  .rich-link:hover { text-decoration: underline; }
 128  .rich-image { max-width: 100%; max-height: 400px; border-radius: var(--radius); margin: 6px 0; display: block; cursor: pointer; }
 129  .rich-video { max-width: 100%; max-height: 400px; border-radius: var(--radius); margin: 6px 0; display: block; }
 130  .rich-mention { color: var(--accent); cursor: pointer; font-weight: 600; }
 131  .rich-mention:hover { text-decoration: underline; }
 132  
 133  /* embedded notes */
 134  .embedded-note { border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; margin: 6px 0; background: var(--bg2); cursor: pointer; }
 135  .embedded-note:hover { border-color: var(--accent); }
 136  .embedded-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
 137  .embedded-content { font-size: 13px; color: var(--fg2); line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
 138  .embedded-loading, .embedded-missing { color: var(--fg2); font-size: 13px; font-style: italic; }
 139  
 140  /* repost */
 141  .repost-label { font-size: 12px; color: var(--fg2); margin-bottom: 4px; padding-left: 36px; }
 142  
 143  /* lightbox */
 144  .lightbox-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 100; display: flex; align-items: center; justify-content: center; }
 145  .lightbox-close { position: fixed; top: 12px; right: 16px; background: none; border: none; color: #fff; font-size: 28px; cursor: pointer; z-index: 101; }
 146  .lightbox-img { max-width: 95vw; max-height: 90vh; object-fit: contain; touch-action: pinch-zoom; }
 147  
 148  /* hashtag feed */
 149  .hashtag-input { width: 100%; background: var(--bg2); color: var(--fg); border: none; border-bottom: 1px solid var(--border); padding: 10px 12px; font-size: 14px; font-family: var(--font); outline: none; }
 150  .hashtag-input:focus { border-color: var(--accent); }
 151  
 152  /* DMs */
 153  .dm-list { flex: 1; overflow-y: auto; }
 154  .dm-list-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
 155  .dm-list-item:hover { background: var(--bg2); }
 156  .dm-preview { flex: 1; min-width: 0; }
 157  .dm-preview-name { font-size: 14px; font-weight: 600; }
 158  .dm-preview-text { font-size: 13px; color: var(--fg2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 159  .dm-preview-time { font-size: 11px; color: var(--fg2); flex-shrink: 0; }
 160  .dm-chat { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
 161  .dm-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 4px; }
 162  .dm-bubble { max-width: 75%; padding: 8px 12px; border-radius: 12px; font-size: 14px; line-height: 1.4; word-break: break-word; white-space: pre-wrap; }
 163  .dm-bubble.mine { background: var(--accent); color: #000; align-self: flex-end; border-bottom-right-radius: 4px; }
 164  .dm-bubble.theirs { background: var(--bg2); align-self: flex-start; border-bottom-left-radius: 4px; }
 165  .dm-protocol { font-size: 10px; color: var(--fg2); margin-top: 2px; }
 166  .dm-protocol.legacy { color: #ef4444; }
 167  .dm-time { font-size: 10px; color: var(--fg2); margin-top: 1px; }
 168  .dm-compose { padding: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
 169  .dm-compose textarea { flex: 1; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-family: var(--font); font-size: 14px; resize: none; min-height: 40px; max-height: 100px; }
 170  .dm-compose textarea:focus { outline: none; border-color: var(--accent); }
 171  .dm-compose button { background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 8px 16px; font-weight: 600; cursor: pointer; font-size: 14px; align-self: flex-end; }
 172  .dm-compose button:disabled { opacity: 0.4; cursor: not-allowed; }
 173  .dm-new-input { padding: 10px 12px; border-bottom: 1px solid var(--border); }
 174  .dm-new-input input { width: 100%; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; font-size: 13px; font-family: var(--mono); }
 175  .dm-new-input input:focus { outline: none; border-color: var(--accent); }
 176  
 177  /* loading */
 178  .loading { display: flex; align-items: center; justify-content: center; height: 100dvh; color: var(--fg2); }
 179  
 180  /* mobile */
 181  @media (max-width: 600px) {
 182    .sidebar { position: fixed; left: -100%; z-index: 10; height: 100dvh; transition: left 0.2s; width: auto; }
 183    .sidebar.open { left: 0; }
 184    .sidebar-toggle { display: block; padding: 8px 12px; cursor: pointer; font-size: 20px; }
 185  }
 186  @media (min-width: 601px) {
 187    .sidebar-toggle { display: none; }
 188  }
 189  </style>
 190  </head>
 191  <body>
 192  <div id="app"><div class="loading">loading...</div></div>
 193  
 194  <script type="module">
 195  import { h, render, createContext } from 'https://esm.sh/preact@10.25.4'
 196  import { useState, useEffect, useCallback, useContext, useRef, useMemo } from 'https://esm.sh/preact@10.25.4/hooks'
 197  import htm from 'https://esm.sh/htm@3.1.1'
 198  import { decode as nip19Decode } from 'https://esm.sh/nostr-tools@2.17.0/nip19'
 199  import { schnorr } from 'https://esm.sh/@noble/curves@1.8.2/secp256k1'
 200  import { bytesToHex } from 'https://esm.sh/@noble/hashes@1.7.2/utils'
 201  const html = htm.bind(h)
 202  
 203  // ─── service worker ──────────────────────────────────────────────────
 204  
 205  let swReady = false
 206  
 207  function send(msg) {
 208    if (navigator.serviceWorker.controller) {
 209      navigator.serviceWorker.controller.postMessage(msg)
 210    }
 211  }
 212  
 213  // ─── SW signing bridge ──────────────────────────────────────────────
 214  
 215  let signCounter = 0
 216  const signCallbacks = new Map()
 217  
 218  function signViaSW(event) {
 219    return new Promise((resolve, reject) => {
 220      const id = ++signCounter
 221      const timeout = setTimeout(() => {
 222        signCallbacks.delete(id)
 223        reject(new Error('sign timeout'))
 224      }, 10000)
 225      signCallbacks.set(id, (signed) => {
 226        clearTimeout(timeout)
 227        resolve(signed)
 228      })
 229      send(['SIGN', id, event])
 230    })
 231  }
 232  
 233  // ─── extension mode crypto bridge ─────────────────────────────────
 234  
 235  async function handleCryptoRequest(type, reqId, pubkey, text) {
 236    if (!window.nostr) {
 237      send(['CRYPTO_RESULT', reqId, null, 'no extension'])
 238      return
 239    }
 240    try {
 241      let result
 242      if (type === 'DECRYPT_NIP04') {
 243        result = await window.nostr.nip04.decrypt(pubkey, text)
 244      } else if (type === 'ENCRYPT_NIP04') {
 245        result = await window.nostr.nip04.encrypt(pubkey, text)
 246      } else if (type === 'DECRYPT_NIP44') {
 247        result = await window.nostr.nip44.decrypt(pubkey, text)
 248      } else if (type === 'ENCRYPT_NIP44') {
 249        result = await window.nostr.nip44.encrypt(pubkey, text)
 250      }
 251      send(['CRYPTO_RESULT', reqId, result, null])
 252    } catch (err) {
 253      send(['CRYPTO_RESULT', reqId, null, err.message])
 254    }
 255  }
 256  
 257  // ─── nsec encryption (PBKDF2 + AES-256-GCM) ────────────────────────
 258  
 259  async function deriveKeyPBKDF2(password, salt) {
 260    const enc = new TextEncoder()
 261    const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
 262    return crypto.subtle.deriveKey(
 263      { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
 264      keyMaterial,
 265      { name: 'AES-GCM', length: 256 },
 266      false,
 267      ['encrypt', 'decrypt']
 268    )
 269  }
 270  
 271  async function encryptNsec(nsec, password) {
 272    const salt = crypto.getRandomValues(new Uint8Array(32))
 273    const iv = crypto.getRandomValues(new Uint8Array(12))
 274    const key = await deriveKeyPBKDF2(password, salt)
 275    const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(nsec))
 276    const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength)
 277    combined.set(salt, 0)
 278    combined.set(iv, salt.length)
 279    combined.set(new Uint8Array(encrypted), salt.length + iv.length)
 280    return btoa(String.fromCharCode(...combined))
 281  }
 282  
 283  async function decryptNsec(encryptedData, password) {
 284    const combined = new Uint8Array(atob(encryptedData).split('').map((c) => c.charCodeAt(0)))
 285    const salt = combined.slice(0, 32)
 286    const iv = combined.slice(32, 44)
 287    const ciphertext = combined.slice(44)
 288    const key = await deriveKeyPBKDF2(password, salt)
 289    const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
 290    return new TextDecoder().decode(decrypted)
 291  }
 292  
 293  function decodeNsec(nsec) {
 294    const decoded = nip19Decode(nsec)
 295    if (decoded.type !== 'nsec') throw new Error('not an nsec')
 296    return decoded.data // Uint8Array(32)
 297  }
 298  
 299  function pubkeyFromSecret(secretKeyBytes) {
 300    const pubBytes = schnorr.getPublicKey(secretKeyBytes)
 301    return bytesToHex(pubBytes)
 302  }
 303  
 304  // ─── nostr helpers ───────────────────────────────────────────────────
 305  
 306  const PROFILE_RELAYS = [
 307    'wss://relay.damus.io',
 308    'wss://relay.nostr.net',
 309    'wss://nos.lol',
 310    'wss://purplepag.es',
 311    'wss://relay.snort.social',
 312    'wss://relay.primal.net',
 313    'wss://offchain.pub',
 314    'wss://nostr.wine',
 315    'wss://relay.noswhere.com',
 316    'wss://nostr-pub.wellorder.net',
 317  ]
 318  
 319  function profileRelays(userRelays) {
 320    return [...new Set([...userRelays.slice(0, 3), ...PROFILE_RELAYS])]
 321  }
 322  
 323  function shortId(hex) {
 324    if (!hex) return '?'
 325    return hex.slice(0, 8) + '...' + hex.slice(-4)
 326  }
 327  
 328  function relativeTime(ts) {
 329    const diff = Math.floor(Date.now() / 1000) - ts
 330    if (diff < 60) return 'now'
 331    if (diff < 3600) return Math.floor(diff / 60) + 'm'
 332    if (diff < 86400) return Math.floor(diff / 3600) + 'h'
 333    return Math.floor(diff / 86400) + 'd'
 334  }
 335  
 336  function parseProfile(event) {
 337    try { return JSON.parse(event.content) } catch { return {} }
 338  }
 339  
 340  // ─── content parser ──────────────────────────────────────────────────
 341  
 342  const IMAGE_RE = /\.(jpe?g|png|gif|webp|svg)(\?[^\s]*)?$/i
 343  const VIDEO_RE = /\.(mp4|webm|mov)(\?[^\s]*)?$/i
 344  
 345  function parseContent(text) {
 346    if (!text) return [{ t: 'text', v: '' }]
 347    const TOKEN = /(https?:\/\/[^\s<>"]+)|(nostr:(npub1|note1|nevent1|nprofile1|naddr1)[a-z0-9]+)/gi
 348    const segments = []
 349    let last = 0, m
 350    while ((m = TOKEN.exec(text)) !== null) {
 351      if (m.index > last) segments.push({ t: 'text', v: text.slice(last, m.index) })
 352      if (m[1]) {
 353        const url = m[1].replace(/[.,;:!?)]+$/, '')
 354        segments.push(IMAGE_RE.test(url) ? { t: 'image', url } : VIDEO_RE.test(url) ? { t: 'video', url } : { t: 'link', url })
 355        last = m.index + url.length
 356        TOKEN.lastIndex = last
 357      } else if (m[2]) {
 358        const raw = m[2], bech32 = raw.slice(6)
 359        try {
 360          const d = nip19Decode(bech32)
 361          if (d.type === 'npub') segments.push({ t: 'mention', pubkey: d.data })
 362          else if (d.type === 'nprofile') segments.push({ t: 'mention', pubkey: d.data.pubkey, relays: d.data.relays })
 363          else if (d.type === 'note') segments.push({ t: 'noteref', id: d.data })
 364          else if (d.type === 'nevent') segments.push({ t: 'noteref', id: d.data.id, relays: d.data.relays })
 365          else if (d.type === 'naddr') segments.push({ t: 'addrref', kind: d.data.kind, pubkey: d.data.pubkey, d: d.data.identifier, relays: d.data.relays })
 366          else segments.push({ t: 'text', v: raw })
 367        } catch { segments.push({ t: 'text', v: raw }) }
 368        last = m.index + raw.length
 369        TOKEN.lastIndex = last
 370      }
 371    }
 372    if (last < text.length) segments.push({ t: 'text', v: text.slice(last) })
 373    return segments
 374  }
 375  
 376  // ─── app context ─────────────────────────────────────────────────────
 377  
 378  const AppContext = createContext()
 379  
 380  function useApp() { return useContext(AppContext) }
 381  
 382  // ─── rich content components ─────────────────────────────────────────
 383  
 384  function EmbeddedNote({ eventId, relays, embedDepth }) {
 385    const { state, dispatch } = useApp()
 386    const [fetched, setFetched] = useState(false)
 387  
 388    const event = state.embeddedNotes?.get(eventId)
 389      || state.feed.find((e) => e.id === eventId)
 390      || state.hashtagFeed?.find((e) => e.id === eventId)
 391      || state.relayFeed?.find((e) => e.id === eventId)
 392      || null
 393  
 394    useEffect(() => {
 395      if (event || fetched) return
 396      setFetched(true)
 397      const subId = 'embed-' + eventId.slice(0, 12)
 398      const fetchRelays = relays?.length ? relays : state.relays.slice(0, 3)
 399      send(['PROXY', subId, { ids: [eventId], limit: 1 }, ...fetchRelays])
 400    }, [eventId, event, fetched])
 401  
 402    if (!event) return html`<div class="embedded-note embedded-loading">loading note...</div>`
 403  
 404    const profile = state.profiles.get(event.pubkey) || {}
 405    const nextDepth = (embedDepth || 0) + 1
 406    const text = typeof event.content === 'string' ? event.content : ''
 407  
 408    return html`
 409      <div class="embedded-note" onClick=${(e) => { e.stopPropagation(); dispatch({ type: 'OPEN_THREAD', eventId: event.id, event }) }}>
 410        <div class="embedded-header">
 411          ${profile.picture
 412            ? html`<img class="note-avatar" src=${profile.picture} />`
 413            : html`<div class="note-avatar" />`}
 414          <span class="note-author" style="font-size:13px">${profile.name || shortId(event.pubkey)}</span>
 415          <span class="note-time">${relativeTime(event.created_at)}</span>
 416        </div>
 417        <div class="embedded-content">
 418          <${RichContent} content=${text} embedDepth=${nextDepth} />
 419        </div>
 420      </div>
 421    `
 422  }
 423  
 424  function RichContent({ content, embedDepth }) {
 425    const { state, dispatch } = useApp()
 426    const depth = embedDepth || 0
 427    const segments = useMemo(() => parseContent(content), [content])
 428  
 429    return html`<div class="note-content">${segments.map((seg) => {
 430      switch (seg.t) {
 431        case 'text':
 432          return seg.v
 433        case 'link':
 434          return html`<a href=${seg.url} target="_blank" rel="noopener noreferrer" class="rich-link">${seg.url.replace(/^https?:\/\//, '').slice(0, 60)}${seg.url.replace(/^https?:\/\//, '').length > 60 ? '...' : ''}</a>`
 435        case 'image':
 436          return html`<img src=${seg.url} class="rich-image" loading="lazy" onClick=${(e) => { e.stopPropagation(); dispatch({ type: 'OPEN_LIGHTBOX', url: seg.url }) }} />`
 437        case 'video':
 438          return html`<video src=${seg.url} controls preload="metadata" class="rich-video" />`
 439        case 'mention': {
 440          const p = state.profiles.get(seg.pubkey)
 441          return html`<span class="rich-mention">@${p?.name || shortId(seg.pubkey)}</span>`
 442        }
 443        case 'noteref':
 444          if (depth >= 2) return html`<span class="rich-mention">note:${seg.id.slice(0, 8)}...</span>`
 445          return html`<${EmbeddedNote} eventId=${seg.id} relays=${seg.relays} embedDepth=${depth} />`
 446        case 'addrref':
 447          if (depth >= 2) return html`<span class="rich-mention">addr:${(seg.d || '').slice(0, 8)}...</span>`
 448          return html`<${EmbeddedNote} eventId=${seg.d} relays=${seg.relays} embedDepth=${depth} />`
 449        default:
 450          return ''
 451      }
 452    })}</div>`
 453  }
 454  
 455  function Lightbox() {
 456    const { state, dispatch } = useApp()
 457    if (!state.lightboxUrl) return null
 458  
 459    const close = () => dispatch({ type: 'CLOSE_LIGHTBOX' })
 460  
 461    useEffect(() => {
 462      const handler = (e) => { if (e.key === 'Escape') close() }
 463      window.addEventListener('keydown', handler)
 464      return () => window.removeEventListener('keydown', handler)
 465    }, [])
 466  
 467    return html`
 468      <div class="lightbox-overlay" onClick=${close}>
 469        <button class="lightbox-close" onClick=${close}>✕</button>
 470        <img src=${state.lightboxUrl} class="lightbox-img" onClick=${(e) => e.stopPropagation()} />
 471      </div>
 472    `
 473  }
 474  
 475  // ─── components ──────────────────────────────────────────────────────
 476  
 477  function Sidebar({ sidebarOpen, setSidebarOpen }) {
 478    const { state, dispatch } = useApp()
 479    const profile = state.profile || {}
 480  
 481    return html`
 482      <div class="sidebar ${sidebarOpen ? 'open' : ''}">
 483        <div class="profile-area" onClick=${() => dispatch({ type: 'SET_TAB', tab: 'profile' })}>
 484          ${profile.picture
 485            ? html`<img class="avatar" src=${profile.picture} />`
 486            : html`<div class="avatar" />`}
 487          <div>
 488            <div class="profile-name">${profile.name || 'anon'}</div>
 489          </div>
 490        </div>
 491  
 492        <div class="feed-list">
 493          <div class="feed-item ${state.activeTab === 'feed' ? 'active' : ''}"
 494               onClick=${() => dispatch({ type: 'SET_TAB', tab: 'feed' })}>
 495            Following
 496          </div>
 497          <div class="feed-item ${state.activeTab === 'dms' ? 'active' : ''}"
 498               onClick=${() => dispatch({ type: 'SET_TAB', tab: 'dms' })}>
 499            DMs
 500          </div>
 501          <div class="feed-item ${state.activeTab === 'relays' ? 'active' : ''}"
 502               onClick=${() => dispatch({ type: 'SET_TAB', tab: 'relays' })}>
 503            Relays
 504          </div>
 505          <div class="feed-item ${state.activeTab === 'hashtags' ? 'active' : ''}"
 506               onClick=${() => dispatch({ type: 'SET_TAB', tab: 'hashtags' })}>
 507            Hashtags
 508          </div>
 509          <div class="feed-item ${state.activeTab === 'settings' ? 'active' : ''}"
 510               onClick=${() => dispatch({ type: 'SET_TAB', tab: 'settings' })}>
 511            Settings
 512          </div>
 513        </div>
 514  
 515        ${state.pubkey && html`
 516          <div class="sidebar-logout" onClick=${() => {
 517            send(['CLEAR_KEY'])
 518            localStorage.removeItem('smesh2-enc')
 519            localStorage.removeItem('smesh2-pubkey')
 520            localStorage.removeItem('smesh2-loginMode')
 521            location.reload()
 522          }}>logout</div>
 523        `}
 524  
 525        <div class="relay-status">
 526          ${(state.relays || []).map((r) => html`
 527            <div><span class="relay-dot on" />${r.replace('wss://', '')}</div>
 528          `)}
 529          ${(!state.relays || state.relays.length === 0) && html`<div>no relays configured</div>`}
 530        </div>
 531      </div>
 532    `
 533  }
 534  
 535  function Note({ event, isRoot, isReply, depth, inThread }) {
 536    const { state } = useApp()
 537  
 538    // kind 6 repost: unwrap inner event or show embedded reference
 539    if (event.kind === 6) {
 540      let inner = null
 541      try { inner = JSON.parse(event.content) } catch {}
 542      const repostProfile = state.profiles.get(event.pubkey) || {}
 543      const repostName = repostProfile.name || shortId(event.pubkey)
 544      if (inner && inner.id) {
 545        return html`
 546          <div class="note" style=${{ paddingTop: '4px' }}>
 547            <div class="repost-label">⟳ reposted by ${repostName}</div>
 548            <${NoteInner} event=${inner} isRoot=${isRoot} isReply=${isReply} depth=${depth} inThread=${inThread} />
 549          </div>
 550        `
 551      }
 552      const eTag = event.tags?.find((t) => t[0] === 'e')
 553      if (eTag) {
 554        return html`
 555          <div class="note" style=${{ paddingTop: '4px' }}>
 556            <div class="repost-label">⟳ reposted by ${repostName}</div>
 557            <${EmbeddedNote} eventId=${eTag[1]} />
 558          </div>
 559        `
 560      }
 561    }
 562  
 563    return html`<${NoteInner} event=${event} isRoot=${isRoot} isReply=${isReply} depth=${depth} inThread=${inThread} />`
 564  }
 565  
 566  function NoteInner({ event, isRoot, isReply, depth, inThread }) {
 567    const { state, dispatch } = useApp()
 568    const profile = state.profiles.get(event.pubkey) || {}
 569    const [replying, setReplying] = useState(false)
 570    const [replyText, setReplyText] = useState('')
 571    const [sending, setSending] = useState(false)
 572  
 573    const openThread = () => {
 574      dispatch({ type: 'OPEN_THREAD', eventId: event.id, event })
 575    }
 576  
 577    const submitReply = useCallback(async () => {
 578      if (!replyText.trim() || !state.pubkey) return
 579      setSending(true)
 580      try {
 581        const eTags = (event.tags || []).filter((t) => t[0] === 'e')
 582        const rootTag = eTags.find((t) => t[3] === 'root')
 583        const tags = []
 584        if (rootTag) {
 585          tags.push(rootTag)
 586          tags.push(['e', event.id, '', 'reply'])
 587        } else if (eTags.length > 0) {
 588          tags.push(['e', eTags[0][1], eTags[0][2] || '', 'root'])
 589          tags.push(['e', event.id, '', 'reply'])
 590        } else {
 591          tags.push(['e', event.id, '', 'root'])
 592        }
 593        tags.push(['p', event.pubkey])
 594        const ev = {
 595          kind: 1,
 596          content: replyText.trim(),
 597          tags,
 598          created_at: Math.floor(Date.now() / 1000),
 599          pubkey: state.pubkey,
 600        }
 601        let signed
 602        if (state.loginMode === 'nsec') {
 603          signed = await signViaSW(ev)
 604        } else if (window.nostr) {
 605          signed = await window.nostr.signEvent(ev)
 606        } else { return }
 607        send(['EVENT', signed])
 608        setReplyText('')
 609        setReplying(false)
 610      } finally {
 611        setSending(false)
 612      }
 613    }, [replyText, state.pubkey, state.loginMode, event])
 614  
 615    const hasETags = event.tags?.some((t) => t[0] === 'e')
 616    const hasReplies = state.feed.some((e) =>
 617      e.tags?.some((t) => t[0] === 'e' && t[1] === event.id)
 618    )
 619    const isThread = hasETags || hasReplies
 620  
 621    const cls = `note ${isRoot ? 'thread-root' : ''} ${isReply ? 'thread-reply' : ''}`
 622    const indent = isReply && depth > 0 ? { marginLeft: (depth * 10) + 'px' } : {}
 623  
 624    return html`
 625      <div class=${cls} style=${indent}>
 626        <div class="note-header">
 627          ${profile.picture
 628            ? html`<img class="note-avatar" src=${profile.picture} />`
 629            : html`<div class="note-avatar" />`}
 630          <span class="note-author">${profile.name || shortId(event.pubkey)}</span>
 631          ${state.pubkey && html`<span class="note-action header-action" onClick=${() => setReplying(!replying)}>reply</span>`}
 632          ${!inThread && isThread && html`<span class="note-action header-action" onClick=${openThread}>thread</span>`}
 633          <span class="note-time">${relativeTime(event.created_at)}</span>
 634        </div>
 635        <${RichContent} content=${event.content} />
 636        ${replying && html`
 637          <div class="reply-compose">
 638            <textarea
 639              class="reply-input"
 640              value=${replyText}
 641              onInput=${(e) => setReplyText(e.target.value)}
 642              placeholder="reply..."
 643              onKeyDown=${(e) => { if (e.key === 'Enter' && e.ctrlKey) submitReply() }}
 644            />
 645            <div class="reply-buttons">
 646              <button class="reply-cancel" onClick=${() => { setReplying(false); setReplyText('') }}>cancel</button>
 647              <button class="reply-submit" onClick=${submitReply} disabled=${sending || !replyText.trim()}>reply</button>
 648            </div>
 649          </div>
 650        `}
 651      </div>
 652    `
 653  }
 654  
 655  function Feed() {
 656    const { state, dispatch } = useApp()
 657    const feedRef = useRef(null)
 658    const pending = state.pendingNotes.length
 659  
 660    const flush = () => {
 661      dispatch({ type: 'FLUSH_PENDING' })
 662      if (feedRef.current) feedRef.current.scrollTop = 0
 663    }
 664  
 665    const sorted = useMemo(
 666      () => [...state.feed].sort((a, b) => b.created_at - a.created_at),
 667      [state.feed]
 668    )
 669  
 670    const loadMore = useCallback(() => {
 671      if (state.feedLoading || state.feedExhausted || !state.contacts.length || !sorted.length) return
 672      dispatch({ type: 'SET_FEED_LOADING' })
 673      const oldest = sorted[sorted.length - 1].created_at
 674      const batch = state.contacts.slice(0, 100)
 675      const relays = state.relays.slice(0, 4)
 676      const subId = 'feed-more-' + state.feedPage
 677      send(['PROXY', subId, { kinds: [1, 6], authors: batch, limit: 50, until: oldest }, ...relays])
 678    }, [state.feedLoading, state.feedExhausted, state.contacts, state.relays, state.feedPage, sorted])
 679  
 680    const onScroll = useCallback((e) => {
 681      const el = e.target
 682      if (el.scrollHeight - el.scrollTop - el.clientHeight < 400) {
 683        loadMore()
 684      }
 685    }, [loadMore])
 686  
 687    return html`
 688      <div class="feed" ref=${feedRef} style="position:relative" onScroll=${onScroll}>
 689        ${pending > 0 && html`
 690          <div class="new-notes-pill" onClick=${flush}>
 691            ${pending} new note${pending > 1 ? 's' : ''}
 692          </div>
 693        `}
 694        ${sorted.map((ev) => html`<${Note} key=${ev.id} event=${ev} inThread=${false} />`)}
 695        ${state.feedLoading && html`
 696          <div style="padding: 16px; text-align: center; color: var(--fg2); font-size: 13px">loading...</div>
 697        `}
 698        ${sorted.length === 0 && !state.feedLoading && html`
 699          <div style="padding: 24px; text-align: center; color: var(--fg2)">
 700            ${state.pubkey ? 'no notes yet' : 'log in to see your feed'}
 701          </div>
 702        `}
 703      </div>
 704    `
 705  }
 706  
 707  function Compose() {
 708    const { state } = useApp()
 709    const [text, setText] = useState('')
 710    const [sending, setSending] = useState(false)
 711  
 712    const publish = useCallback(async () => {
 713      if (!text.trim() || !state.pubkey) return
 714      setSending(true)
 715      try {
 716        const event = {
 717          kind: 1,
 718          content: text.trim(),
 719          tags: [],
 720          created_at: Math.floor(Date.now() / 1000),
 721          pubkey: state.pubkey,
 722        }
 723        let signed
 724        if (state.loginMode === 'nsec') {
 725          signed = await signViaSW(event)
 726        } else if (window.nostr) {
 727          signed = await window.nostr.signEvent(event)
 728        } else { return }
 729        send(['EVENT', signed])
 730        setText('')
 731      } finally {
 732        setSending(false)
 733      }
 734    }, [text, state.pubkey, state.loginMode])
 735  
 736    if (!state.pubkey) return null
 737  
 738    return html`
 739      <div class="compose">
 740        <textarea
 741          value=${text}
 742          onInput=${(e) => setText(e.target.value)}
 743          placeholder="what's on your mind?"
 744          onKeyDown=${(e) => { if (e.key === 'Enter' && e.ctrlKey) publish() }}
 745        />
 746        <button onClick=${publish} disabled=${sending || !text.trim()}>post</button>
 747      </div>
 748    `
 749  }
 750  
 751  function HashtagFeed() {
 752    const { state, dispatch } = useApp()
 753    const feedRef = useRef(null)
 754    const [input, setInput] = useState(state.hashtagQuery || '')
 755    const timerRef = useRef(null)
 756  
 757    const doSearch = useCallback((query) => {
 758      if (!query.trim()) return
 759      const tag = query.trim().replace(/^#/, '')
 760      dispatch({ type: 'SET_HASHTAG_QUERY', query: tag })
 761      const relays = state.relays.slice(0, 4)
 762      send(['PROXY', 'hashtag-init', { kinds: [1], '#t': [tag], limit: 50 }, ...relays])
 763    }, [state.relays])
 764  
 765    const onInput = useCallback((e) => {
 766      const val = e.target.value
 767      setInput(val)
 768      if (timerRef.current) clearTimeout(timerRef.current)
 769      timerRef.current = setTimeout(() => doSearch(val), 2000)
 770    }, [doSearch])
 771  
 772    const sorted = useMemo(
 773      () => [...state.hashtagFeed].sort((a, b) => b.created_at - a.created_at),
 774      [state.hashtagFeed]
 775    )
 776  
 777    const loadMore = useCallback(() => {
 778      if (state.hashtagLoading || state.hashtagExhausted || !state.hashtagQuery || !sorted.length) return
 779      dispatch({ type: 'SET_HASHTAG_LOADING' })
 780      const oldest = sorted[sorted.length - 1].created_at
 781      const relays = state.relays.slice(0, 4)
 782      const subId = 'hashtag-more-' + state.hashtagPage
 783      send(['PROXY', subId, { kinds: [1], '#t': [state.hashtagQuery], limit: 50, until: oldest }, ...relays])
 784    }, [state.hashtagLoading, state.hashtagExhausted, state.hashtagQuery, state.relays, state.hashtagPage, sorted])
 785  
 786    const onScroll = useCallback((e) => {
 787      const el = e.target
 788      if (el.scrollHeight - el.scrollTop - el.clientHeight < 400) loadMore()
 789    }, [loadMore])
 790  
 791    // fetch profiles for hashtag feed authors
 792    useEffect(() => {
 793      if (!state.hashtagFeed.length) return
 794      const unknown = [...new Set(state.hashtagFeed.filter((e) => !state.profiles.has(e.pubkey)).map((e) => e.pubkey))]
 795      if (!unknown.length) return
 796      send(['PROXY', 'hashtag-profiles', { kinds: [0], authors: unknown.slice(0, 50) }, ...profileRelays(state.relays)])
 797    }, [state.hashtagFeed.length])
 798  
 799    return html`
 800      <div class="feed" ref=${feedRef} style="position:relative" onScroll=${onScroll}>
 801        <input class="hashtag-input" value=${input} onInput=${onInput} placeholder="search hashtag..." />
 802        ${sorted.map((ev) => html`<${Note} key=${ev.id} event=${ev} inThread=${false} />`)}
 803        ${state.hashtagLoading && html`
 804          <div style="padding: 16px; text-align: center; color: var(--fg2); font-size: 13px">loading...</div>
 805        `}
 806        ${sorted.length === 0 && state.hashtagQuery && !state.hashtagLoading && html`
 807          <div style="padding: 24px; text-align: center; color: var(--fg2)">no notes for #${state.hashtagQuery}</div>
 808        `}
 809      </div>
 810    `
 811  }
 812  
 813  function RelayFeed() {
 814    const { state, dispatch } = useApp()
 815    const feedRef = useRef(null)
 816    const [loaded, setLoaded] = useState(false)
 817  
 818    // initial load
 819    useEffect(() => {
 820      if (loaded || !state.relays.length) return
 821      setLoaded(true)
 822      const relays = state.relays.slice(0, 4)
 823      send(['PROXY', 'relay-init', { kinds: [1], limit: 50 }, ...relays])
 824    }, [state.relays, loaded])
 825  
 826    const sorted = useMemo(
 827      () => [...state.relayFeed].sort((a, b) => b.created_at - a.created_at),
 828      [state.relayFeed]
 829    )
 830  
 831    const loadMore = useCallback(() => {
 832      if (state.relayFeedLoading || state.relayFeedExhausted || !sorted.length) return
 833      dispatch({ type: 'SET_RELAY_LOADING' })
 834      const oldest = sorted[sorted.length - 1].created_at
 835      const relays = state.relays.slice(0, 4)
 836      const subId = 'relay-more-' + state.relayFeedPage
 837      send(['PROXY', subId, { kinds: [1], limit: 50, until: oldest }, ...relays])
 838    }, [state.relayFeedLoading, state.relayFeedExhausted, state.relays, state.relayFeedPage, sorted])
 839  
 840    const onScroll = useCallback((e) => {
 841      const el = e.target
 842      if (el.scrollHeight - el.scrollTop - el.clientHeight < 400) loadMore()
 843    }, [loadMore])
 844  
 845    // fetch profiles for relay feed authors
 846    useEffect(() => {
 847      if (!state.relayFeed.length) return
 848      const unknown = [...new Set(state.relayFeed.filter((e) => !state.profiles.has(e.pubkey)).map((e) => e.pubkey))]
 849      if (!unknown.length) return
 850      send(['PROXY', 'relay-profiles', { kinds: [0], authors: unknown.slice(0, 50) }, ...profileRelays(state.relays)])
 851    }, [state.relayFeed.length])
 852  
 853    return html`
 854      <div class="feed" ref=${feedRef} style="position:relative" onScroll=${onScroll}>
 855        ${sorted.map((ev) => html`<${Note} key=${ev.id} event=${ev} inThread=${false} />`)}
 856        ${state.relayFeedLoading && html`
 857          <div style="padding: 16px; text-align: center; color: var(--fg2); font-size: 13px">loading...</div>
 858        `}
 859        ${sorted.length === 0 && !state.relayFeedLoading && html`
 860          <div style="padding: 24px; text-align: center; color: var(--fg2)">no notes yet</div>
 861        `}
 862      </div>
 863    `
 864  }
 865  
 866  function Settings() {
 867    const { state, dispatch } = useApp()
 868    const [newRelay, setNewRelay] = useState('')
 869  
 870    const addRelay = () => {
 871      let url = newRelay.trim()
 872      if (!url) return
 873      if (!url.startsWith('wss://') && !url.startsWith('ws://')) url = 'wss://' + url
 874      dispatch({ type: 'ADD_RELAY', url })
 875      setNewRelay('')
 876    }
 877  
 878    return html`
 879      <div class="settings">
 880        <section>
 881          <h2>relays</h2>
 882          <div class="relay-input">
 883            <input
 884              value=${newRelay}
 885              onInput=${(e) => setNewRelay(e.target.value)}
 886              placeholder="wss://relay.example.com"
 887              onKeyDown=${(e) => { if (e.key === 'Enter') addRelay() }}
 888            />
 889            <button onClick=${addRelay}>add</button>
 890          </div>
 891          ${(state.relays || []).map((r) => html`
 892            <div class="relay-list-item">
 893              <span>${r}</span>
 894              <button onClick=${() => dispatch({ type: 'REMOVE_RELAY', url: r })}>×</button>
 895            </div>
 896          `)}
 897        </section>
 898  
 899        <section>
 900          <h2>identity</h2>
 901          ${state.pubkey
 902            ? html`<div style="font-family: var(--mono); font-size: 12px; word-break: break-all">${state.pubkey}</div>`
 903            : html`<div style="color: var(--fg2)">not logged in</div>`}
 904          ${state.pubkey && html`
 905            <button onClick=${() => {
 906              const targets = profileRelays(state.relays)
 907              send(['BROADCAST', state.pubkey, targets])
 908              dispatch({ type: 'SET_SNACKBAR', message: 'broadcasting...' })
 909            }} style="margin-top: 12px; background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 8px 16px; font-size: 13px; cursor: pointer; font-weight: 600;">
 910              broadcast identity
 911            </button>
 912            <div style="font-size: 12px; color: var(--fg2); margin-top: 4px">publish profile, contacts, relay list, DM inbox + MLS relays to ${PROFILE_RELAYS.length + Math.min(state.relays.length, 3)} relays</div>
 913          `}
 914          ${state.loginMode === 'nsec' && html`
 915            <button onClick=${() => {
 916              send(['CLEAR_KEY'])
 917              localStorage.removeItem('smesh2-enc')
 918              localStorage.removeItem('smesh2-pubkey')
 919              location.reload()
 920            }} style="margin-top: 12px; background: none; border: 1px solid var(--border); color: var(--fg2); border-radius: var(--radius); padding: 6px 16px; font-size: 13px; cursor: pointer;">
 921              logout
 922            </button>
 923          `}
 924        </section>
 925      </div>
 926    `
 927  }
 928  
 929  function DMList() {
 930    const { state, dispatch } = useApp()
 931    const [showNew, setShowNew] = useState(false)
 932    const [newPubkey, setNewPubkey] = useState('')
 933  
 934    useEffect(() => {
 935      send(['DM_LIST'])
 936      send(['DM_SUB', state.relays])
 937    }, [])
 938  
 939    const openConversation = (peer) => {
 940      dispatch({ type: 'OPEN_DM', peer })
 941    }
 942  
 943    const startNew = () => {
 944      let pk = newPubkey.trim()
 945      if (!pk) return
 946      try {
 947        if (pk.startsWith('npub1')) {
 948          const d = nip19Decode(pk)
 949          if (d.type === 'npub') pk = d.data
 950        }
 951      } catch {}
 952      if (pk.length !== 64) return
 953      setNewPubkey('')
 954      setShowNew(false)
 955      openConversation(pk)
 956    }
 957  
 958    return html`
 959      <div class="dm-list">
 960        <div style="padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border)">
 961          <span style="font-size: 14px; font-weight: 600">conversations</span>
 962          <button onClick=${() => setShowNew(!showNew)} style="background: var(--accent); color: #000; border: none; border-radius: var(--radius); padding: 4px 10px; font-size: 12px; font-weight: 600; cursor: pointer">new</button>
 963        </div>
 964        ${showNew && html`
 965          <div class="dm-new-input">
 966            <input
 967              value=${newPubkey}
 968              onInput=${(e) => setNewPubkey(e.target.value)}
 969              placeholder="npub or hex pubkey"
 970              onKeyDown=${(e) => { if (e.key === 'Enter') startNew() }}
 971            />
 972          </div>
 973        `}
 974        ${(state.conversations || []).map((c) => {
 975          const profile = state.profiles.get(c.peer) || {}
 976          return html`
 977            <div class="dm-list-item" onClick=${() => openConversation(c.peer)}>
 978              ${profile.picture
 979                ? html`<img class="note-avatar" src=${profile.picture} />`
 980                : html`<div class="note-avatar" />`}
 981              <div class="dm-preview">
 982                <div class="dm-preview-name">${profile.name || shortId(c.peer)}</div>
 983                <div class="dm-preview-text">${c.lastMessage}</div>
 984              </div>
 985              <span class="dm-preview-time">${relativeTime(c.lastTs)}</span>
 986            </div>
 987          `
 988        })}
 989        ${(!state.conversations || state.conversations.length === 0) && html`
 990          <div style="padding: 24px; text-align: center; color: var(--fg2); font-size: 14px">
 991            no conversations yet
 992          </div>
 993        `}
 994      </div>
 995    `
 996  }
 997  
 998  function DMChat() {
 999    const { state, dispatch } = useApp()
1000    const peer = state.activeDM
1001    const profile = state.profiles.get(peer) || {}
1002    const [text, setText] = useState('')
1003    const [sending, setSending] = useState(false)
1004    const messagesRef = useRef(null)
1005    const endRef = useRef(null)
1006  
1007    useEffect(() => {
1008      if (!peer) return
1009      send(['DM_HISTORY', peer, 50, null])
1010      // fetch peer profile if unknown
1011      if (!state.profiles.has(peer)) {
1012        send(['PROXY', 'dm-profile-' + peer.slice(0, 8), { kinds: [0], authors: [peer], limit: 1 }, ...profileRelays(state.relays)])
1013      }
1014    }, [peer])
1015  
1016    // scroll to bottom on new messages
1017    useEffect(() => {
1018      if (endRef.current) endRef.current.scrollIntoView({ behavior: 'smooth' })
1019    }, [state.dmMessages?.length])
1020  
1021    const goBack = () => dispatch({ type: 'SET_DM_TAB', tab: 'list' })
1022  
1023    const sendMessage = useCallback(async () => {
1024      if (!text.trim() || !peer) return
1025      setSending(true)
1026      try {
1027        send(['SEND_DM', peer, text.trim(), state.relays])
1028        setText('')
1029      } finally {
1030        setSending(false)
1031      }
1032    }, [text, peer, state.relays])
1033  
1034    const messages = useMemo(
1035      () => [...(state.dmMessages || [])].sort((a, b) => a.created_at - b.created_at),
1036      [state.dmMessages]
1037    )
1038  
1039    return html`
1040      <div class="dm-chat">
1041        <div style="padding: 10px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px">
1042          <span style="cursor: pointer; color: var(--accent); font-size: 16px" onClick=${goBack}>←</span>
1043          ${profile.picture
1044            ? html`<img class="note-avatar" src=${profile.picture} />`
1045            : html`<div class="note-avatar" />`}
1046          <span style="font-size: 14px; font-weight: 600">${profile.name || shortId(peer)}</span>
1047        </div>
1048        <div class="dm-messages" ref=${messagesRef}>
1049          ${messages.map((m) => html`
1050            <div key=${m.id} style="align-self: ${m.from === state.pubkey ? 'flex-end' : 'flex-start'}; max-width: 75%">
1051              <div class="dm-bubble ${m.from === state.pubkey ? 'mine' : 'theirs'}">${m.content}</div>
1052              <div style="display: flex; gap: 6px; justify-content: ${m.from === state.pubkey ? 'flex-end' : 'flex-start'}">
1053                <span class="dm-protocol ${m.protocol === 'nip04' ? 'legacy' : ''}">${m.protocol}</span>
1054                <span class="dm-time">${relativeTime(m.created_at)}</span>
1055              </div>
1056            </div>
1057          `)}
1058          <div ref=${endRef} />
1059          ${messages.length === 0 && html`
1060            <div style="text-align: center; color: var(--fg2); padding: 24px; font-size: 14px">no messages yet</div>
1061          `}
1062        </div>
1063        <div class="dm-compose">
1064          <textarea
1065            value=${text}
1066            onInput=${(e) => setText(e.target.value)}
1067            placeholder="message..."
1068            onKeyDown=${(e) => { if (e.key === 'Enter' && e.ctrlKey) sendMessage() }}
1069          />
1070          <button onClick=${sendMessage} disabled=${sending || !text.trim()}>send</button>
1071        </div>
1072      </div>
1073    `
1074  }
1075  
1076  function DMView() {
1077    const { state } = useApp()
1078    if (state.dmTab === 'chat' && state.activeDM) return html`<${DMChat} />`
1079    return html`<${DMList} />`
1080  }
1081  
1082  function ProfileView() {
1083    const { state } = useApp()
1084    const profile = state.profile || {}
1085  
1086    return html`
1087      <div class="settings">
1088        <section>
1089          <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px">
1090            ${profile.picture
1091              ? html`<img class="avatar" style="width:64px;height:64px" src=${profile.picture} />`
1092              : html`<div class="avatar" style="width:64px;height:64px" />`}
1093            <div>
1094              <div style="font-size: 20px; font-weight: 600">${profile.name || shortId(state.pubkey)}</div>
1095              ${profile.nip05 && html`<div style="font-size: 13px; color: var(--fg2)">${profile.nip05}</div>`}
1096            </div>
1097          </div>
1098          ${profile.about && html`<div style="font-size: 14px; line-height: 1.5">${profile.about}</div>`}
1099        </section>
1100      </div>
1101    `
1102  }
1103  
1104  function buildThread(events, rootId) {
1105    // find each event's parent from e tags
1106    const parentOf = new Map()
1107    const childrenOf = new Map()
1108    for (const ev of events) {
1109      if (ev.id === rootId) continue
1110      // NIP-10: reply marker on e tag, or last e tag without marker
1111      const eTags = (ev.tags || []).filter((t) => t[0] === 'e')
1112      const replyTag = eTags.find((t) => t[3] === 'reply')
1113      const rootTag = eTags.find((t) => t[3] === 'root')
1114      let parentId
1115      if (replyTag) {
1116        parentId = replyTag[1]
1117      } else if (eTags.length === 1) {
1118        parentId = eTags[0][1]
1119      } else if (eTags.length > 1) {
1120        parentId = eTags[eTags.length - 1][1]
1121      } else {
1122        parentId = rootId
1123      }
1124      // if parent isn't in our event set, attach to root
1125      if (!events.some((e) => e.id === parentId)) parentId = rootId
1126      parentOf.set(ev.id, parentId)
1127      if (!childrenOf.has(parentId)) childrenOf.set(parentId, [])
1128      childrenOf.get(parentId).push(ev)
1129    }
1130    // sort children by time
1131    for (const [, kids] of childrenOf) kids.sort((a, b) => a.created_at - b.created_at)
1132    // flatten tree depth-first
1133    const flat = []
1134    const walk = (id, depth) => {
1135      const kids = childrenOf.get(id) || []
1136      for (const kid of kids) {
1137        flat.push({ event: kid, depth })
1138        walk(kid.id, depth + 1)
1139      }
1140    }
1141    walk(rootId, 1)
1142    return flat
1143  }
1144  
1145  function ThreadView() {
1146    const { state, dispatch } = useApp()
1147    const rootId = state.threadRootId
1148    const rootEvent = state.threadEvents.find((e) => e.id === rootId)
1149    const threaded = useMemo(
1150      () => buildThread(state.threadEvents, rootId),
1151      [state.threadEvents, rootId]
1152    )
1153  
1154    const goBack = () => dispatch({ type: 'SET_TAB', tab: 'feed' })
1155  
1156    return html`
1157      <div class="feed">
1158        <div class="thread-back" onClick=${goBack}>← back</div>
1159        ${rootEvent && html`<${Note} event=${rootEvent} isRoot=${true} inThread=${true} />`}
1160        ${!rootEvent && html`<div class="note" style="color:var(--fg2)">loading root note...</div>`}
1161        ${threaded.map(({ event, depth }) => html`
1162          <${Note} key=${event.id} event=${event} isReply=${true} depth=${depth} inThread=${true} />
1163        `)}
1164        ${threaded.length === 0 && rootEvent && html`
1165          <div style="padding: 16px 24px; color: var(--fg2); font-size: 14px">no replies yet</div>
1166        `}
1167        ${state.orlyRelays.length > 0 && html`
1168          <div style="padding: 4px 16px; font-size: 11px; color: var(--fg2)">
1169            thread via graph query <span class="orly-badge">ORLY</span>
1170          </div>
1171        `}
1172      </div>
1173    `
1174  }
1175  
1176  function SmeshLoader() {
1177    const svgRef = useRef(null)
1178  
1179    useEffect(() => {
1180      const g = svgRef.current
1181      if (!g) return
1182      while (g.firstChild) g.removeChild(g.firstChild)
1183  
1184      const SVG_NS = 'http://www.w3.org/2000/svg'
1185      const COLORS = ['#e07030', '#8833bb', '#00aabb']
1186      const BASE_LEN = 110, DECAY = 0.56, BASE_WIDTH = 32, MAX_DEPTH = 6
1187      const SPREAD = Math.PI * 0.68, CX = 400, CY = 400
1188      const ANGLES = [-Math.PI / 2, -Math.PI / 2 + (2 * Math.PI) / 3, -Math.PI / 2 + (4 * Math.PI) / 3]
1189  
1190      let delay = 50
1191      function branch(px, py, angle, depth, branchIdx, spreadAngle) {
1192        if (depth > MAX_DEPTH) return
1193        const scale = Math.pow(DECAY, depth - 1)
1194        const len = BASE_LEN * scale
1195        const width = BASE_WIDTH * scale
1196        const nx = px + Math.cos(angle) * len
1197        const ny = py + Math.sin(angle) * len
1198        const d = delay
1199        delay += 30
1200  
1201        const line = document.createElementNS(SVG_NS, 'line')
1202        line.setAttribute('x1', px); line.setAttribute('y1', py)
1203        line.setAttribute('x2', nx); line.setAttribute('y2', ny)
1204        line.setAttribute('stroke', COLORS[branchIdx])
1205        line.setAttribute('stroke-width', width)
1206        line.classList.add('smesh-loader-edge')
1207        line.style.animationDelay = d + 'ms'
1208        g.appendChild(line)
1209  
1210        const childSpread = spreadAngle * 0.82
1211        branch(nx, ny, angle - childSpread / 2, depth + 1, branchIdx, childSpread)
1212        branch(nx, ny, angle + childSpread / 2, depth + 1, branchIdx, childSpread)
1213      }
1214  
1215      for (let i = 0; i < 3; i++) branch(CX, CY, ANGLES[i], 1, i, SPREAD)
1216  
1217      // center hexagon
1218      const r = 24
1219      const pts = []
1220      for (let i = 0; i < 6; i++) {
1221        const a = Math.PI / 6 + (i * Math.PI) / 3
1222        pts.push((CX + r * Math.cos(a)).toFixed(2) + ',' + (CY + r * Math.sin(a)).toFixed(2))
1223      }
1224      const hex = document.createElementNS(SVG_NS, 'polygon')
1225      hex.setAttribute('points', pts.join(' '))
1226      hex.setAttribute('fill', '#e8e4da')
1227      hex.setAttribute('stroke', '#0a0a0e')
1228      hex.setAttribute('stroke-width', '7.5')
1229      hex.setAttribute('stroke-linejoin', 'round')
1230      hex.classList.add('smesh-loader-center')
1231      hex.style.animationDelay = '0ms'
1232      g.appendChild(hex)
1233    }, [])
1234  
1235    return html`
1236      <div class="smesh-loader">
1237        <svg viewBox="160.68 160.68 478.65 478.65" style="width:100%;height:100%">
1238          <g ref=${svgRef} />
1239        </svg>
1240      </div>
1241    `
1242  }
1243  
1244  function LoginScreen() {
1245    const { dispatch } = useApp()
1246    const [nsecInput, setNsecInput] = useState('')
1247    const [passwordInput, setPasswordInput] = useState('')
1248    const [error, setError] = useState('')
1249    const [loading, setLoading] = useState(false)
1250  
1251    const loginExtension = async () => {
1252      if (!window.nostr) {
1253        setError('install a NIP-07 browser extension (nos2x, Alby, etc)')
1254        return
1255      }
1256      try {
1257        const pubkey = await window.nostr.getPublicKey()
1258        send(['SET_PUBKEY', pubkey])
1259        dispatch({ type: 'LOGIN', pubkey, loginMode: 'extension' })
1260      } catch (err) {
1261        setError('login failed: ' + err.message)
1262      }
1263    }
1264  
1265    const loginNsec = async () => {
1266      setError('')
1267      setLoading(true)
1268      try {
1269        const secretKeyBytes = decodeNsec(nsecInput.trim())
1270        const pubkey = pubkeyFromSecret(secretKeyBytes)
1271        // send key to SW
1272        send(['SET_KEY', Array.from(secretKeyBytes)])
1273        // encrypt and store if password provided
1274        if (passwordInput) {
1275          const encrypted = await encryptNsec(nsecInput.trim(), passwordInput)
1276          localStorage.setItem('smesh2-enc', encrypted)
1277          localStorage.setItem('smesh2-pubkey', pubkey)
1278        }
1279        dispatch({ type: 'LOGIN', pubkey, loginMode: 'nsec' })
1280      } catch (err) {
1281        setError('invalid nsec: ' + err.message)
1282      } finally {
1283        setLoading(false)
1284      }
1285    }
1286  
1287    return html`
1288      <div class="login-screen">
1289        <${SmeshLoader} />
1290        <button onClick=${loginExtension}>login with extension</button>
1291        <div style="color: var(--fg2); font-size: 13px; margin: 8px 0">or</div>
1292        <form onSubmit=${(e) => { e.preventDefault(); loginNsec() }} style="display:flex;flex-direction:column;align-items:center;gap:8px">
1293          <input
1294            type="password"
1295            value=${nsecInput}
1296            onInput=${(e) => setNsecInput(e.target.value)}
1297            placeholder="nsec1..."
1298            style="width: 300px; max-width: 90vw; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; font-family: var(--mono); font-size: 13px;"
1299          />
1300          <input
1301            type="password"
1302            value=${passwordInput}
1303            onInput=${(e) => setPasswordInput(e.target.value)}
1304            placeholder="password (optional, for session restore)"
1305            style="width: 300px; max-width: 90vw; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; font-size: 13px;"
1306          />
1307          <button type="submit" disabled=${loading || !nsecInput.trim()}>
1308            ${loading ? 'encrypting...' : 'login with nsec'}
1309          </button>
1310        </form>
1311        ${error && html`<div style="color: #ef4444; font-size: 13px; max-width: 300px; text-align: center">${error}</div>`}
1312      </div>
1313    `
1314  }
1315  
1316  function PasswordPrompt() {
1317    const { dispatch } = useApp()
1318    const [password, setPassword] = useState('')
1319    const [error, setError] = useState('')
1320    const [loading, setLoading] = useState(false)
1321  
1322    const unlock = async () => {
1323      setError('')
1324      setLoading(true)
1325      try {
1326        const encrypted = localStorage.getItem('smesh2-enc')
1327        const nsec = await decryptNsec(encrypted, password)
1328        const secretKeyBytes = decodeNsec(nsec)
1329        const pubkey = pubkeyFromSecret(secretKeyBytes)
1330        send(['SET_KEY', Array.from(secretKeyBytes)])
1331        dispatch({ type: 'LOGIN', pubkey, loginMode: 'nsec' })
1332      } catch (err) {
1333        setError('wrong password')
1334      } finally {
1335        setLoading(false)
1336      }
1337    }
1338  
1339    const forget = () => {
1340      localStorage.removeItem('smesh2-enc')
1341      localStorage.removeItem('smesh2-pubkey')
1342      send(['CLEAR_KEY'])
1343      dispatch({ type: 'CLEAR_STORED_SESSION' })
1344    }
1345  
1346    return html`
1347      <div class="login-screen">
1348        <${SmeshLoader} />
1349        <div style="font-size: 13px; color: var(--fg2); margin-bottom: 8px">enter password to unlock</div>
1350        <form onSubmit=${(e) => { e.preventDefault(); unlock() }} style="display:flex;flex-direction:column;align-items:center;gap:8px">
1351          <input
1352            type="password"
1353            value=${password}
1354            onInput=${(e) => setPassword(e.target.value)}
1355            placeholder="password"
1356            style="width: 300px; max-width: 90vw; background: var(--bg2); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; font-size: 13px;"
1357          />
1358          <button type="submit" disabled=${loading || !password}>
1359            ${loading ? 'decrypting...' : 'unlock'}
1360          </button>
1361        </form>
1362        ${error && html`<div style="color: #ef4444; font-size: 13px">${error}</div>`}
1363        <button onClick=${forget} style="background: none; border: 1px solid var(--border); color: var(--fg2); margin-top: 8px; font-size: 12px; padding: 6px 16px;">
1364          forget me
1365        </button>
1366      </div>
1367    `
1368  }
1369  
1370  function Snackbar({ message, onDone }) {
1371    useEffect(() => {
1372      const t = setTimeout(onDone, 4000)
1373      return () => clearTimeout(t)
1374    }, [message])
1375  
1376    return html`<div class="snackbar">${message}</div>`
1377  }
1378  
1379  function UpdateSnackbar() {
1380    const [show, setShow] = useState(false)
1381  
1382    useEffect(() => {
1383      const handler = () => setShow(true)
1384      window.addEventListener('sw-update', handler)
1385      return () => window.removeEventListener('sw-update', handler)
1386    }, [])
1387  
1388    if (!show) return null
1389  
1390    const update = () => {
1391      if (pendingWorker) pendingWorker.postMessage(['SKIP_WAITING'])
1392      window.location.reload()
1393    }
1394  
1395    return html`
1396      <div class="snackbar" onClick=${update} style="cursor:pointer">
1397        new version available — click to update
1398      </div>
1399    `
1400  }
1401  
1402  // ─── state reducer ───────────────────────────────────────────────────
1403  
1404  const DEFAULT_RELAYS = ['wss://relay.orly.dev', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']
1405  
1406  function reducer(state, action) {
1407    switch (action.type) {
1408      case 'LOGIN': {
1409        const mode = action.loginMode || 'extension'
1410        localStorage.setItem('smesh2-pubkey', action.pubkey)
1411        localStorage.setItem('smesh2-loginMode', mode)
1412        return { ...state, pubkey: action.pubkey, loginMode: mode, hasStoredSession: false, feedReady: false }
1413      }
1414      case 'CLEAR_STORED_SESSION':
1415        localStorage.removeItem('smesh2-pubkey')
1416        localStorage.removeItem('smesh2-loginMode')
1417        return { ...state, hasStoredSession: false }
1418      case 'SET_TAB':
1419        return { ...state, activeTab: action.tab }
1420      case 'SET_PROFILE': {
1421        if (state.profileTs && action.ts < state.profileTs) return state
1422        return { ...state, profile: action.profile, profileTs: action.ts }
1423      }
1424      case 'ADD_PROFILE': {
1425        const existing = state.profiles.get(action.pubkey)
1426        if (existing && existing._ts >= action.ts) return state
1427        const profiles = new Map(state.profiles)
1428        profiles.set(action.pubkey, { ...action.profile, _ts: action.ts })
1429        return { ...state, profiles }
1430      }
1431      case 'SET_CONTACTS':
1432        return { ...state, contacts: action.contacts }
1433      case 'SET_RELAYS':
1434        return { ...state, relays: action.relays }
1435      case 'ADD_RELAY':
1436        if (state.relays.includes(action.url)) return state
1437        return { ...state, relays: [...state.relays, action.url] }
1438      case 'REMOVE_RELAY':
1439        return { ...state, relays: state.relays.filter((r) => r !== action.url) }
1440      case 'ADD_EVENT': {
1441        if (state.feed.some((e) => e.id === action.event.id)) return state
1442        if (state.pendingNotes.some((e) => e.id === action.event.id)) return state
1443        return { ...state, feed: [...state.feed, action.event] }
1444      }
1445      case 'ADD_PENDING_NOTE': {
1446        if (state.feed.some((e) => e.id === action.event.id)) return state
1447        if (state.pendingNotes.some((e) => e.id === action.event.id)) return state
1448        return { ...state, pendingNotes: [...state.pendingNotes, action.event] }
1449      }
1450      case 'FLUSH_PENDING': {
1451        if (!state.pendingNotes.length) return state
1452        return { ...state, feed: [...state.feed, ...state.pendingNotes], pendingNotes: [] }
1453      }
1454      case 'SET_FEED_READY':
1455        return { ...state, feedReady: true }
1456      case 'SET_FEED_LOADING':
1457        return { ...state, feedLoading: true, feedLenBefore: state.feed.length }
1458      case 'FEED_LOADED_MORE': {
1459        const added = state.feed.length - state.feedLenBefore
1460        return { ...state, feedLoading: false, feedPage: state.feedPage + 1, feedExhausted: added === 0 }
1461      }
1462      case 'OPEN_THREAD': {
1463        const ev = action.event
1464        const eTags = (ev?.tags || []).filter((t) => t[0] === 'e')
1465        // NIP-10: find root — explicit root marker first, then first e tag (positional)
1466        const rootTag = eTags.find((t) => t[3] === 'root') || (eTags.length > 0 ? eTags[0] : null)
1467        const rootId = rootTag ? rootTag[1] : action.eventId
1468        // collect relay hints from all e tags
1469        const hints = (ev?.tags || [])
1470          .filter((t) => t[0] === 'e' && t[2] && t[2].startsWith('wss://'))
1471          .map((t) => t[2])
1472        return { ...state, activeTab: 'thread', threadEventId: action.eventId, threadRootId: rootId, threadRelayHints: hints, threadEvents: ev ? [ev] : [], threadQueriedIds: [] }
1473      }
1474      case 'ADD_THREAD_EVENT': {
1475        if (state.threadEvents.some((e) => e.id === action.event.id)) return state
1476        return { ...state, threadEvents: [...state.threadEvents, action.event] }
1477      }
1478      case 'MARK_THREAD_QUERIED':
1479        return { ...state, threadQueriedIds: [...state.threadQueriedIds, ...action.ids] }
1480      case 'SET_ORLY_RELAYS':
1481        return { ...state, orlyRelays: action.relays }
1482      case 'SET_SNACKBAR':
1483        return { ...state, snackbar: action.message }
1484      case 'CACHE_EMBEDDED': {
1485        const embeddedNotes = new Map(state.embeddedNotes)
1486        embeddedNotes.set(action.eventId, action.event)
1487        return { ...state, embeddedNotes }
1488      }
1489      case 'OPEN_LIGHTBOX':
1490        return { ...state, lightboxUrl: action.url }
1491      case 'CLOSE_LIGHTBOX':
1492        return { ...state, lightboxUrl: null }
1493      case 'SET_DM_TAB':
1494        return { ...state, dmTab: action.tab }
1495      case 'OPEN_DM':
1496        return { ...state, activeTab: 'dms', dmTab: 'chat', activeDM: action.peer, dmMessages: [] }
1497      case 'SET_CONVERSATIONS':
1498        return { ...state, conversations: action.conversations }
1499      case 'SET_DM_MESSAGES':
1500        return { ...state, dmMessages: action.messages }
1501      case 'ADD_DM_MESSAGE': {
1502        const msg = action.message
1503        if (msg.peer !== state.activeDM) return state
1504        if (state.dmMessages.some((m) => m.id === msg.id)) return state
1505        return { ...state, dmMessages: [...state.dmMessages, msg] }
1506      }
1507      case 'ADD_CONVERSATION': {
1508        const c = action.conversation
1509        const existing = (state.conversations || []).filter((x) => x.peer !== c.peer)
1510        return { ...state, conversations: [c, ...existing].sort((a, b) => b.lastTs - a.lastTs) }
1511      }
1512      case 'SET_HASHTAG_QUERY':
1513        return { ...state, hashtagQuery: action.query, hashtagFeed: [], hashtagPage: 0, hashtagExhausted: false, hashtagLoading: false, hashtagLenBefore: 0 }
1514      case 'ADD_HASHTAG_EVENT': {
1515        if (state.hashtagFeed.some((e) => e.id === action.event.id)) return state
1516        return { ...state, hashtagFeed: [...state.hashtagFeed, action.event] }
1517      }
1518      case 'SET_HASHTAG_LOADING':
1519        return { ...state, hashtagLoading: true, hashtagLenBefore: state.hashtagFeed.length }
1520      case 'HASHTAG_LOADED_MORE': {
1521        const added = state.hashtagFeed.length - state.hashtagLenBefore
1522        return { ...state, hashtagLoading: false, hashtagPage: state.hashtagPage + 1, hashtagExhausted: added === 0 }
1523      }
1524      case 'ADD_RELAY_EVENT': {
1525        if (state.relayFeed.some((e) => e.id === action.event.id)) return state
1526        return { ...state, relayFeed: [...state.relayFeed, action.event] }
1527      }
1528      case 'SET_RELAY_LOADING':
1529        return { ...state, relayFeedLoading: true, relayFeedLenBefore: state.relayFeed.length }
1530      case 'RELAY_LOADED_MORE': {
1531        const added = state.relayFeed.length - state.relayFeedLenBefore
1532        return { ...state, relayFeedLoading: false, relayFeedPage: state.relayFeedPage + 1, relayFeedExhausted: added === 0 }
1533      }
1534      default:
1535        return state
1536    }
1537  }
1538  
1539  // ─── App ─────────────────────────────────────────────────────────────
1540  
1541  function App() {
1542    const storedPubkey = localStorage.getItem('smesh2-pubkey')
1543    const storedMode = localStorage.getItem('smesh2-loginMode') || 'extension'
1544    const hasEncrypted = !!(localStorage.getItem('smesh2-enc'))
1545    // restore: extension or nsec-without-password restore immediately; nsec with encrypted key shows password prompt
1546    const canAutoRestore = storedPubkey && (storedMode === 'extension' || (storedMode === 'nsec' && !hasEncrypted))
1547    const needsPasswordPrompt = storedPubkey && storedMode === 'nsec' && hasEncrypted
1548  
1549    const [state, rawDispatch] = useState({
1550      pubkey: canAutoRestore ? storedPubkey : null,
1551      loginMode: canAutoRestore ? storedMode : null,
1552      hasStoredSession: !!needsPasswordPrompt,
1553      profile: {},
1554      profileTs: 0,
1555      profiles: new Map(),
1556      contacts: [],
1557      relays: JSON.parse(localStorage.getItem('smesh2-relays') || 'null') || DEFAULT_RELAYS,
1558      feed: [],
1559      pendingNotes: [],
1560      feedReady: false,
1561      feedLoading: false,
1562      feedPage: 0,
1563      feedExhausted: false,
1564      feedLenBefore: 0,
1565      activeTab: 'feed',
1566      snackbar: null,
1567      threadEventId: null,
1568      threadRootId: null,
1569      threadRelayHints: [],
1570      threadEvents: [],
1571      threadQueriedIds: [],
1572      orlyRelays: [],
1573      embeddedNotes: new Map(),
1574      lightboxUrl: null,
1575      hashtagQuery: '',
1576      hashtagFeed: [],
1577      hashtagLoading: false,
1578      hashtagPage: 0,
1579      hashtagExhausted: false,
1580      hashtagLenBefore: 0,
1581      relayFeed: [],
1582      relayFeedLoading: false,
1583      relayFeedPage: 0,
1584      relayFeedExhausted: false,
1585      relayFeedLenBefore: 0,
1586      conversations: [],
1587      activeDM: null,
1588      dmMessages: [],
1589      dmTab: 'list',
1590    })
1591  
1592    const dispatch = useCallback((action) => {
1593      rawDispatch((prev) => reducer(prev, action))
1594    }, [])
1595  
1596    const [sidebarOpen, setSidebarOpen] = useState(false)
1597  
1598    // persist relays
1599    useEffect(() => {
1600      localStorage.setItem('smesh2-relays', JSON.stringify(state.relays))
1601      if (state.pubkey) send(['SET_WRITE_RELAYS', state.relays])
1602    }, [state.relays])
1603  
1604    // listen to service worker messages
1605    useEffect(() => {
1606      const handler = (e) => {
1607        const [type, ...args] = e.data
1608        switch (type) {
1609          case 'EVENT': {
1610            const [subId, event] = args
1611            if (event.kind === 0) {
1612              const profile = parseProfile(event)
1613              if (event.pubkey === state.pubkey) {
1614                dispatch({ type: 'SET_PROFILE', profile, ts: event.created_at })
1615              }
1616              dispatch({ type: 'ADD_PROFILE', pubkey: event.pubkey, profile, ts: event.created_at })
1617            }
1618            if (event.kind === 1 || event.kind === 6) {
1619              // route to correct feed based on subscription prefix
1620              if (subId.startsWith('hashtag-')) {
1621                dispatch({ type: 'ADD_HASHTAG_EVENT', event })
1622              } else if (subId.startsWith('relay-')) {
1623                dispatch({ type: 'ADD_RELAY_EVENT', event })
1624              } else if (subId.startsWith('embed-')) {
1625                dispatch({ type: 'CACHE_EMBEDDED', eventId: event.id, event })
1626              } else if (subId === 'feed-live' && state.feedReady) {
1627                dispatch({ type: 'ADD_PENDING_NOTE', event })
1628              } else {
1629                dispatch({ type: 'ADD_EVENT', event })
1630              }
1631              if (subId.startsWith('thread-')) {
1632                dispatch({ type: 'ADD_THREAD_EVENT', event })
1633              }
1634            }
1635            if (event.kind === 3 && event.pubkey === state.pubkey) {
1636              const contacts = event.tags
1637                .filter((t) => t[0] === 'p')
1638                .map((t) => t[1])
1639              dispatch({ type: 'SET_CONTACTS', contacts })
1640            }
1641            if (event.kind === 10002 && event.pubkey === state.pubkey) {
1642              const relays = event.tags
1643                .filter((t) => t[0] === 'r')
1644                .map((t) => t[1])
1645              if (relays.length > 0) dispatch({ type: 'SET_RELAYS', relays })
1646            }
1647            break
1648          }
1649          case 'EOSE': {
1650            const [subId] = args
1651            if (subId === 'feed-live') {
1652              dispatch({ type: 'SET_FEED_READY' })
1653            }
1654            if (subId.startsWith('feed-more-')) {
1655              setTimeout(() => dispatch({ type: 'FEED_LOADED_MORE' }), 200)
1656            }
1657            if (subId.startsWith('hashtag-more-')) {
1658              setTimeout(() => dispatch({ type: 'HASHTAG_LOADED_MORE' }), 200)
1659            }
1660            if (subId.startsWith('relay-more-')) {
1661              setTimeout(() => dispatch({ type: 'RELAY_LOADED_MORE' }), 200)
1662            }
1663            break
1664          }
1665          case 'NOTICE':
1666            dispatch({ type: 'SET_SNACKBAR', message: args[0] })
1667            break
1668          case 'RELAY_INFO': {
1669            const [relayUrl, info] = args
1670            if (info?.graph_query?.enabled || info?.proxy_query?.enabled) {
1671              dispatch({ type: 'SET_ORLY_RELAYS', relays: [...(state.orlyRelays || []).filter((r) => r !== relayUrl), relayUrl] })
1672            }
1673            break
1674          }
1675          case 'SIGNED': {
1676            const [requestId, signedEvent] = args
1677            const cb = signCallbacks.get(requestId)
1678            if (cb) { signCallbacks.delete(requestId); cb(signedEvent) }
1679            break
1680          }
1681          case 'BROADCAST_DONE': {
1682            const [eventCount, relayCount] = args
1683            dispatch({ type: 'SET_SNACKBAR', message: eventCount ? 'broadcast ' + eventCount + ' events to ' + relayCount + ' relays' : 'no identity events found to broadcast' })
1684            break
1685          }
1686          case 'SIGN_ERROR': {
1687            const [requestId, errMsg] = args
1688            signCallbacks.delete(requestId)
1689            dispatch({ type: 'SET_SNACKBAR', message: 'sign error: ' + errMsg })
1690            break
1691          }
1692          case 'DM_LIST': {
1693            dispatch({ type: 'SET_CONVERSATIONS', conversations: args[0] })
1694            break
1695          }
1696          case 'DM_HISTORY': {
1697            const [peer, messages] = args
1698            if (peer === state.activeDM) dispatch({ type: 'SET_DM_MESSAGES', messages })
1699            break
1700          }
1701          case 'DM_RECEIVED': {
1702            const dm = args[0]
1703            dispatch({ type: 'ADD_CONVERSATION', conversation: { peer: dm.peer, lastMessage: dm.content.slice(0, 80), lastTs: dm.created_at, from: dm.from } })
1704            // always dispatch — reducer checks if peer matches activeDM
1705            dispatch({ type: 'ADD_DM_MESSAGE', message: dm })
1706            break
1707          }
1708          case 'DM_SENT': {
1709            const [recipientPubkey, success, errMsg] = args
1710            if (!success) dispatch({ type: 'SET_SNACKBAR', message: 'DM error: ' + errMsg })
1711            break
1712          }
1713          case 'DM_SEND_VIA_EXT': {
1714            // Extension mode: SW can't encrypt/sign, do it here with window.nostr
1715            const [dmRecipient, dmContent, dmRelayUrls] = args
1716            ;(async () => {
1717              try {
1718                if (!window.nostr?.nip04) throw new Error('extension has no nip04')
1719                const ciphertext = await window.nostr.nip04.encrypt(dmRecipient, dmContent)
1720                const unsigned = {
1721                  kind: 4,
1722                  content: ciphertext,
1723                  tags: [['p', dmRecipient]],
1724                  created_at: Math.floor(Date.now() / 1000),
1725                  pubkey: state.pubkey,
1726                }
1727                const signed = await window.nostr.signEvent(unsigned)
1728                send(['EVENT', signed])
1729                send(['DM_EXT_RESULT', dmRecipient, dmContent, true, ''])
1730              } catch (err) {
1731                send(['DM_EXT_RESULT', dmRecipient, dmContent, false, err.message])
1732              }
1733            })()
1734            break
1735          }
1736          case 'DECRYPT_NIP04':
1737          case 'DECRYPT_NIP44':
1738          case 'ENCRYPT_NIP04':
1739          case 'ENCRYPT_NIP44': {
1740            // Extension mode: SW asking us to do crypto
1741            const [reqId, pubkey, text] = args
1742            handleCryptoRequest(type, reqId, pubkey, text)
1743            break
1744          }
1745        }
1746      }
1747      navigator.serviceWorker.addEventListener('message', handler)
1748      return () => navigator.serviceWorker.removeEventListener('message', handler)
1749    }, [state.pubkey, state.activeDM])
1750  
1751    // on login, fetch profile + contacts + relay list from remote relays
1752    useEffect(() => {
1753      if (!state.pubkey) return
1754      // tell SW which relays to propagate fetched events to
1755      send(['SET_WRITE_RELAYS', state.relays])
1756      // ensure SW has our pubkey for DM routing (extension mode doesn't send SET_KEY)
1757      if (state.loginMode === 'extension') send(['SET_PUBKEY', state.pubkey])
1758      const relays = state.relays.slice(0, 3)
1759      send(['PROXY', 'init-profile', { kinds: [0], authors: [state.pubkey], limit: 1 }, ...profileRelays(relays)])
1760      send(['PROXY', 'init-contacts', { kinds: [3], authors: [state.pubkey], limit: 1 }, ...relays])
1761      send(['PROXY', 'init-relays', { kinds: [10002], authors: [state.pubkey], limit: 1 }, ...relays])
1762      // probe all relays for ORLY graph_query capability
1763      for (const r of state.relays) send(['RELAY_INFO', r])
1764    }, [state.pubkey])
1765  
1766    // fetch thread when thread tab opens
1767    useEffect(() => {
1768      if (state.activeTab !== 'thread' || !state.threadRootId) return
1769      const rootId = state.threadRootId
1770      const relays = state.relays.slice(0, 4)
1771      const hints = state.threadRelayHints || []
1772      const orly = state.orlyRelays[0]
1773      const allRelays = [...new Set([...hints, ...relays])]
1774  
1775      // collect all ancestor IDs from the clicked event's e tags
1776      const clickedEv = state.threadEvents.find((e) => e.id === state.threadEventId)
1777      const ancestorIds = (clickedEv?.tags || [])
1778        .filter((t) => t[0] === 'e')
1779        .map((t) => t[1])
1780        .filter((id) => id !== rootId)
1781  
1782      // mark root + ancestors as queried so deepen effect doesn't re-query
1783      dispatch({ type: 'MARK_THREAD_QUERIED', ids: [rootId, ...ancestorIds] })
1784  
1785      // fetch root + all ancestors by ID
1786      send(['PROXY', 'thread-root', { ids: [rootId, ...ancestorIds] }, ...allRelays])
1787  
1788      // fetch ALL events that reference the root in any e tag — this gets the
1789      // entire thread tree, not just direct replies to ancestors in our lineage
1790      if (orly) {
1791        const hintRelays = [...new Set([...hints, ...relays.filter((r) => r !== orly)])]
1792        send(['PROXY', 'thread-replies', { '#e': [rootId], kinds: [1], limit: 500, _proxy: hintRelays }, orly])
1793        send(['PROXY', 'thread-graph', { _graph: [rootId, 3, 'ee', 'in'], kinds: [1] }, orly])
1794      } else {
1795        send(['PROXY', 'thread-replies', { '#e': [rootId], kinds: [1], limit: 500 }, ...relays])
1796      }
1797    }, [state.activeTab, state.threadRootId])
1798  
1799    // deepen thread: fetch replies to replies we haven't queried yet
1800    useEffect(() => {
1801      if (state.activeTab !== 'thread' || state.threadEvents.length < 2) return
1802      const queried = new Set(state.threadQueriedIds)
1803      const newIds = state.threadEvents
1804        .map((e) => e.id)
1805        .filter((id) => !queried.has(id))
1806      if (!newIds.length) return
1807      dispatch({ type: 'MARK_THREAD_QUERIED', ids: newIds })
1808      const relays = state.relays.slice(0, 4)
1809      const orly = state.orlyRelays[0]
1810      // batch query: find events that tag any of these IDs
1811      if (orly) {
1812        const hintRelays = relays.filter((r) => r !== orly)
1813        send(['PROXY', 'thread-deep', { '#e': newIds, kinds: [1], limit: 200, _proxy: hintRelays }, orly])
1814      } else {
1815        send(['PROXY', 'thread-deep', { '#e': newIds, kinds: [1], limit: 200 }, ...relays])
1816      }
1817    }, [state.threadEvents.length])
1818  
1819    // fetch profiles for thread participants as events arrive
1820    useEffect(() => {
1821      if (state.activeTab !== 'thread' || !state.threadEvents.length) return
1822      const unknownAuthors = state.threadEvents
1823        .filter((e) => !state.profiles.has(e.pubkey))
1824        .map((e) => e.pubkey)
1825      if (!unknownAuthors.length) return
1826      const relays = state.relays.slice(0, 3)
1827      const dedupedAuthors = [...new Set(unknownAuthors)]
1828      send(['PROXY', 'thread-profiles', { kinds: [0], authors: dedupedAuthors }, ...profileRelays(relays)])
1829    }, [state.threadEvents.length])
1830  
1831    // subscribe to feed when contacts arrive
1832    useEffect(() => {
1833      if (!state.contacts.length) return
1834      const batch = state.contacts.slice(0, 100)
1835      const relays = state.relays.slice(0, 4)
1836      send(['PROXY', 'feed-profiles', { kinds: [0], authors: batch }, ...profileRelays(relays)])
1837      send(['PROXY', 'feed-notes', { kinds: [1, 6], authors: batch, limit: 50 }, ...relays])
1838      send(['REQ', 'feed-live', { kinds: [1, 6], authors: batch }])
1839    }, [state.contacts])
1840  
1841    const tabTitle = { feed: 'Following', dms: 'DMs', relays: 'Relays', hashtags: 'Hashtags', profile: 'Profile', settings: 'Settings', thread: 'Thread' }
1842  
1843    return html`
1844      <${AppContext.Provider} value=${{ state, dispatch }}>
1845        ${!state.pubkey && state.hasStoredSession
1846          ? html`<${PasswordPrompt} />`
1847          : !state.pubkey
1848          ? html`<${LoginScreen} />`
1849          : html`
1850            <div class="sidebar-toggle" onClick=${() => setSidebarOpen(!sidebarOpen)}>☰</div>
1851            <${Sidebar} sidebarOpen=${sidebarOpen} setSidebarOpen=${setSidebarOpen} />
1852            <div class="main">
1853              <div class="toolbar">
1854                <span>${tabTitle[state.activeTab] || 'smesh'}</span>
1855                <button class="toolbar-reload" onClick=${() => location.reload()} title="reload">⟳</button>
1856              </div>
1857              ${state.activeTab === 'feed' && html`<${Feed} />`}
1858              ${state.activeTab === 'feed' && html`<${Compose} />`}
1859              ${state.activeTab === 'dms' && html`<${DMView} />`}
1860              ${state.activeTab === 'relays' && html`<${RelayFeed} />`}
1861              ${state.activeTab === 'hashtags' && html`<${HashtagFeed} />`}
1862              ${state.activeTab === 'thread' && html`<${ThreadView} />`}
1863              ${state.activeTab === 'profile' && html`<${ProfileView} />`}
1864              ${state.activeTab === 'settings' && html`<${Settings} />`}
1865            </div>
1866          `}
1867        <${Lightbox} />
1868        ${state.snackbar && html`
1869          <${Snackbar}
1870            message=${state.snackbar}
1871            onDone=${() => dispatch({ type: 'SET_SNACKBAR', message: null })}
1872          />`}
1873        <${UpdateSnackbar} />
1874      <//>
1875    `
1876  }
1877  
1878  // ─── boot ────────────────────────────────────────────────────────────
1879  
1880  let pendingWorker = null
1881  
1882  async function boot() {
1883    const el = document.getElementById('app')
1884    try {
1885      if ('serviceWorker' in navigator) {
1886        const reg = await navigator.serviceWorker.register('./sw.js', { type: 'module' })
1887        if (!navigator.serviceWorker.controller) {
1888          await new Promise((resolve) => {
1889            navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })
1890          })
1891        }
1892  
1893        // detect updates — new SW installed and waiting
1894        const onUpdate = (worker) => {
1895          pendingWorker = worker
1896          window.dispatchEvent(new CustomEvent('sw-update'))
1897        }
1898  
1899        if (reg.waiting) onUpdate(reg.waiting)
1900        reg.addEventListener('updatefound', () => {
1901          const installing = reg.installing
1902          if (!installing) return
1903          installing.addEventListener('statechange', () => {
1904            if (installing.state === 'installed' && navigator.serviceWorker.controller) {
1905              onUpdate(installing)
1906            }
1907          })
1908        })
1909  
1910        // reload when new SW takes over
1911        navigator.serviceWorker.addEventListener('controllerchange', () => {
1912          if (pendingWorker) location.reload()
1913        })
1914      }
1915      render(html`<${App} />`, el)
1916    } catch (err) {
1917      el.innerHTML = '<pre style="color:red;padding:20px">' + err.stack + '</pre>'
1918    }
1919  }
1920  
1921  boot()
1922  </script>
1923  </body>
1924  </html>
1925