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