main.mx raw
1 package main
2
3 import (
4 "runtime"
5 "crypto/sha256"
6 "git.smesh.lol/moxie/pkg/mxutil"
7 "git.smesh.lol/musiquay/pkg/protocol"
8 "git.smesh.lol/musiquay/web/common/accum"
9 "git.smesh.lol/musiquay/web/common/helpers"
10 "git.smesh.lol/musiquay/web/common/jsbridge/dom"
11 "git.smesh.lol/musiquay/web/common/jsbridge/feed"
12 "git.smesh.lol/musiquay/web/common/jsbridge/localstorage"
13 "git.smesh.lol/musiquay/web/common/jsbridge/markdown"
14 "git.smesh.lol/musiquay/web/common/jsbridge/notif"
15 "git.smesh.lol/musiquay/web/common/jsbridge/profile"
16 "git.smesh.lol/musiquay/web/common/jsbridge/relayproxy"
17 "git.smesh.lol/musiquay/web/common/jsbridge/signer"
18 "git.smesh.lol/musiquay/web/common/protocolwire"
19 "git.smesh.lol/nostr/pkg/core"
20 )
21
22 const (
23 lsKeyPubkey = "musiquay-pubkey"
24 lsKeyTheme = "musiquay-theme"
25 lsKeyRelayPolicy = "musiquay-relay-policy"
26 lsKeyRelayBlocklist = "musiquay-relay-blocklist"
27 lsKeyRelayList = "musiquay-relays"
28 lsKeyBlossomServer = "musiquay-blossom-server"
29 lsKeyInvidiousURL = "musiquay-invidious-url"
30 lsKeyCampionURL = "musiquay-campion-url"
31 lsKeyDevMode = "musiquay-dev-mode"
32 lsKeyRelayRoles = "musiquay-relay-roles"
33 )
34
35 // Relay policy bitfield - persisted as decimal int in lsKeyRelayPolicy.
36 // All flags default to 0 (no policy enforced). Enforcement happens in later
37 // steps; step 3 only reads and writes the state.
38 const (
39 policyRequireAuth = 1 << 0 // require NIP-42 support
40 policyRequireProtect = 1 << 1 // require NIP-70 support
41 policyBlockPayment = 1 << 2 // block relays with limitation.payment_required
42 policyBlockRestricted = 1 << 3 // block relays with limitation.restricted_writes
43 )
44
45 const activityLogMax = 500
46
47 type logEntry struct {
48 ts int64
49 action string // "boot", "publish", "reply", "react", "rebroadcast", "subscribe", "relay-ok", "relay-err"
50 summary string // one-line title
51 detail string // multi-line detail, can be empty
52 }
53
54 var (
55
56 // Profile data from kind 0.
57
58 // DOM refs that need updating after creation.
59 signerBtnEl dom.Element
60
61 // Search page state.
62 notifRowCBs accum.Ints // callback IDs registered by current notif rows; released on rebuild
63
64 // App root - content goes here, not body (snackbar stays outside).
65
66 // Messaging UI state.
67
68 // Relay tracking - parallel slices, grown dynamically.
69
70 // Relay policy (loaded once at startup from localStorage).
71
72
73 // Author name/pic view cache - small, populated from P_RESOLVED and IDB bootstrap.
74 // Profile Worker owns canonical data; Shell keeps this for sync DOM reads.
75 // Shell-local fetch dedup hints - not authoritative (Profile Worker owns dedup).
76 // Empty map reads return false so all guards fire, Profile Worker handles actual dedup.
77 // Two-gen rotation: when current exceeds 10000, old is dropped and current becomes old.
78 // retryRound names the retry-batch subscriptions; authorSubPK maps an
79 // outstanding profile subscription id to its pubkey. Both are used by
80 // retryMissingProfiles, reached from the EOSE handler.
81
82 // User card hover popover.
83
84
85 // QR modal.
86
87 // Profile page tab state.
88 // Own follow/mute lists - Shell's working copy for sync filter and action ops.
89 // Profile Worker is authoritative; Shell is updated via P_FOLLOWS/P_MUTES pushes.
90 // Profile content/follows/mutes/relays for viewed profiles (small view caches).
91
92 // Profile page cache - keeps rendered profile DOMs alive.
93
94 // History/routing.
95
96 // Embedded nostr: entity resolution.
97
98 // @-mention autocomplete.
99
100 // Thread view state.
101
102 // Reply preview state.
103
104 // Action button state (reactions/reposts).
105
106 // Compose editor state.
107
108 // Emoji picker state.
109 emojiBackdrop dom.Element
110 emojiAnchor dom.Element
111
112 // Cached events for repost/quote content.
113
114 // Global ready flag - set after showApp finishes all map init + DOM setup.
115
116 // Activity log state. activityLog is a fixed ring of activityLogMax entries
117 // presized on first use, newest at slot (activityLogNext-1) mod activityLogMax.
118 // Each entry is a single action with a short summary and optional multi-line
119 // detail.
120
121 // Live-update subscriber callbacks keyed by event ID. The relay info modal
122 // registers a callback for its evID on open; the SEEN_ON handler fires the
123 // registered callback (if any) after updating eventRelays. Modal must
124 // unregister on close. Single registration per evID - second registration
125 // replaces the first.
126
127 // Feed controls.
128 refreshBtn dom.Element
129
130 )
131
132 // ── Rotate-map cache bounds ───────────────────────────────────────────────────
133 // Each cache group has two generations: current and old. When current exceeds
134 // its threshold, old is dropped and current becomes old. Lookups check both.
135
136 const (
137 rotateEventMax = 2000
138 rotateAuthorMax = 500
139 rotateEmbedMax = 200
140 rotateReplyMax = 500
141 rotateProfileMax = 10
142 notifEventsMax = 500
143 rotateFetchedMax = 10000
144 )
145
146 var (
147 // Event data "old" generation.
148
149 // Author name/pic "old" generation - two-gen rotation so GC can collect stale entries.
150
151 // Embed "old" generation.
152
153 // Reply "old" generation.
154 )
155
156 // rotateEventCaches swaps the event data caches and releases dropped DOM handles.
157 func rotateEventCaches() {
158 var toRelease []int32
159 for id, el := range appSt.noteElementsOld {
160 if _, ok := appSt.noteElements[id]; !ok {
161 toRelease = mxutil.Ensure(toRelease, 1)
162 toRelease = push(toRelease, int32(el))
163 }
164 }
165 if len(toRelease) > 0 {
166 dom.ReleaseAll(toRelease)
167 }
168 appSt.eventCacheOld = appSt.eventCache
169 appSt.eventCache = map[string]*nostr.Event{}
170 appSt.seenEventsOld = appSt.seenEvents
171 appSt.seenEvents = map[string]bool{}
172 appSt.noteElementsOld = appSt.noteElements
173 appSt.noteElements = map[string]dom.Element{}
174 appSt.noteReactionMapOld = appSt.noteReactionMap
175 appSt.noteReactionMap = map[string]map[string]map[string]bool{}
176 appSt.noteRepostCountsOld = appSt.noteRepostCounts
177 appSt.noteRepostCounts = map[string]map[string]bool{}
178 appSt.noteZapSeenOld = appSt.noteZapSeen
179 appSt.noteZapSeen = map[string]map[string]bool{}
180 appSt.actionFetchedIDsOld = appSt.actionFetchedIDs
181 appSt.actionFetchedIDs = map[string]bool{}
182 appSt.eventRelaysOld = appSt.eventRelays
183 appSt.eventRelays = map[string][]string{}
184 appSt.noteReactionEls = map[string]dom.Element{}
185 appSt.noteRepostEls = map[string]dom.Element{}
186 appSt.noteZapEls = map[string]dom.Element{}
187 appSt.eventCacheCount = 0
188 }
189
190 // rotateAuthorCaches rotates the Shell name/pic view cache (two-gen for GC).
191 // Profile Worker owns the canonical data rotation; this only covers Shell's small cache.
192 func rotateAuthorCaches() {
193 appSt.authorNamesOld = appSt.authorNames
194 appSt.authorNames = map[string]string{}
195 appSt.authorPicsOld = appSt.authorPics
196 appSt.authorPics = map[string]string{}
197 appSt.authorCacheCount = 0
198 }
199
200 // rotateEmbedCaches swaps the embed caches.
201 func rotateEmbedCaches() {
202 appSt.embedCallbacksOld = appSt.embedCallbacks
203 appSt.embedCallbacks = map[string][]string{}
204 appSt.embedRequestedOld = appSt.embedRequested
205 appSt.embedRequested = map[string]bool{}
206 appSt.embedSubIDsOld = appSt.embedSubIDs
207 appSt.embedSubIDs = map[string][]string{}
208 appSt.embedCoordCBsOld = appSt.embedCoordCBs
209 appSt.embedCoordCBs = map[string][]string{}
210 appSt.embedRelayHintsOld = appSt.embedRelayHints
211 appSt.embedRelayHints = map[string][]string{}
212 appSt.embedCacheCount = 0
213 }
214
215 // lookupFetchedK0 checks current and old generation.
216 func lookupFetchedK0(pk string) (ok bool) {
217 if appSt.fetchedK0[pk] {
218 return true
219 }
220 return appSt.fetchedK0Old[pk]
221 }
222
223 // setFetchedK0 sets the entry and rotates if threshold exceeded.
224 func setFetchedK0(pk string, v bool) {
225 appSt.fetchedK0[pk] = v
226 if v && len(appSt.fetchedK0) > rotateFetchedMax {
227 appSt.fetchedK0Old = appSt.fetchedK0
228 appSt.fetchedK0 = map[string]bool{}
229 }
230 }
231
232 // lookupFetchedK10k checks current and old generation.
233 func lookupFetchedK10k(pk string) (ok bool) {
234 if appSt.fetchedK10k[pk] {
235 return true
236 }
237 return appSt.fetchedK10kOld[pk]
238 }
239
240 // setFetchedK10k sets the entry and rotates if threshold exceeded.
241 func setFetchedK10k(pk string, v bool) {
242 appSt.fetchedK10k[pk] = v
243 if v && len(appSt.fetchedK10k) > rotateFetchedMax {
244 appSt.fetchedK10kOld = appSt.fetchedK10k
245 appSt.fetchedK10k = map[string]bool{}
246 }
247 }
248
249 // rotateReplyCaches swaps the reply caches.
250 func rotateReplyCaches() {
251 appSt.replyCacheOld = appSt.replyCache
252 appSt.replyCache = map[string]string{}
253 appSt.replyAvatarCacheOld = appSt.replyAvatarCache
254 appSt.replyAvatarCache = map[string]string{}
255 appSt.replyLineCacheOld = appSt.replyLineCache
256 appSt.replyLineCache = map[string]string{}
257 appSt.replyAuthorMapOld = appSt.replyAuthorMap
258 appSt.replyAuthorMap = map[string]string{}
259 appSt.replyNeedNameOld = appSt.replyNeedName
260 appSt.replyNeedName = map[string][]dom.Element{}
261 appSt.replyHintsOld = appSt.replyHints
262 appSt.replyHints = map[string]string{}
263 appSt.replyCount = 0
264 }
265
266 // lookupAuthorName returns the cached display name, checking both generations.
267 func lookupAuthorName(pk string) (s string, ok3 bool) {
268 if namew2, ok2 := appSt.authorNames[pk]; ok2 {
269 return namew2, true
270 }
271 namew1, ok := appSt.authorNamesOld[pk]
272 return namew1, ok
273 }
274
275 // lookupAuthorPic returns the cached avatar URL, checking both generations.
276 func lookupAuthorPic(pk string) (s string, ok3 bool) {
277 if picw2, ok2 := appSt.authorPics[pk]; ok2 {
278 return picw2, true
279 }
280 picw1, ok := appSt.authorPicsOld[pk]
281 return picw1, ok
282 }
283
284 // lookupEvent returns the cached event, checking both generations.
285 func lookupEvent(id string) (p *nostr.Event, ok3 bool) {
286 if evw2, ok2 := appSt.eventCache[id]; ok2 {
287 return evw2, true
288 }
289 evw1, ok := appSt.eventCacheOld[id]
290 return evw1, ok
291 }
292
293 // setEvent stores an event in the cache and rotates if the threshold is reached.
294 func setEvent(id string, ev *nostr.Event) {
295 if _, already := appSt.eventCache[id]; !already {
296 appSt.eventCacheCount++
297 if appSt.eventCacheCount > rotateEventMax {
298 rotateEventCaches()
299 }
300 }
301 appSt.eventCache[id] = ev
302 }
303
304 // setAuthorName caches a display name, rotating if the view cache exceeds the threshold.
305 func setAuthorName(pk, name string) {
306 if _, already := appSt.authorNames[pk]; !already {
307 appSt.authorCacheCount++
308 if appSt.authorCacheCount > rotateAuthorMax {
309 rotateAuthorCaches()
310 }
311 }
312 appSt.authorNames[pk] = name
313 }
314
315 // setAuthorPic caches an avatar URL.
316 func setAuthorPic(pk, pic string) {
317 appSt.authorPics[pk] = pic
318 }
319
320 const orlyRelay = "wss://relay.orly.dev"
321
322
323 func isLocalDev() (ok bool) {
324 h := dom.Hostname()
325 return h == "localhost" || (len(h) > 4 && h[:4] == "127.")
326 }
327
328 func localRelayURL() (s string) {
329 h := dom.Hostname()
330 p := dom.Port()
331 if p == "" || p == "443" || p == "80" {
332 return "wss://" | h
333 }
334 return "ws://" | h | ":" | p
335 }
336
337 // appState is the page's mutable state. Package globals are immutable
338 // outside initialization, so the DOM handles, caches, queues and flags the
339 // app mutates live in one self-mutating type reached through appSt.
340 type appState struct {
341 aboutLogoEl dom.Element
342 aboutPage dom.Element
343 actionFetchQueue []string // IDs awaiting batch fetch
344 actionFetchTimer int32
345 actionFetchedIDs map[string]bool // event IDs already queued for kind 6/7
346 actionFetchedIDsOld map[string]bool
347 activePage string
348 activeProfileNoteSub string
349 activityLog []*logEntry
350 activityLogNext int32 // total entries ever logged; slot = next mod activityLogMax
351 appReady bool
352 authorCacheCount int32
353 authorNames map[string]string // pubkey hex -> display name
354 authorNamesOld map[string]string
355 authorPics map[string]string // pubkey hex -> avatar URL
356 authorPicsOld map[string]string
357 authorSubPK map[string]string
358 avatarEl dom.Element
359 blossomServerURL string
360 bootInProgress bool // true while boot is running; controls logBtn highlight
361 bottomBar dom.Element
362 broadcastBtnEl dom.Element
363 broadcastRow dom.Element
364 campionURL string
365 composePage dom.Element
366 composePreview dom.Element
367 composeRelayChecks map[string]bool // relay URL -> selected for publish
368 composeRelayPopEl dom.Element
369 composeRelayPopOpen bool
370 composeTextarea dom.Element
371 contactTs int64 // kind 3 latest timestamp
372 contentArea dom.Element // scroll container
373 currentLang string
374 defaultRelays []string
375 devMode bool
376 donSubs map[string]func(string) // subID -> callback(plain JSON or "")
377 editorComposer dom.Element // inline composer div appended to note
378 editorDrafts map[string]string // "reply:evID" or "quote:evID" -> draft text
379 editorMode string // "reply" or "quote"
380 editorNoteEl dom.Element // the note element containing the composer
381 editorOpen bool
382 editorPreviewEl dom.Element
383 editorPreviewing bool
384 editorTargetEv *nostr.Event
385 editorTextarea dom.Element
386 embedCacheCount int32
387 embedCallbacks map[string][]string // hex event ID -> DOM element IDs awaiting fill
388 embedCallbacksOld map[string][]string
389 embedCoordCBs map[string][]string // "kind:pubkey:d" -> DOM element IDs awaiting fill
390 embedCoordCBsOld map[string][]string
391 embedCoordSubIDs map[string]string // sub ID -> coord key
392 embedRelayHints map[string][]string // hex event ID -> relay hints from nevent TLV
393 embedRelayHintsOld map[string][]string
394 embedRequested map[string]bool // hex event IDs already sent to PROXY
395 embedRequestedOld map[string]bool
396 embedSubIDs map[string][]string // sub ID -> list of hex event IDs requested in that sub
397 embedSubIDsOld map[string][]string
398 emojiAutoEl dom.Element // emoji autocomplete popup
399 emojiAutoOpen bool
400 emojiAutoStart int32 // position of the opening ':'
401 emojiAutoTA dom.Element // textarea the emoji popup is attached to
402 emojiByName map[string]string
403 emojiOpen bool
404 emojiOverlay dom.Element
405 emojiTargetEv *nostr.Event
406 encKeyHex string // hex symmetric key, sent to SW
407 eventCache map[string]*nostr.Event
408 eventCacheCount int32
409 eventCacheOld map[string]*nostr.Event
410 eventCount int32
411 eventRelays map[string][]string // event ID -> relay URLs that delivered it
412 eventRelaysOld map[string][]string
413 feedBuffer []*nostr.Event // new events buffered after initial load
414 feedContainer dom.Element
415 feedDeferTimer int32 // timer for deferred feed subscription
416 feedEmptyStreak int32 // consecutive empty EOSE responses
417 feedExhausted bool // true when EOSE returns no new events
418 feedHasNew bool // true when feedBuffer is non-empty (pulse indicator)
419 feedInitialLoad bool // true until ~3s after first EOSE on "feed"
420 feedInitialLoadTimer int32 // delays flipping feedInitialLoad=false so late events from slower relays still render directly
421 feedLastSubTs int64 // timestamp of last actual subscription
422 feedLoader dom.Element
423 feedLoading bool // true while fetching older events
424 feedMode string // "follows", "relays", or relay URL
425 feedMoreGot int32
426 feedMoreTimer int32
427 feedPage dom.Element
428 feedPopoverEl dom.Element
429 feedPopoverOpen bool
430 feedSelectEl dom.Element
431 feedSubTimer int32 // throttle timer for subscribeFeed
432 feedSubscribed bool // true once subscribeFeed has been called
433 fetchedK0 map[string]bool
434 fetchedK0Old map[string]bool
435 fetchedK10k map[string]bool
436 fetchedK10kOld map[string]bool
437 followSet map[string]bool // O(1) lookup derived from myFollows
438 gfExp [512]byte
439 gfLog [256]byte
440 gfTables bool
441 homePage dom.Element // Musiquay base: empty landing page; the kind-1 forum is parked
442 hoverCardDismiss int32
443 hoverCardEl dom.Element
444 hoverCardHover bool
445 hoverCardPK string
446 hoverCardParent dom.Element
447 hoverCardTimer int32
448 idbLoaded bool
449 inboxTs int64 // kind 10050 latest timestamp
450 invidiousURL string
451 isDark bool
452 loadMoreBtn dom.Element
453 logBtn dom.Element
454 logListEl dom.Element
455 logPage dom.Element
456 logoSVGCache string
457 marmotInited bool
458 mentionAtPos int32 // character position of the @ in the textarea
459 mentionItems []mentionMatch
460 mentionPopup dom.Element // the popup container
461 mentionQuery string // current query text (after @)
462 mentionSelIdx int32 // highlighted index in popup
463 mentionTA dom.Element // textarea the popup is attached to
464 msgComposeInput dom.Element // textarea
465 msgCurrentConvoID string // peer pubkey (DM) or groupIDHex (group) of open thread, "" when on list
466 msgCurrentKind string // "dm" or "group"
467 msgListContainer dom.Element // conversation list view
468 msgPage dom.Element
469 msgThreadContainer dom.Element // thread view (header + messages + compose)
470 msgThreadMessages dom.Element // scrollable message area
471 msgView string // "list" or "thread"
472 muteSet map[string]bool // O(1) lookup derived from myMutes
473 muteTs int64 // kind 10000 latest timestamp
474 myFollows []string
475 myMutes []string
476 nameEl dom.Element
477 navPop bool // true during popstate - suppresses pushState
478 noteElements map[string]dom.Element // event ID -> note DOM element
479 noteElementsOld map[string]dom.Element
480 noteReactionEls map[string]dom.Element // event ID -> reaction display row
481 noteReactionMap map[string]map[string]map[string]bool // event ID -> emoji -> pubkey set
482 noteReactionMapOld map[string]map[string]map[string]bool
483 noteRepostCounts map[string]map[string]bool // event ID -> pubkey set
484 noteRepostCountsOld map[string]map[string]bool
485 noteRepostEls map[string]dom.Element // event ID -> repost counter span
486 noteZapEls map[string]dom.Element // event ID -> zap total chip
487 noteZapSeen map[string]map[string]bool // event ID -> set of 9735 receipt IDs counted
488 noteZapSeenOld map[string]map[string]bool
489 noteZapTotals map[string]int64 // event ID -> total msats received
490 notifBarGroup dom.Element
491 notifBuilt bool // true after first render
492 notifContainer dom.Element
493 notifDot dom.Element
494 notifEmptyEl dom.Element
495 notifEmptyStrk int32
496 notifEvents []*nostr.Event
497 notifExhausted bool
498 notifFilter string // "all", "mentions", "reactions", "zaps"
499 notifInitLoad bool
500 notifLoadMore dom.Element
501 notifLoading bool
502 notifMarkBtn dom.Element
503 notifMissing []string // refIDs not in eventCache, fetched after EOSE
504 notifMoreGot int32
505 notifMoreTimer int32
506 notifPage dom.Element
507 notifReadTs int64 // watermark: events with CreatedAt > this are unread
508 notifRefTimer int32
509 notifSeen map[string]bool
510 notifSelectEl dom.Element
511 notifUnread bool
512 nwcConnected map[string]bool // connID → open
513 nwcConns []NwcConn
514 nwcPendingQ map[string][]func(ws.Conn, bool) // queued while connecting
515 nwcSockets map[string]ws.Conn // connID → ws handle
516 nwcSubs map[string]*nwcPending // subID → pending
517 oldestFeedTs int64 // oldest created_at in feed - for infinite scroll
518 oldestNotifTs int64
519 pageTitleEl dom.Element
520 pendingDeletes map[string]string // delete event ID -> target event ID
521 pendingK0Name string // nickname to publish as kind 0 after login
522 pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile
523 pendingNotifAvatars map[string][]dom.Element // pubkey hex -> notif avatar imgs awaiting profile
524 pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation
525 pendingZapRows map[string][]zapRowPending // authorPK -> rows awaiting lud16
526 popoverEl dom.Element
527 popoverOpen bool
528 popoverSummary dom.Element
529 profileContentCache map[string]string
530 profileFollowsCache map[string][]string
531 profileMutesCache map[string][]string
532 profileName string
533 profileNotesSeen map[string]bool
534 profilePage dom.Element
535 profilePic string
536 profileRelaysCache map[string][]string
537 profileTab string
538 profileTabBtns map[string]dom.Element
539 profileTabContent dom.Element
540 profileTs int64
541 profileViewPK string
542 profileWrapOrder []string // LRU order (oldest first)
543 profileWrapTabCont map[string]dom.Element // pk -> tab content ref
544 profileWrapTabSel map[string]string // pk -> selected tab
545 profileWrapTabs map[string]map[string]dom.Element // pk -> tab button map
546 profileWrappers map[string]dom.Element // pk -> wrapper div
547 pubhex string
548 refreshPulseOn bool
549 refreshPulseTimer int32
550 refreshSpinAngle int32
551 refreshSpinTimer int32
552 refreshSpinning bool
553 relayBadgeContainers []dom.Element // capability warning badges, rewritten on NIP-11 fetch
554 relayBlocklist []string // exact-URL blocklist (persisted; kind 10006 publication is future work)
555 relayCaps []relayCap // NIP-11 snapshot (populated async after addRelay)
556 relayCapsTs []int64 // unix seconds when each relayCaps entry was fetched
557 relayDots []dom.Element
558 relayInfoEl dom.Element // relay info page wrapper (removed on nav)
559 relayLabels []dom.Element
560 relayListTs int64 // kind 10002 latest timestamp
561 relayPolicyFlags int32 // bitfield of policy* constants
562 relayReadBtns []dom.Element // per-relay "r" toggle button
563 relayRole []string // "both", "read", "write" per relay
564 relayRows []dom.Element // popover row container - used by removeRelayByURL
565 relayURLs []string
566 relayUserPick []bool // true = from user's kind 10002
567 relayWriteBtns []dom.Element // per-relay "w" toggle button
568 replyAuthorMap map[string]string // event ID -> author pubkey hex
569 replyAuthorMapOld map[string]string
570 replyAvatarCache map[string]string // event ID -> avatar URL of parent author
571 replyAvatarCacheOld map[string]string
572 replyCache map[string]string // event ID -> "name: first line"
573 replyCacheOld map[string]string
574 replyCount int32
575 replyHints map[string]string // event ID -> relay hint URL from e-tag
576 replyHintsOld map[string]string
577 replyLineCache map[string]string // event ID -> raw first line (no name)
578 replyLineCacheOld map[string]string
579 replyNeedName map[string][]dom.Element // event ID -> preview divs needing name update
580 replyNeedNameOld map[string][]dom.Element
581 replyPending map[string][]dom.Element // event ID -> preview divs awaiting fetch
582 replyQueue []string // event IDs to batch-fetch
583 replySweepTimer int32 // debounced sweep for unfound replies
584 replyTimer int32
585 retryRound int32
586 root dom.Element
587 routerInited bool // guard against duplicate listener registration
588 savedScrollTop string // feed scroll position before thread open
589 searchBarActive bool
590 searchBtnGlobal dom.Element // captured for deactivation from anywhere
591 searchContainer dom.Element
592 searchCurrentQ string
593 searchFilterBarEl dom.Element // sticky bar above results
594 searchFollowsOnly bool
595 searchInputEl dom.Element
596 searchNSFW bool
597 searchPage dom.Element
598 searchRendered int32 // how many filtered results are currently rendered
599 searchResults []*nostr.Event
600 searchReturnPage string // page to restore when user manually closes search
601 searchSeen map[string]bool
602 searchSortDesc bool // true = newest first (default)
603 searchSubmitBtn dom.Element
604 seenEvents map[string]bool // event ID -> already rendered
605 seenEventsOld map[string]bool
606 seenOnSubs map[string]func()
607 settingsPage dom.Element
608 settingsTs int64 // kind 30078 latest timestamp
609 sidebarCompose dom.Element
610 sidebarFeed dom.Element
611 sidebarMsg dom.Element
612 sidebarNotif dom.Element
613 sidebarSettings dom.Element
614 signerContent dom.Element
615 signerOpen bool
616 signerPanel dom.Element
617 signerStatus string // "none", "locked", "unlocked"
618 statusEl dom.Element
619 themeBtn dom.Element
620 threadActiveSubs []string // sub IDs to close when opening a new thread
621 threadContainer dom.Element
622 threadEvents map[string]*nostr.Event
623 threadFetchedIDs map[string]bool // IDs already queried for child replies
624 threadFocusID string
625 threadFollowUp int32 // follow-up round counter (0 = initial, caps at 3)
626 threadLastRendered int32 // event count at last render - skip if unchanged
627 threadOpen bool
628 threadPage dom.Element
629 threadPushedState bool // true if we pushed history for this thread
630 threadRelays []string // relay URLs used for this thread's fetches
631 threadRenderTimer int32
632 threadReturnPage string // page to return to when closing thread
633 threadRootID string
634 topBackBtn dom.Element
635 topBarLeft dom.Element
636 topBarRight dom.Element
637 userBtn dom.Element
638 viewProfileRow dom.Element
639 walletActiveID string
640 walletBalanceSats int64 // -1 = not fetched, -2 = fetch failed
641 walletBtnEl dom.Element
642 walletContent dom.Element
643 walletEnc string // "nip04" | "nip44", auto-picked on first get_info
644 walletInfoLoaded bool
645 walletLud16 string // cached from get_info
646 walletPanel dom.Element
647 walletPanelOpen bool
648 walletPayResultEl dom.Element
649 walletShowAddForm bool
650 ytTitleCache map[string]string
651 actionSubCounter int32
652 donNextSub int32
653 embedCounter int32
654 nwcNextSubN int32
655 profileSubCounter int32
656 threadGen int32 // generation counter - reject events from old threads
657 threadSubCount int32
658 }
659
660 var appSt *appState
661
662 func initState() {
663 if appSt != nil {
664 return
665 }
666 // appSt and everything it holds live as long as the page: build them in
667 // the root arena, not in this call's frame arena which dies on return.
668 runtime.SovereignSetArena(runtime.RootArena())
669 appSt = &appState{}
670 appSt.currentLang = "en"
671 appSt.defaultRelays = []string{
672 orlyRelay,
673 "wss://nostr.wine",
674 }
675 appSt.donSubs = map[string]func(string){}
676 appSt.nwcConnected = map[string]bool{}
677 appSt.nwcPendingQ = map[string][]func(ws.Conn, bool){}
678 appSt.nwcSockets = map[string]ws.Conn{}
679 appSt.nwcSubs = map[string]*nwcPending{}
680 appSt.walletBalanceSats = -1
681 appSt.ytTitleCache = map[string]string{}
682 runtime.SovereignRestoreArena(runtime.RootArena())
683 }
684
685 func initAppGlobals() {
686 dnsBlacklist = []string{
687 "wss://relay.nostr.band",
688 "wss://relay.nostr.band/",
689 }
690 discoveryRelays = []string{
691 "wss://purplepag.es",
692 "wss://relay.damus.io",
693 "wss://nos.lol",
694 }
695 migratedRelayTypes = map[string]bool{
696 "REQ": true,
697 "CLOSE": true,
698 "EVENT": true,
699 "PUBLISH_TO": true,
700 "PROXY": true,
701 "SET_PUBKEY": true,
702 "SET_WRITE_RELAYS": true,
703 "ENC_KEY": true,
704 }
705 swTypes = map[string]bool{
706 "activate-update": true,
707 }
708 emojiList = []emojiEntry{
709 {"\xf0\x9f\x98\x80", "grinning_face"}, {"\xf0\x9f\x98\x80", "grinning"}, {"\xf0\x9f\x98\x83", "grinning_face_with_big_eyes"},
710 {"\xf0\x9f\x98\x83", "smiley"}, {"\xf0\x9f\x98\x84", "grinning_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x84", "smile"},
711 {"\xf0\x9f\x98\x81", "beaming_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x81", "grin"}, {"\xf0\x9f\x98\x86", "grinning_squinting_face"},
712 {"\xf0\x9f\x98\x86", "laughing"}, {"\xf0\x9f\x98\x85", "grinning_face_with_sweat"}, {"\xf0\x9f\x98\x85", "sweat_smile"},
713 {"\xf0\x9f\xa4\xa3", "rolling_on_the_floor_laughing"}, {"\xf0\x9f\xa4\xa3", "rofl"}, {"\xf0\x9f\x98\x82", "face_with_tears_of_joy"},
714 {"\xf0\x9f\x98\x82", "joy"}, {"\xf0\x9f\x99\x82", "slightly_smiling_face"}, {"\xf0\x9f\x99\x82", "slight_smile"},
715 {"\xf0\x9f\x99\x83", "upside-down_face"}, {"\xf0\x9f\xab\xa0", "melting_face"}, {"\xf0\x9f\x98\x89", "winking_face"},
716 {"\xf0\x9f\x98\x89", "wink"}, {"\xf0\x9f\x98\x8a", "smiling_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x8a", "blush"},
717 {"\xf0\x9f\x98\x87", "smiling_face_with_halo"}, {"\xf0\x9f\x98\x87", "innocent"}, {"\xf0\x9f\xa5\xb0", "smiling_face_with_hearts"},
718 {"\xf0\x9f\x98\x8d", "smiling_face_with_heart-eyes"}, {"\xf0\x9f\x98\x8d", "heart_eyes"}, {"\xf0\x9f\xa4\xa9", "star-struck"},
719 {"\xf0\x9f\x98\x98", "face_blowing_a_kiss"}, {"\xf0\x9f\x98\x98", "kissing_heart"}, {"\xf0\x9f\x98\x97", "kissing_face"},
720 {"\xe2\x98\xba\xef\xb8\x8f", "smiling_face"}, {"\xf0\x9f\x98\x9a", "kissing_face_with_closed_eyes"}, {"\xf0\x9f\x98\x99", "kissing_face_with_smiling_eyes"},
721 {"\xf0\x9f\xa5\xb2", "smiling_face_with_tear"}, {"\xf0\x9f\x98\x8b", "face_savoring_food"}, {"\xf0\x9f\x98\x8b", "yum"},
722 {"\xf0\x9f\x98\x9b", "face_with_tongue"}, {"\xf0\x9f\x98\x9b", "stuck_out_tongue"}, {"\xf0\x9f\x98\x9c", "winking_face_with_tongue"},
723 {"\xf0\x9f\x98\x9c", "stuck_out_tongue_winking_eye"}, {"\xf0\x9f\xa4\xaa", "zany_face"}, {"\xf0\x9f\xa4\xaa", "zany"},
724 {"\xf0\x9f\x98\x9d", "squinting_face_with_tongue"}, {"\xf0\x9f\x98\x9d", "stuck_out_tongue_closed_eyes"}, {"\xf0\x9f\xa4\x91", "money-mouth_face"},
725 {"\xf0\x9f\xa4\x91", "money_mouth"}, {"\xf0\x9f\xa4\x97", "smiling_face_with_open_hands"}, {"\xf0\x9f\xa4\x97", "hugs"},
726 {"\xf0\x9f\xa4\xad", "face_with_hand_over_mouth"}, {"\xf0\x9f\xa4\xad", "shushing"}, {"\xf0\x9f\xab\xa2", "face_with_open_eyes_and_hand_over_mouth"},
727 {"\xf0\x9f\xab\xa3", "face_with_peeking_eye"}, {"\xf0\x9f\xa4\xab", "shushing_face"}, {"\xf0\x9f\xa4\xab", "shush"},
728 {"\xf0\x9f\xa4\x94", "thinking_face"}, {"\xf0\x9f\xa4\x94", "thinking"}, {"\xf0\x9f\xab\xa1", "saluting_face"},
729 {"\xf0\x9f\xa4\x90", "zipper-mouth_face"}, {"\xf0\x9f\xa4\x90", "zipper_mouth"}, {"\xf0\x9f\xa4\xa8", "face_with_raised_eyebrow"},
730 {"\xf0\x9f\xa4\xa8", "raised_eyebrow"}, {"\xf0\x9f\x98\x90", "neutral_face"}, {"\xf0\x9f\x98\x90", "neutral"},
731 {"\xf0\x9f\x98\x91", "expressionless_face"}, {"\xf0\x9f\x98\x91", "expressionless"}, {"\xf0\x9f\x98\xb6", "face_without_mouth"},
732 {"\xf0\x9f\x98\xb6", "no_mouth"}, {"\xf0\x9f\xab\xa5", "dotted_line_face"}, {"\xf0\x9f\x98\xb6\xe2\x80\x8d\xf0\x9f\x8c\xab\xef\xb8\x8f", "face_in_clouds"},
733 {"\xf0\x9f\x98\x8f", "smirking_face"}, {"\xf0\x9f\x98\x8f", "smirk"}, {"\xf0\x9f\x98\x92", "unamused_face"},
734 {"\xf0\x9f\x98\x92", "unamused"}, {"\xf0\x9f\x99\x84", "face_with_rolling_eyes"}, {"\xf0\x9f\x99\x84", "roll_eyes"},
735 {"\xf0\x9f\x98\xac", "grimacing_face"}, {"\xf0\x9f\x98\xac", "grimacing"}, {"\xf0\x9f\x98\xae\xe2\x80\x8d\xf0\x9f\x92\xa8", "face_exhaling"},
736 {"\xf0\x9f\xa4\xa5", "lying_face"}, {"\xf0\x9f\xa4\xa5", "lying"}, {"\xf0\x9f\xab\xa8", "shaking_face"},
737 {"\xf0\x9f\x99\x82\xe2\x80\x8d\xe2\x86\x94\xef\xb8\x8f", "head_shaking_horizontally"}, {"\xf0\x9f\x99\x82\xe2\x80\x8d\xe2\x86\x95\xef\xb8\x8f", "head_shaking_vertically"}, {"\xf0\x9f\x98\x8c", "relieved_face"},
738 {"\xf0\x9f\x98\x8c", "relieved"}, {"\xf0\x9f\x98\x94", "pensive_face"}, {"\xf0\x9f\x98\x94", "pensive"},
739 {"\xf0\x9f\x98\xaa", "sleepy_face"}, {"\xf0\x9f\x98\xaa", "sleepy"}, {"\xf0\x9f\xa4\xa4", "drooling_face"},
740 {"\xf0\x9f\x98\xb4", "sleeping_face"}, {"\xf0\x9f\x98\xb4", "sleeping"}, {"\xf0\x9f\xab\xa9", "face_with_bags_under_eyes"},
741 {"\xf0\x9f\x98\xb7", "face_with_medical_mask"}, {"\xf0\x9f\x98\xb7", "mask"}, {"\xf0\x9f\xa4\x92", "face_with_thermometer"},
742 {"\xf0\x9f\xa4\x92", "thermometer_face"}, {"\xf0\x9f\xa4\x95", "face_with_head-bandage"}, {"\xf0\x9f\xa4\xa2", "nauseated_face"},
743 {"\xf0\x9f\xa4\xa2", "nauseated"}, {"\xf0\x9f\xa4\xae", "face_vomiting"}, {"\xf0\x9f\xa4\xae", "vomiting"},
744 {"\xf0\x9f\xa4\xa7", "sneezing_face"}, {"\xf0\x9f\xa4\xa7", "sneezing"}, {"\xf0\x9f\xa5\xb5", "hot_face"},
745 {"\xf0\x9f\xa5\xb6", "cold_face"}, {"\xf0\x9f\xa5\xb4", "woozy_face"}, {"\xf0\x9f\x98\xb5", "face_with_crossed-out_eyes"},
746 {"\xf0\x9f\x98\xb5\xe2\x80\x8d\xf0\x9f\x92\xab", "face_with_spiral_eyes"}, {"\xf0\x9f\xa4\xaf", "exploding_head"}, {"\xf0\x9f\xa4\xa0", "cowboy_hat_face"},
747 {"\xf0\x9f\xa4\xa0", "cowboy"}, {"\xf0\x9f\xa5\xb3", "partying_face"}, {"\xf0\x9f\xa5\xb8", "disguised_face"},
748 {"\xf0\x9f\x98\x8e", "smiling_face_with_sunglasses"}, {"\xf0\x9f\x98\x8e", "sunglasses"}, {"\xf0\x9f\xa4\x93", "nerd_face"},
749 {"\xf0\x9f\xa4\x93", "nerd"}, {"\xf0\x9f\xa7\x90", "face_with_monocle"}, {"\xf0\x9f\x98\x95", "confused_face"},
750 {"\xf0\x9f\x98\x95", "confused"}, {"\xf0\x9f\xab\xa4", "face_with_diagonal_mouth"}, {"\xf0\x9f\x98\x9f", "worried_face"},
751 {"\xf0\x9f\x98\x9f", "worried"}, {"\xf0\x9f\x99\x81", "slightly_frowning_face"}, {"\xe2\x98\xb9\xef\xb8\x8f", "frowning_face"},
752 {"\xf0\x9f\x98\xae", "face_with_open_mouth"}, {"\xf0\x9f\x98\xae", "open_mouth"}, {"\xf0\x9f\x98\xaf", "hushed_face"},
753 {"\xf0\x9f\x98\xaf", "hushed"}, {"\xf0\x9f\x98\xb2", "astonished_face"}, {"\xf0\x9f\x98\xb2", "astonished"},
754 {"\xf0\x9f\x98\xb3", "flushed_face"}, {"\xf0\x9f\x98\xb3", "flushed"}, {"\xf0\x9f\xa5\xba", "pleading_face"},
755 {"\xf0\x9f\xa5\xba", "pleading"}, {"\xf0\x9f\xa5\xb9", "face_holding_back_tears"}, {"\xf0\x9f\x98\xa6", "frowning_face_with_open_mouth"},
756 {"\xf0\x9f\x98\xa7", "anguished_face"}, {"\xf0\x9f\x98\xa8", "fearful_face"}, {"\xf0\x9f\x98\xa8", "fearful"},
757 {"\xf0\x9f\x98\xb0", "anxious_face_with_sweat"}, {"\xf0\x9f\x98\xb0", "cold_sweat"}, {"\xf0\x9f\x98\xa5", "sad_but_relieved_face"},
758 {"\xf0\x9f\x98\xa2", "crying_face"}, {"\xf0\x9f\x98\xa2", "cry"}, {"\xf0\x9f\x98\xad", "loudly_crying_face"},
759 {"\xf0\x9f\x98\xad", "sob"}, {"\xf0\x9f\x98\xb1", "face_screaming_in_fear"}, {"\xf0\x9f\x98\xb1", "scream"},
760 {"\xf0\x9f\x98\x96", "confounded_face"}, {"\xf0\x9f\x98\xa3", "persevering_face"}, {"\xf0\x9f\x98\xa3", "persevere"},
761 {"\xf0\x9f\x98\x9e", "disappointed_face"}, {"\xf0\x9f\x98\x9e", "disappointed"}, {"\xf0\x9f\x98\x93", "downcast_face_with_sweat"},
762 {"\xf0\x9f\x98\xa9", "weary_face"}, {"\xf0\x9f\x98\xa9", "weary"}, {"\xf0\x9f\x98\xab", "tired_face"},
763 {"\xf0\x9f\x98\xab", "tired"}, {"\xf0\x9f\xa5\xb1", "yawning_face"}, {"\xf0\x9f\xa5\xb1", "yawning"},
764 {"\xf0\x9f\x98\xa4", "face_with_steam_from_nose"}, {"\xf0\x9f\x98\xa4", "triumph"}, {"\xf0\x9f\x98\xa1", "enraged_face"},
765 {"\xf0\x9f\x98\xa1", "rage"}, {"\xf0\x9f\x98\xa0", "angry_face"}, {"\xf0\x9f\x98\xa0", "angry"},
766 {"\xf0\x9f\xa4\xac", "face_with_symbols_on_mouth"}, {"\xf0\x9f\x98\x88", "smiling_face_with_horns"}, {"\xf0\x9f\x91\xbf", "angry_face_with_horns"},
767 {"\xf0\x9f\x92\x80", "skull"}, {"\xe2\x98\xa0\xef\xb8\x8f", "skull_and_crossbones"}, {"\xe2\x98\xa0\xef\xb8\x8f", "skull_crossbones"},
768 {"\xf0\x9f\x92\xa9", "pile_of_poo"}, {"\xf0\x9f\x92\xa9", "poop"}, {"\xf0\x9f\xa4\xa1", "clown_face"},
769 {"\xf0\x9f\xa4\xa1", "clown"}, {"\xf0\x9f\x91\xb9", "ogre"}, {"\xf0\x9f\x91\xba", "goblin"},
770 {"\xf0\x9f\x91\xbb", "ghost"}, {"\xf0\x9f\x91\xbd", "alien"}, {"\xf0\x9f\x91\xbe", "alien_monster"},
771 {"\xf0\x9f\xa4\x96", "robot"}, {"\xf0\x9f\x98\xba", "grinning_cat"}, {"\xf0\x9f\x98\xb8", "grinning_cat_with_smiling_eyes"},
772 {"\xf0\x9f\x98\xb9", "cat_with_tears_of_joy"}, {"\xf0\x9f\x98\xbb", "smiling_cat_with_heart-eyes"}, {"\xf0\x9f\x98\xbc", "cat_with_wry_smile"},
773 {"\xf0\x9f\x98\xbd", "kissing_cat"}, {"\xf0\x9f\x99\x80", "weary_cat"}, {"\xf0\x9f\x98\xbf", "crying_cat"},
774 {"\xf0\x9f\x98\xbe", "pouting_cat"}, {"\xf0\x9f\x99\x88", "see-no-evil_monkey"}, {"\xf0\x9f\x99\x89", "hear-no-evil_monkey"},
775 {"\xf0\x9f\x99\x8a", "speak-no-evil_monkey"}, {"\xf0\x9f\x92\x8c", "love_letter"}, {"\xf0\x9f\x92\x98", "heart_with_arrow"},
776 {"\xf0\x9f\x92\x9d", "heart_with_ribbon"}, {"\xf0\x9f\x92\x96", "sparkling_heart"}, {"\xf0\x9f\x92\x97", "growing_heart"},
777 {"\xf0\x9f\x92\x93", "beating_heart"}, {"\xf0\x9f\x92\x9e", "revolving_hearts"}, {"\xf0\x9f\x92\x95", "two_hearts"},
778 {"\xf0\x9f\x92\x9f", "heart_decoration"}, {"\xe2\x9d\xa3\xef\xb8\x8f", "heart_exclamation"}, {"\xf0\x9f\x92\x94", "broken_heart"},
779 {"\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x94\xa5", "heart_on_fire"}, {"\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\xa9\xb9", "mending_heart"}, {"\xe2\x9d\xa4\xef\xb8\x8f", "red_heart"},
780 {"\xe2\x9d\xa4\xef\xb8\x8f", "heart"}, {"\xf0\x9f\xa9\xb7", "pink_heart"}, {"\xf0\x9f\xa7\xa1", "orange_heart"},
781 {"\xf0\x9f\x92\x9b", "yellow_heart"}, {"\xf0\x9f\x92\x9a", "green_heart"}, {"\xf0\x9f\x92\x99", "blue_heart"},
782 {"\xf0\x9f\xa9\xb5", "light_blue_heart"}, {"\xf0\x9f\x92\x9c", "purple_heart"}, {"\xf0\x9f\xa4\x8e", "brown_heart"},
783 {"\xf0\x9f\x96\xa4", "black_heart"}, {"\xf0\x9f\xa9\xb6", "grey_heart"}, {"\xf0\x9f\xa4\x8d", "white_heart"},
784 {"\xf0\x9f\x92\x8b", "kiss_mark"}, {"\xf0\x9f\x92\xaf", "hundred_points"}, {"\xf0\x9f\x92\xaf", "hundred"},
785 {"\xf0\x9f\x92\xa2", "anger_symbol"}, {"\xf0\x9f\x92\xa5", "collision"}, {"\xf0\x9f\x92\xab", "dizzy"},
786 {"\xf0\x9f\x92\xa6", "sweat_droplets"}, {"\xf0\x9f\x92\xa8", "dashing_away"}, {"\xf0\x9f\x95\xb3\xef\xb8\x8f", "hole"},
787 {"\xf0\x9f\x92\xac", "speech_balloon"}, {"\xf0\x9f\x91\x81\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x97\xa8\xef\xb8\x8f", "eye_in_speech_bubble"}, {"\xf0\x9f\x97\xa8\xef\xb8\x8f", "left_speech_bubble"},
788 {"\xf0\x9f\x97\xaf\xef\xb8\x8f", "right_anger_bubble"}, {"\xf0\x9f\x92\xad", "thought_balloon"}, {"\xf0\x9f\x92\xa4", "zzz"},
789 {"\xf0\x9f\x91\x8b", "waving_hand"}, {"\xf0\x9f\x91\x8b", "wave_hand"}, {"\xf0\x9f\xa4\x9a", "raised_back_of_hand"},
790 {"\xf0\x9f\x96\x90\xef\xb8\x8f", "hand_with_fingers_splayed"}, {"\xe2\x9c\x8b", "raised_hand"}, {"\xf0\x9f\x96\x96", "vulcan_salute"},
791 {"\xf0\x9f\xab\xb1", "rightwards_hand"}, {"\xf0\x9f\xab\xb2", "leftwards_hand"}, {"\xf0\x9f\xab\xb3", "palm_down_hand"},
792 {"\xf0\x9f\xab\xb4", "palm_up_hand"}, {"\xf0\x9f\xab\xb7", "leftwards_pushing_hand"}, {"\xf0\x9f\xab\xb8", "rightwards_pushing_hand"},
793 {"\xf0\x9f\x91\x8c", "ok_hand"}, {"\xf0\x9f\xa4\x8c", "pinched_fingers"}, {"\xf0\x9f\xa4\x8f", "pinching_hand"},
794 {"\xe2\x9c\x8c\xef\xb8\x8f", "victory_hand"}, {"\xe2\x9c\x8c\xef\xb8\x8f", "v"}, {"\xf0\x9f\xa4\x9e", "crossed_fingers"},
795 {"\xf0\x9f\xab\xb0", "hand_with_index_finger_and_thumb_crossed"}, {"\xf0\x9f\xa4\x9f", "love-you_gesture"}, {"\xf0\x9f\xa4\x98", "sign_of_the_horns"},
796 {"\xf0\x9f\xa4\x99", "call_me_hand"}, {"\xf0\x9f\xa4\x99", "call_me"}, {"\xf0\x9f\x91\x88", "backhand_index_pointing_left"},
797 {"\xf0\x9f\x91\x88", "point_left"}, {"\xf0\x9f\x91\x89", "backhand_index_pointing_right"}, {"\xf0\x9f\x91\x89", "point_right"},
798 {"\xf0\x9f\x91\x86", "backhand_index_pointing_up"}, {"\xf0\x9f\x91\x86", "point_up"}, {"\xf0\x9f\x96\x95", "middle_finger"},
799 {"\xf0\x9f\x91\x87", "backhand_index_pointing_down"}, {"\xf0\x9f\x91\x87", "point_down"}, {"\xe2\x98\x9d\xef\xb8\x8f", "index_pointing_up"},
800 {"\xe2\x98\x9d\xef\xb8\x8f", "point_up_2"}, {"\xf0\x9f\xab\xb5", "index_pointing_at_the_viewer"}, {"\xf0\x9f\x91\x8d", "thumbs_up"},
801 {"\xf0\x9f\x91\x8d", "thumbsup"}, {"\xf0\x9f\x91\x8e", "thumbs_down"}, {"\xf0\x9f\x91\x8e", "thumbsdown"},
802 {"\xe2\x9c\x8a", "raised_fist"}, {"\xe2\x9c\x8a", "fist"}, {"\xf0\x9f\x91\x8a", "oncoming_fist"},
803 {"\xf0\x9f\x91\x8a", "punch"}, {"\xf0\x9f\xa4\x9b", "left-facing_fist"}, {"\xf0\x9f\xa4\x9c", "right-facing_fist"},
804 {"\xf0\x9f\x91\x8f", "clapping_hands"}, {"\xf0\x9f\x91\x8f", "clap"}, {"\xf0\x9f\x99\x8c", "raising_hands"},
805 {"\xf0\x9f\x99\x8c", "raised_hands"}, {"\xf0\x9f\xab\xb6", "heart_hands"}, {"\xf0\x9f\x91\x90", "open_hands"},
806 {"\xf0\x9f\xa4\xb2", "palms_up_together"}, {"\xf0\x9f\xa4\xb2", "palms_up"}, {"\xf0\x9f\xa4\x9d", "handshake"},
807 {"\xf0\x9f\x99\x8f", "folded_hands"}, {"\xf0\x9f\x99\x8f", "pray"}, {"\xe2\x9c\x8d\xef\xb8\x8f", "writing_hand"},
808 {"\xf0\x9f\x92\x85", "nail_polish"}, {"\xf0\x9f\x92\x85", "nail_care"}, {"\xf0\x9f\xa4\xb3", "selfie"},
809 {"\xf0\x9f\x92\xaa", "flexed_biceps"}, {"\xf0\x9f\x92\xaa", "muscle"}, {"\xf0\x9f\xa6\xbe", "mechanical_arm"},
810 {"\xf0\x9f\xa6\xbf", "mechanical_leg"}, {"\xf0\x9f\xa6\xb5", "leg"}, {"\xf0\x9f\xa6\xb6", "foot"},
811 {"\xf0\x9f\x91\x82", "ear"}, {"\xf0\x9f\xa6\xbb", "ear_with_hearing_aid"}, {"\xf0\x9f\x91\x83", "nose"},
812 {"\xf0\x9f\xa7\xa0", "brain"}, {"\xf0\x9f\xab\x80", "anatomical_heart"}, {"\xf0\x9f\xab\x81", "lungs"},
813 {"\xf0\x9f\xa6\xb7", "tooth"}, {"\xf0\x9f\xa6\xb4", "bone"}, {"\xf0\x9f\x91\x80", "eyes"},
814 {"\xf0\x9f\x91\x81\xef\xb8\x8f", "eye"}, {"\xf0\x9f\x91\x85", "tongue"}, {"\xf0\x9f\x91\x84", "mouth"},
815 {"\xf0\x9f\xab\xa6", "biting_lip"}, {"\xf0\x9f\x91\xb6", "baby"}, {"\xf0\x9f\xa7\x92", "child"},
816 {"\xf0\x9f\x91\xa6", "boy"}, {"\xf0\x9f\x91\xa7", "girl"}, {"\xf0\x9f\xa7\x91", "person"},
817 {"\xf0\x9f\x91\xb1", "person_blond_hair"}, {"\xf0\x9f\x91\xa8", "man"}, {"\xf0\x9f\xa7\x94", "person_beard"},
818 {"\xf0\x9f\xa7\x94\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_beard"}, {"\xf0\x9f\xa7\x94\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_beard"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb0", "man_red_hair"},
819 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb1", "man_curly_hair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb3", "man_white_hair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb2", "man_bald"},
820 {"\xf0\x9f\x91\xa9", "woman"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb0", "woman_red_hair"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb0", "person_red_hair"},
821 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb1", "woman_curly_hair"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb1", "person_curly_hair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb3", "woman_white_hair"},
822 {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb3", "person_white_hair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb2", "woman_bald"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb2", "person_bald"},
823 {"\xf0\x9f\x91\xb1\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_blond_hair"}, {"\xf0\x9f\x91\xb1\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_blond_hair"}, {"\xf0\x9f\xa7\x93", "older_person"},
824 {"\xf0\x9f\x91\xb4", "old_man"}, {"\xf0\x9f\x91\xb5", "old_woman"}, {"\xf0\x9f\x99\x8d", "person_frowning"},
825 {"\xf0\x9f\x99\x8d\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_frowning"}, {"\xf0\x9f\x99\x8d\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_frowning"}, {"\xf0\x9f\x99\x8e", "person_pouting"},
826 {"\xf0\x9f\x99\x8e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_pouting"}, {"\xf0\x9f\x99\x8e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_pouting"}, {"\xf0\x9f\x99\x85", "person_gesturing_no"},
827 {"\xf0\x9f\x99\x85\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_gesturing_no"}, {"\xf0\x9f\x99\x85\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_gesturing_no"}, {"\xf0\x9f\x99\x86", "person_gesturing_ok"},
828 {"\xf0\x9f\x99\x86\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_gesturing_ok"}, {"\xf0\x9f\x99\x86\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_gesturing_ok"}, {"\xf0\x9f\x92\x81", "person_tipping_hand"},
829 {"\xf0\x9f\x92\x81\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_tipping_hand"}, {"\xf0\x9f\x92\x81\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_tipping_hand"}, {"\xf0\x9f\x99\x8b", "person_raising_hand"},
830 {"\xf0\x9f\x99\x8b\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_raising_hand"}, {"\xf0\x9f\x99\x8b\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_raising_hand"}, {"\xf0\x9f\xa7\x8f", "deaf_person"},
831 {"\xf0\x9f\xa7\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "deaf_man"}, {"\xf0\x9f\xa7\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "deaf_woman"}, {"\xf0\x9f\x99\x87", "person_bowing"},
832 {"\xf0\x9f\x99\x87\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_bowing"}, {"\xf0\x9f\x99\x87\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_bowing"}, {"\xf0\x9f\xa4\xa6", "person_facepalming"},
833 {"\xf0\x9f\xa4\xa6\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_facepalming"}, {"\xf0\x9f\xa4\xa6\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_facepalming"}, {"\xf0\x9f\xa4\xb7", "person_shrugging"},
834 {"\xf0\x9f\xa4\xb7\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_shrugging"}, {"\xf0\x9f\xa4\xb7\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_shrugging"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xe2\x9a\x95\xef\xb8\x8f", "health_worker"},
835 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9a\x95\xef\xb8\x8f", "man_health_worker"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9a\x95\xef\xb8\x8f", "woman_health_worker"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\x93", "student"},
836 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8e\x93", "man_student"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8e\x93", "woman_student"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8f\xab", "teacher"},
837 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8f\xab", "man_teacher"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8f\xab", "woman_teacher"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xe2\x9a\x96\xef\xb8\x8f", "judge"},
838 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9a\x96\xef\xb8\x8f", "man_judge"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9a\x96\xef\xb8\x8f", "woman_judge"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8c\xbe", "farmer"},
839 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8c\xbe", "man_farmer"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8c\xbe", "woman_farmer"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8d\xb3", "cook"},
840 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8d\xb3", "man_cook"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8d\xb3", "woman_cook"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x94\xa7", "mechanic"},
841 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x94\xa7", "man_mechanic"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x94\xa7", "woman_mechanic"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8f\xad", "factory_worker"},
842 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8f\xad", "man_factory_worker"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8f\xad", "woman_factory_worker"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x92\xbc", "office_worker"},
843 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x92\xbc", "man_office_worker"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbc", "woman_office_worker"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x94\xac", "scientist"},
844 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x94\xac", "man_scientist"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x94\xac", "woman_scientist"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x92\xbb", "technologist"},
845 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x92\xbb", "man_technologist"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbb", "woman_technologist"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\xa4", "singer"},
846 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8e\xa4", "man_singer"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8e\xa4", "woman_singer"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\xa8", "artist"},
847 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8e\xa8", "man_artist"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8e\xa8", "woman_artist"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xe2\x9c\x88\xef\xb8\x8f", "pilot"},
848 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9c\x88\xef\xb8\x8f", "man_pilot"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9c\x88\xef\xb8\x8f", "woman_pilot"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x9a\x80", "astronaut"},
849 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x9a\x80", "man_astronaut"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x9a\x80", "woman_astronaut"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x9a\x92", "firefighter"},
850 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x9a\x92", "man_firefighter"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x9a\x92", "woman_firefighter"}, {"\xf0\x9f\x91\xae", "police_officer"},
851 {"\xf0\x9f\x91\xae\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_police_officer"}, {"\xf0\x9f\x91\xae\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_police_officer"}, {"\xf0\x9f\x95\xb5\xef\xb8\x8f", "detective"},
852 {"\xf0\x9f\x95\xb5\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_detective"}, {"\xf0\x9f\x95\xb5\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_detective"}, {"\xf0\x9f\x92\x82", "guard"},
853 {"\xf0\x9f\x92\x82\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_guard"}, {"\xf0\x9f\x92\x82\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_guard"}, {"\xf0\x9f\xa5\xb7", "ninja"},
854 {"\xf0\x9f\x91\xb7", "construction_worker"}, {"\xf0\x9f\x91\xb7\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_construction_worker"}, {"\xf0\x9f\x91\xb7\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_construction_worker"},
855 {"\xf0\x9f\xab\x85", "person_with_crown"}, {"\xf0\x9f\xa4\xb4", "prince"}, {"\xf0\x9f\x91\xb8", "princess"},
856 {"\xf0\x9f\x91\xb3", "person_wearing_turban"}, {"\xf0\x9f\x91\xb3\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_wearing_turban"}, {"\xf0\x9f\x91\xb3\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_wearing_turban"},
857 {"\xf0\x9f\x91\xb2", "person_with_skullcap"}, {"\xf0\x9f\xa7\x95", "woman_with_headscarf"}, {"\xf0\x9f\xa4\xb5", "person_in_tuxedo"},
858 {"\xf0\x9f\xa4\xb5\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_in_tuxedo"}, {"\xf0\x9f\xa4\xb5\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_in_tuxedo"}, {"\xf0\x9f\x91\xb0", "person_with_veil"},
859 {"\xf0\x9f\x91\xb0\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_with_veil"}, {"\xf0\x9f\x91\xb0\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_with_veil"}, {"\xf0\x9f\xa4\xb0", "pregnant_woman"},
860 {"\xf0\x9f\xab\x83", "pregnant_man"}, {"\xf0\x9f\xab\x84", "pregnant_person"}, {"\xf0\x9f\xa4\xb1", "breast-feeding"},
861 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8d\xbc", "woman_feeding_baby"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8d\xbc", "man_feeding_baby"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8d\xbc", "person_feeding_baby"},
862 {"\xf0\x9f\x91\xbc", "baby_angel"}, {"\xf0\x9f\x8e\x85", "santa_claus"}, {"\xf0\x9f\xa4\xb6", "mrs_claus"},
863 {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\x84", "mx_claus"}, {"\xf0\x9f\xa6\xb8", "superhero"}, {"\xf0\x9f\xa6\xb8\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_superhero"},
864 {"\xf0\x9f\xa6\xb8\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_superhero"}, {"\xf0\x9f\xa6\xb9", "supervillain"}, {"\xf0\x9f\xa6\xb9\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_supervillain"},
865 {"\xf0\x9f\xa6\xb9\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_supervillain"}, {"\xf0\x9f\xa7\x99", "mage"}, {"\xf0\x9f\xa7\x99\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_mage"},
866 {"\xf0\x9f\xa7\x99\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_mage"}, {"\xf0\x9f\xa7\x9a", "fairy"}, {"\xf0\x9f\xa7\x9a\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_fairy"},
867 {"\xf0\x9f\xa7\x9a\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_fairy"}, {"\xf0\x9f\xa7\x9b", "vampire"}, {"\xf0\x9f\xa7\x9b\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_vampire"},
868 {"\xf0\x9f\xa7\x9b\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_vampire"}, {"\xf0\x9f\xa7\x9c", "merperson"}, {"\xf0\x9f\xa7\x9c\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "merman"},
869 {"\xf0\x9f\xa7\x9c\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "mermaid"}, {"\xf0\x9f\xa7\x9d", "elf"}, {"\xf0\x9f\xa7\x9d\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_elf"},
870 {"\xf0\x9f\xa7\x9d\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_elf"}, {"\xf0\x9f\xa7\x9e", "genie"}, {"\xf0\x9f\xa7\x9e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_genie"},
871 {"\xf0\x9f\xa7\x9e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_genie"}, {"\xf0\x9f\xa7\x9f", "zombie"}, {"\xf0\x9f\xa7\x9f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_zombie"},
872 {"\xf0\x9f\xa7\x9f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_zombie"}, {"\xf0\x9f\xa7\x8c", "troll"}, {"\xf0\x9f\x92\x86", "person_getting_massage"},
873 {"\xf0\x9f\x92\x86\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_getting_massage"}, {"\xf0\x9f\x92\x86\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_getting_massage"}, {"\xf0\x9f\x92\x87", "person_getting_haircut"},
874 {"\xf0\x9f\x92\x87\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_getting_haircut"}, {"\xf0\x9f\x92\x87\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_getting_haircut"}, {"\xf0\x9f\x9a\xb6", "person_walking"},
875 {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_walking"}, {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_walking"}, {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_walking_facing_right"},
876 {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_walking_facing_right"}, {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_walking_facing_right"}, {"\xf0\x9f\xa7\x8d", "person_standing"},
877 {"\xf0\x9f\xa7\x8d\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_standing"}, {"\xf0\x9f\xa7\x8d\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_standing"}, {"\xf0\x9f\xa7\x8e", "person_kneeling"},
878 {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_kneeling"}, {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_kneeling"}, {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_kneeling_facing_right"},
879 {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_kneeling_facing_right"}, {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_kneeling_facing_right"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xaf", "person_with_white_cane"},
880 {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xaf\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_with_white_cane_facing_right"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xaf", "man_with_white_cane"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xaf\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_with_white_cane_facing_right"},
881 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xaf", "woman_with_white_cane"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xaf\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_with_white_cane_facing_right"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbc", "person_in_motorized_wheelchair"},
882 {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbc\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_in_motorized_wheelchair_facing_right"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbc", "man_in_motorized_wheelchair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbc\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_in_motorized_wheelchair_facing_right"},
883 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbc", "woman_in_motorized_wheelchair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbc\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_in_motorized_wheelchair_facing_right"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbd", "person_in_manual_wheelchair"},
884 {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbd\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_in_manual_wheelchair_facing_right"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbd", "man_in_manual_wheelchair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbd\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_in_manual_wheelchair_facing_right"},
885 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbd", "woman_in_manual_wheelchair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbd\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_in_manual_wheelchair_facing_right"}, {"\xf0\x9f\x8f\x83", "person_running"},
886 {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_running"}, {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_running"}, {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_running_facing_right"},
887 {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_running_facing_right"}, {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_running_facing_right"}, {"\xf0\x9f\x92\x83", "woman_dancing"},
888 {"\xf0\x9f\x95\xba", "man_dancing"}, {"\xf0\x9f\x95\xb4\xef\xb8\x8f", "person_in_suit_levitating"}, {"\xf0\x9f\x91\xaf", "people_with_bunny_ears"},
889 {"\xf0\x9f\x91\xaf\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "men_with_bunny_ears"}, {"\xf0\x9f\x91\xaf\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "women_with_bunny_ears"}, {"\xf0\x9f\xa7\x96", "person_in_steamy_room"},
890 {"\xf0\x9f\xa7\x96\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_in_steamy_room"}, {"\xf0\x9f\xa7\x96\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_in_steamy_room"}, {"\xf0\x9f\xa7\x97", "person_climbing"},
891 {"\xf0\x9f\xa7\x97\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_climbing"}, {"\xf0\x9f\xa7\x97\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_climbing"}, {"\xf0\x9f\xa4\xba", "person_fencing"},
892 {"\xf0\x9f\x8f\x87", "horse_racing"}, {"\xe2\x9b\xb7\xef\xb8\x8f", "skier"}, {"\xf0\x9f\x8f\x82", "snowboarder"},
893 {"\xf0\x9f\x8f\x8c\xef\xb8\x8f", "person_golfing"}, {"\xf0\x9f\x8f\x8c\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_golfing"}, {"\xf0\x9f\x8f\x8c\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_golfing"},
894 {"\xf0\x9f\x8f\x84", "person_surfing"}, {"\xf0\x9f\x8f\x84\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_surfing"}, {"\xf0\x9f\x8f\x84\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_surfing"},
895 {"\xf0\x9f\x9a\xa3", "person_rowing_boat"}, {"\xf0\x9f\x9a\xa3\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_rowing_boat"}, {"\xf0\x9f\x9a\xa3\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_rowing_boat"},
896 {"\xf0\x9f\x8f\x8a", "person_swimming"}, {"\xf0\x9f\x8f\x8a\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_swimming"}, {"\xf0\x9f\x8f\x8a\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_swimming"},
897 {"\xe2\x9b\xb9\xef\xb8\x8f", "person_bouncing_ball"}, {"\xe2\x9b\xb9\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_bouncing_ball"}, {"\xe2\x9b\xb9\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_bouncing_ball"},
898 {"\xf0\x9f\x8f\x8b\xef\xb8\x8f", "person_lifting_weights"}, {"\xf0\x9f\x8f\x8b\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_lifting_weights"}, {"\xf0\x9f\x8f\x8b\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_lifting_weights"},
899 {"\xf0\x9f\x9a\xb4", "person_biking"}, {"\xf0\x9f\x9a\xb4\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_biking"}, {"\xf0\x9f\x9a\xb4\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_biking"},
900 {"\xf0\x9f\x9a\xb5", "person_mountain_biking"}, {"\xf0\x9f\x9a\xb5\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_mountain_biking"}, {"\xf0\x9f\x9a\xb5\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_mountain_biking"},
901 {"\xf0\x9f\xa4\xb8", "person_cartwheeling"}, {"\xf0\x9f\xa4\xb8\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_cartwheeling"}, {"\xf0\x9f\xa4\xb8\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_cartwheeling"},
902 {"\xf0\x9f\xa4\xbc", "people_wrestling"}, {"\xf0\x9f\xa4\xbc\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "men_wrestling"}, {"\xf0\x9f\xa4\xbc\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "women_wrestling"},
903 {"\xf0\x9f\xa4\xbd", "person_playing_water_polo"}, {"\xf0\x9f\xa4\xbd\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_playing_water_polo"}, {"\xf0\x9f\xa4\xbd\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_playing_water_polo"},
904 {"\xf0\x9f\xa4\xbe", "person_playing_handball"}, {"\xf0\x9f\xa4\xbe\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_playing_handball"}, {"\xf0\x9f\xa4\xbe\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_playing_handball"},
905 {"\xf0\x9f\xa4\xb9", "person_juggling"}, {"\xf0\x9f\xa4\xb9\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_juggling"}, {"\xf0\x9f\xa4\xb9\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_juggling"},
906 {"\xf0\x9f\xa7\x98", "person_in_lotus_position"}, {"\xf0\x9f\xa7\x98\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_in_lotus_position"}, {"\xf0\x9f\xa7\x98\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_in_lotus_position"},
907 {"\xf0\x9f\x9b\x80", "person_taking_bath"}, {"\xf0\x9f\x9b\x8c", "person_in_bed"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa4\x9d\xe2\x80\x8d\xf0\x9f\xa7\x91", "people_holding_hands"},
908 {"\xf0\x9f\x91\xad", "women_holding_hands"}, {"\xf0\x9f\x91\xab", "woman_and_man_holding_hands"}, {"\xf0\x9f\x91\xac", "men_holding_hands"},
909 {"\xf0\x9f\x92\x8f", "kiss"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\x8b\xe2\x80\x8d\xf0\x9f\x91\xa8", "kiss_woman_man"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\x8b\xe2\x80\x8d\xf0\x9f\x91\xa8", "kiss_man_man"},
910 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\x8b\xe2\x80\x8d\xf0\x9f\x91\xa9", "kiss_woman_woman"}, {"\xf0\x9f\x92\x91", "couple_with_heart"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x91\xa8", "couple_with_heart_woman_man"},
911 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x91\xa8", "couple_with_heart_man_man"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x91\xa9", "couple_with_heart_woman_woman"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_woman_boy"},
912 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_woman_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_woman_girl_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_woman_boy_boy"},
913 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_woman_girl_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_man_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_man_girl"},
914 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_man_girl_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_man_boy_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_man_girl_girl"},
915 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_woman_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_woman_girl"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_woman_girl_boy"},
916 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_woman_boy_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_woman_girl_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_boy"},
917 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_boy_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_girl_boy"},
918 {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_girl_girl"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_boy_boy"},
919 {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_girl"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_girl_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_girl_girl"},
920 {"\xf0\x9f\x97\xa3\xef\xb8\x8f", "speaking_head"}, {"\xf0\x9f\x91\xa4", "bust_in_silhouette"}, {"\xf0\x9f\x91\xa5", "busts_in_silhouette"},
921 {"\xf0\x9f\xab\x82", "people_hugging"}, {"\xf0\x9f\x91\xaa", "family"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_adult_child"},
922 {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_adult_child_child"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_child"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_child_child"},
923 {"\xf0\x9f\x91\xa3", "footprints"}, {"\xf0\x9f\xab\x86", "fingerprint"}, {"\xf0\x9f\x90\xb5", "monkey_face"},
924 {"\xf0\x9f\x90\x92", "monkey"}, {"\xf0\x9f\xa6\x8d", "gorilla"}, {"\xf0\x9f\xa6\xa7", "orangutan"},
925 {"\xf0\x9f\x90\xb6", "dog_face"}, {"\xf0\x9f\x90\xb6", "dog"}, {"\xf0\x9f\xa6\xae", "guide_dog"},
926 {"\xf0\x9f\x90\x95\xe2\x80\x8d\xf0\x9f\xa6\xba", "service_dog"}, {"\xf0\x9f\x90\xa9", "poodle"}, {"\xf0\x9f\x90\xba", "wolf"},
927 {"\xf0\x9f\xa6\x8a", "fox"}, {"\xf0\x9f\xa6\x9d", "raccoon"}, {"\xf0\x9f\x90\xb1", "cat_face"},
928 {"\xf0\x9f\x90\xb1", "cat"}, {"\xf0\x9f\x90\x88\xe2\x80\x8d\xe2\xac\x9b", "black_cat"}, {"\xf0\x9f\xa6\x81", "lion"},
929 {"\xf0\x9f\x90\xaf", "tiger_face"}, {"\xf0\x9f\x90\x85", "tiger"}, {"\xf0\x9f\x90\x86", "leopard"},
930 {"\xf0\x9f\x90\xb4", "horse_face"}, {"\xf0\x9f\xab\x8e", "moose"}, {"\xf0\x9f\xab\x8f", "donkey"},
931 {"\xf0\x9f\x90\x8e", "horse"}, {"\xf0\x9f\xa6\x84", "unicorn"}, {"\xf0\x9f\xa6\x93", "zebra"},
932 {"\xf0\x9f\xa6\x8c", "deer"}, {"\xf0\x9f\xa6\xac", "bison"}, {"\xf0\x9f\x90\xae", "cow_face"},
933 {"\xf0\x9f\x90\x82", "ox"}, {"\xf0\x9f\x90\x83", "water_buffalo"}, {"\xf0\x9f\x90\x84", "cow"},
934 {"\xf0\x9f\x90\xb7", "pig_face"}, {"\xf0\x9f\x90\x96", "pig"}, {"\xf0\x9f\x90\x97", "boar"},
935 {"\xf0\x9f\x90\xbd", "pig_nose"}, {"\xf0\x9f\x90\x8f", "ram"}, {"\xf0\x9f\x90\x91", "ewe"},
936 {"\xf0\x9f\x90\x90", "goat"}, {"\xf0\x9f\x90\xaa", "camel"}, {"\xf0\x9f\x90\xab", "two-hump_camel"},
937 {"\xf0\x9f\xa6\x99", "llama"}, {"\xf0\x9f\xa6\x92", "giraffe"}, {"\xf0\x9f\x90\x98", "elephant"},
938 {"\xf0\x9f\xa6\xa3", "mammoth"}, {"\xf0\x9f\xa6\x8f", "rhinoceros"}, {"\xf0\x9f\xa6\x9b", "hippopotamus"},
939 {"\xf0\x9f\x90\xad", "mouse_face"}, {"\xf0\x9f\x90\x81", "mouse"}, {"\xf0\x9f\x90\x80", "rat"},
940 {"\xf0\x9f\x90\xb9", "hamster"}, {"\xf0\x9f\x90\xb0", "rabbit_face"}, {"\xf0\x9f\x90\x87", "rabbit"},
941 {"\xf0\x9f\x90\xbf\xef\xb8\x8f", "chipmunk"}, {"\xf0\x9f\xa6\xab", "beaver"}, {"\xf0\x9f\xa6\x94", "hedgehog"},
942 {"\xf0\x9f\xa6\x87", "bat"}, {"\xf0\x9f\x90\xbb", "bear"}, {"\xf0\x9f\x90\xbb\xe2\x80\x8d\xe2\x9d\x84\xef\xb8\x8f", "polar_bear"},
943 {"\xf0\x9f\x90\xa8", "koala"}, {"\xf0\x9f\x90\xbc", "panda"}, {"\xf0\x9f\xa6\xa5", "sloth"},
944 {"\xf0\x9f\xa6\xa6", "otter"}, {"\xf0\x9f\xa6\xa8", "skunk"}, {"\xf0\x9f\xa6\x98", "kangaroo"},
945 {"\xf0\x9f\xa6\xa1", "badger"}, {"\xf0\x9f\x90\xbe", "paw_prints"}, {"\xf0\x9f\xa6\x83", "turkey"},
946 {"\xf0\x9f\x90\x94", "chicken"}, {"\xf0\x9f\x90\x93", "rooster"}, {"\xf0\x9f\x90\xa3", "hatching_chick"},
947 {"\xf0\x9f\x90\xa4", "baby_chick"}, {"\xf0\x9f\x90\xa5", "front-facing_baby_chick"}, {"\xf0\x9f\x90\xa6", "bird"},
948 {"\xf0\x9f\x90\xa7", "penguin"}, {"\xf0\x9f\x95\x8a\xef\xb8\x8f", "dove"}, {"\xf0\x9f\xa6\x85", "eagle"},
949 {"\xf0\x9f\xa6\x86", "duck"}, {"\xf0\x9f\xa6\xa2", "swan"}, {"\xf0\x9f\xa6\x89", "owl"},
950 {"\xf0\x9f\xa6\xa4", "dodo"}, {"\xf0\x9f\xaa\xb6", "feather"}, {"\xf0\x9f\xa6\xa9", "flamingo"},
951 {"\xf0\x9f\xa6\x9a", "peacock"}, {"\xf0\x9f\xa6\x9c", "parrot"}, {"\xf0\x9f\xaa\xbd", "wing"},
952 {"\xf0\x9f\x90\xa6\xe2\x80\x8d\xe2\xac\x9b", "black_bird"}, {"\xf0\x9f\xaa\xbf", "goose"}, {"\xf0\x9f\x90\xa6\xe2\x80\x8d\xf0\x9f\x94\xa5", "phoenix"},
953 {"\xf0\x9f\x90\xb8", "frog"}, {"\xf0\x9f\x90\x8a", "crocodile"}, {"\xf0\x9f\x90\xa2", "turtle"},
954 {"\xf0\x9f\xa6\x8e", "lizard"}, {"\xf0\x9f\x90\x8d", "snake"}, {"\xf0\x9f\x90\xb2", "dragon_face"},
955 {"\xf0\x9f\x90\x89", "dragon"}, {"\xf0\x9f\xa6\x95", "sauropod"}, {"\xf0\x9f\xa6\x96", "t-rex"},
956 {"\xf0\x9f\x90\xb3", "spouting_whale"}, {"\xf0\x9f\x90\x8b", "whale"}, {"\xf0\x9f\x90\xac", "dolphin"},
957 {"\xf0\x9f\xa6\xad", "seal"}, {"\xf0\x9f\x90\x9f", "fish"}, {"\xf0\x9f\x90\xa0", "tropical_fish"},
958 {"\xf0\x9f\x90\xa1", "blowfish"}, {"\xf0\x9f\xa6\x88", "shark"}, {"\xf0\x9f\x90\x99", "octopus"},
959 {"\xf0\x9f\x90\x9a", "spiral_shell"}, {"\xf0\x9f\xaa\xb8", "coral"}, {"\xf0\x9f\xaa\xbc", "jellyfish"},
960 {"\xf0\x9f\xa6\x80", "crab"}, {"\xf0\x9f\xa6\x9e", "lobster"}, {"\xf0\x9f\xa6\x90", "shrimp"},
961 {"\xf0\x9f\xa6\x91", "squid"}, {"\xf0\x9f\xa6\xaa", "oyster"}, {"\xf0\x9f\x90\x8c", "snail"},
962 {"\xf0\x9f\xa6\x8b", "butterfly"}, {"\xf0\x9f\x90\x9b", "bug"}, {"\xf0\x9f\x90\x9c", "ant"},
963 {"\xf0\x9f\x90\x9d", "honeybee"}, {"\xf0\x9f\x90\x9d", "bee"}, {"\xf0\x9f\xaa\xb2", "beetle"},
964 {"\xf0\x9f\x90\x9e", "lady_beetle"}, {"\xf0\x9f\xa6\x97", "cricket"}, {"\xf0\x9f\xaa\xb3", "cockroach"},
965 {"\xf0\x9f\x95\xb7\xef\xb8\x8f", "spider"}, {"\xf0\x9f\x95\xb8\xef\xb8\x8f", "spider_web"}, {"\xf0\x9f\xa6\x82", "scorpion"},
966 {"\xf0\x9f\xa6\x9f", "mosquito"}, {"\xf0\x9f\xaa\xb0", "fly"}, {"\xf0\x9f\xaa\xb1", "worm"},
967 {"\xf0\x9f\xa6\xa0", "microbe"}, {"\xf0\x9f\x92\x90", "bouquet"}, {"\xf0\x9f\x8c\xb8", "cherry_blossom"},
968 {"\xf0\x9f\x92\xae", "white_flower"}, {"\xf0\x9f\xaa\xb7", "lotus"}, {"\xf0\x9f\x8f\xb5\xef\xb8\x8f", "rosette"},
969 {"\xf0\x9f\x8c\xb9", "rose"}, {"\xf0\x9f\xa5\x80", "wilted_flower"}, {"\xf0\x9f\x8c\xba", "hibiscus"},
970 {"\xf0\x9f\x8c\xbb", "sunflower"}, {"\xf0\x9f\x8c\xbc", "blossom"}, {"\xf0\x9f\x8c\xb7", "tulip"},
971 {"\xf0\x9f\xaa\xbb", "hyacinth"}, {"\xf0\x9f\x8c\xb1", "seedling"}, {"\xf0\x9f\xaa\xb4", "potted_plant"},
972 {"\xf0\x9f\x8c\xb2", "evergreen_tree"}, {"\xf0\x9f\x8c\xb3", "deciduous_tree"}, {"\xf0\x9f\x8c\xb4", "palm_tree"},
973 {"\xf0\x9f\x8c\xb5", "cactus"}, {"\xf0\x9f\x8c\xbe", "sheaf_of_rice"}, {"\xf0\x9f\x8c\xbf", "herb"},
974 {"\xe2\x98\x98\xef\xb8\x8f", "shamrock"}, {"\xf0\x9f\x8d\x80", "four_leaf_clover"}, {"\xf0\x9f\x8d\x81", "maple_leaf"},
975 {"\xf0\x9f\x8d\x82", "fallen_leaf"}, {"\xf0\x9f\x8d\x83", "leaf_fluttering_in_wind"}, {"\xf0\x9f\xaa\xb9", "empty_nest"},
976 {"\xf0\x9f\xaa\xba", "nest_with_eggs"}, {"\xf0\x9f\x8d\x84", "mushroom"}, {"\xf0\x9f\xaa\xbe", "leafless_tree"},
977 {"\xf0\x9f\x8d\x87", "grapes"}, {"\xf0\x9f\x8d\x88", "melon"}, {"\xf0\x9f\x8d\x89", "watermelon"},
978 {"\xf0\x9f\x8d\x8a", "tangerine"}, {"\xf0\x9f\x8d\x8b", "lemon"}, {"\xf0\x9f\x8d\x8b\xe2\x80\x8d\xf0\x9f\x9f\xa9", "lime"},
979 {"\xf0\x9f\x8d\x8c", "banana"}, {"\xf0\x9f\x8d\x8d", "pineapple"}, {"\xf0\x9f\xa5\xad", "mango"},
980 {"\xf0\x9f\x8d\x8e", "red_apple"}, {"\xf0\x9f\x8d\x8f", "green_apple"}, {"\xf0\x9f\x8d\x90", "pear"},
981 {"\xf0\x9f\x8d\x91", "peach"}, {"\xf0\x9f\x8d\x92", "cherries"}, {"\xf0\x9f\x8d\x93", "strawberry"},
982 {"\xf0\x9f\xab\x90", "blueberries"}, {"\xf0\x9f\xa5\x9d", "kiwi_fruit"}, {"\xf0\x9f\x8d\x85", "tomato"},
983 {"\xf0\x9f\xab\x92", "olive"}, {"\xf0\x9f\xa5\xa5", "coconut"}, {"\xf0\x9f\xa5\x91", "avocado"},
984 {"\xf0\x9f\x8d\x86", "eggplant"}, {"\xf0\x9f\xa5\x94", "potato"}, {"\xf0\x9f\xa5\x95", "carrot"},
985 {"\xf0\x9f\x8c\xbd", "ear_of_corn"}, {"\xf0\x9f\x8c\xb6\xef\xb8\x8f", "hot_pepper"}, {"\xf0\x9f\xab\x91", "bell_pepper"},
986 {"\xf0\x9f\xa5\x92", "cucumber"}, {"\xf0\x9f\xa5\xac", "leafy_green"}, {"\xf0\x9f\xa5\xa6", "broccoli"},
987 {"\xf0\x9f\xa7\x84", "garlic"}, {"\xf0\x9f\xa7\x85", "onion"}, {"\xf0\x9f\xa5\x9c", "peanuts"},
988 {"\xf0\x9f\xab\x98", "beans"}, {"\xf0\x9f\x8c\xb0", "chestnut"}, {"\xf0\x9f\xab\x9a", "ginger_root"},
989 {"\xf0\x9f\xab\x9b", "pea_pod"}, {"\xf0\x9f\x8d\x84\xe2\x80\x8d\xf0\x9f\x9f\xab", "brown_mushroom"}, {"\xf0\x9f\xab\x9c", "root_vegetable"},
990 {"\xf0\x9f\x8d\x9e", "bread"}, {"\xf0\x9f\xa5\x90", "croissant"}, {"\xf0\x9f\xa5\x96", "baguette_bread"},
991 {"\xf0\x9f\xab\x93", "flatbread"}, {"\xf0\x9f\xa5\xa8", "pretzel"}, {"\xf0\x9f\xa5\xaf", "bagel"},
992 {"\xf0\x9f\xa5\x9e", "pancakes"}, {"\xf0\x9f\xa7\x87", "waffle"}, {"\xf0\x9f\xa7\x80", "cheese_wedge"},
993 {"\xf0\x9f\x8d\x96", "meat_on_bone"}, {"\xf0\x9f\x8d\x97", "poultry_leg"}, {"\xf0\x9f\xa5\xa9", "cut_of_meat"},
994 {"\xf0\x9f\xa5\x93", "bacon"}, {"\xf0\x9f\x8d\x94", "hamburger"}, {"\xf0\x9f\x8d\x9f", "french_fries"},
995 {"\xf0\x9f\x8d\x95", "pizza"}, {"\xf0\x9f\x8c\xad", "hot_dog"}, {"\xf0\x9f\xa5\xaa", "sandwich"},
996 {"\xf0\x9f\x8c\xae", "taco"}, {"\xf0\x9f\x8c\xaf", "burrito"}, {"\xf0\x9f\xab\x94", "tamale"},
997 {"\xf0\x9f\xa5\x99", "stuffed_flatbread"}, {"\xf0\x9f\xa7\x86", "falafel"}, {"\xf0\x9f\xa5\x9a", "egg"},
998 {"\xf0\x9f\x8d\xb3", "cooking"}, {"\xf0\x9f\xa5\x98", "shallow_pan_of_food"}, {"\xf0\x9f\x8d\xb2", "pot_of_food"},
999 {"\xf0\x9f\xab\x95", "fondue"}, {"\xf0\x9f\xa5\xa3", "bowl_with_spoon"}, {"\xf0\x9f\xa5\x97", "green_salad"},
1000 {"\xf0\x9f\x8d\xbf", "popcorn"}, {"\xf0\x9f\xa7\x88", "butter"}, {"\xf0\x9f\xa7\x82", "salt"},
1001 {"\xf0\x9f\xa5\xab", "canned_food"}, {"\xf0\x9f\x8d\xb1", "bento_box"}, {"\xf0\x9f\x8d\x98", "rice_cracker"},
1002 {"\xf0\x9f\x8d\x99", "rice_ball"}, {"\xf0\x9f\x8d\x9a", "cooked_rice"}, {"\xf0\x9f\x8d\x9b", "curry_rice"},
1003 {"\xf0\x9f\x8d\x9c", "steaming_bowl"}, {"\xf0\x9f\x8d\x9d", "spaghetti"}, {"\xf0\x9f\x8d\xa0", "roasted_sweet_potato"},
1004 {"\xf0\x9f\x8d\xa2", "oden"}, {"\xf0\x9f\x8d\xa3", "sushi"}, {"\xf0\x9f\x8d\xa4", "fried_shrimp"},
1005 {"\xf0\x9f\x8d\xa5", "fish_cake_with_swirl"}, {"\xf0\x9f\xa5\xae", "moon_cake"}, {"\xf0\x9f\x8d\xa1", "dango"},
1006 {"\xf0\x9f\xa5\x9f", "dumpling"}, {"\xf0\x9f\xa5\xa0", "fortune_cookie"}, {"\xf0\x9f\xa5\xa1", "takeout_box"},
1007 {"\xf0\x9f\x8d\xa6", "soft_ice_cream"}, {"\xf0\x9f\x8d\xa7", "shaved_ice"}, {"\xf0\x9f\x8d\xa8", "ice_cream"},
1008 {"\xf0\x9f\x8d\xa9", "doughnut"}, {"\xf0\x9f\x8d\xaa", "cookie"}, {"\xf0\x9f\x8e\x82", "birthday_cake"},
1009 {"\xf0\x9f\x8d\xb0", "shortcake"}, {"\xf0\x9f\xa7\x81", "cupcake"}, {"\xf0\x9f\xa5\xa7", "pie"},
1010 {"\xf0\x9f\x8d\xab", "chocolate_bar"}, {"\xf0\x9f\x8d\xac", "candy"}, {"\xf0\x9f\x8d\xad", "lollipop"},
1011 {"\xf0\x9f\x8d\xae", "custard"}, {"\xf0\x9f\x8d\xaf", "honey_pot"}, {"\xf0\x9f\x8d\xbc", "baby_bottle"},
1012 {"\xf0\x9f\xa5\x9b", "glass_of_milk"}, {"\xe2\x98\x95", "hot_beverage"}, {"\xe2\x98\x95", "coffee"},
1013 {"\xf0\x9f\xab\x96", "teapot"}, {"\xf0\x9f\x8d\xb5", "teacup_without_handle"}, {"\xf0\x9f\x8d\xb6", "sake"},
1014 {"\xf0\x9f\x8d\xbe", "bottle_with_popping_cork"}, {"\xf0\x9f\x8d\xb7", "wine_glass"}, {"\xf0\x9f\x8d\xb8", "cocktail_glass"},
1015 {"\xf0\x9f\x8d\xb9", "tropical_drink"}, {"\xf0\x9f\x8d\xba", "beer_mug"}, {"\xf0\x9f\x8d\xba", "beer"},
1016 {"\xf0\x9f\x8d\xbb", "clinking_beer_mugs"}, {"\xf0\x9f\xa5\x82", "clinking_glasses"}, {"\xf0\x9f\xa5\x83", "tumbler_glass"},
1017 {"\xf0\x9f\xab\x97", "pouring_liquid"}, {"\xf0\x9f\xa5\xa4", "cup_with_straw"}, {"\xf0\x9f\xa7\x8b", "bubble_tea"},
1018 {"\xf0\x9f\xa7\x83", "beverage_box"}, {"\xf0\x9f\xa7\x89", "mate"}, {"\xf0\x9f\xa7\x8a", "ice"},
1019 {"\xf0\x9f\xa5\xa2", "chopsticks"}, {"\xf0\x9f\x8d\xbd\xef\xb8\x8f", "fork_and_knife_with_plate"}, {"\xf0\x9f\x8d\xb4", "fork_and_knife"},
1020 {"\xf0\x9f\xa5\x84", "spoon"}, {"\xf0\x9f\x94\xaa", "kitchen_knife"}, {"\xf0\x9f\xab\x99", "jar"},
1021 {"\xf0\x9f\x8f\xba", "amphora"}, {"\xf0\x9f\x8c\x8d", "globe_showing_europe-africa"}, {"\xf0\x9f\x8c\x8d", "earth"},
1022 {"\xf0\x9f\x8c\x8e", "globe_showing_americas"}, {"\xf0\x9f\x8c\x8f", "globe_showing_asia-australia"}, {"\xf0\x9f\x8c\x90", "globe_with_meridians"},
1023 {"\xf0\x9f\x97\xba\xef\xb8\x8f", "world_map"}, {"\xf0\x9f\x97\xbe", "map_of_japan"}, {"\xf0\x9f\xa7\xad", "compass"},
1024 {"\xf0\x9f\x8f\x94\xef\xb8\x8f", "snow-capped_mountain"}, {"\xe2\x9b\xb0\xef\xb8\x8f", "mountain"}, {"\xf0\x9f\x8c\x8b", "volcano"},
1025 {"\xf0\x9f\x97\xbb", "mount_fuji"}, {"\xf0\x9f\x8f\x95\xef\xb8\x8f", "camping"}, {"\xf0\x9f\x8f\x96\xef\xb8\x8f", "beach_with_umbrella"},
1026 {"\xf0\x9f\x8f\x9c\xef\xb8\x8f", "desert"}, {"\xf0\x9f\x8f\x9d\xef\xb8\x8f", "desert_island"}, {"\xf0\x9f\x8f\x9e\xef\xb8\x8f", "national_park"},
1027 {"\xf0\x9f\x8f\x9f\xef\xb8\x8f", "stadium"}, {"\xf0\x9f\x8f\x9b\xef\xb8\x8f", "classical_building"}, {"\xf0\x9f\x8f\x97\xef\xb8\x8f", "building_construction"},
1028 {"\xf0\x9f\xa7\xb1", "brick"}, {"\xf0\x9f\xaa\xa8", "rock"}, {"\xf0\x9f\xaa\xb5", "wood"},
1029 {"\xf0\x9f\x9b\x96", "hut"}, {"\xf0\x9f\x8f\x98\xef\xb8\x8f", "houses"}, {"\xf0\x9f\x8f\x9a\xef\xb8\x8f", "derelict_house"},
1030 {"\xf0\x9f\x8f\xa0", "house"}, {"\xf0\x9f\x8f\xa1", "house_with_garden"}, {"\xf0\x9f\x8f\xa2", "office_building"},
1031 {"\xf0\x9f\x8f\xa3", "japanese_post_office"}, {"\xf0\x9f\x8f\xa4", "post_office"}, {"\xf0\x9f\x8f\xa5", "hospital"},
1032 {"\xf0\x9f\x8f\xa6", "bank"}, {"\xf0\x9f\x8f\xa8", "hotel"}, {"\xf0\x9f\x8f\xa9", "love_hotel"},
1033 {"\xf0\x9f\x8f\xaa", "convenience_store"}, {"\xf0\x9f\x8f\xab", "school"}, {"\xf0\x9f\x8f\xac", "department_store"},
1034 {"\xf0\x9f\x8f\xad", "factory"}, {"\xf0\x9f\x8f\xaf", "japanese_castle"}, {"\xf0\x9f\x8f\xb0", "castle"},
1035 {"\xf0\x9f\x92\x92", "wedding"}, {"\xf0\x9f\x97\xbc", "tokyo_tower"}, {"\xf0\x9f\x97\xbd", "statue_of_liberty"},
1036 {"\xe2\x9b\xaa", "church"}, {"\xf0\x9f\x95\x8c", "mosque"}, {"\xf0\x9f\x9b\x95", "hindu_temple"},
1037 {"\xf0\x9f\x95\x8d", "synagogue"}, {"\xe2\x9b\xa9\xef\xb8\x8f", "shinto_shrine"}, {"\xf0\x9f\x95\x8b", "kaaba"},
1038 {"\xe2\x9b\xb2", "fountain"}, {"\xe2\x9b\xba", "tent"}, {"\xf0\x9f\x8c\x81", "foggy"},
1039 {"\xf0\x9f\x8c\x83", "night_with_stars"}, {"\xf0\x9f\x8f\x99\xef\xb8\x8f", "cityscape"}, {"\xf0\x9f\x8c\x84", "sunrise_over_mountains"},
1040 {"\xf0\x9f\x8c\x85", "sunrise"}, {"\xf0\x9f\x8c\x86", "cityscape_at_dusk"}, {"\xf0\x9f\x8c\x87", "sunset"},
1041 {"\xf0\x9f\x8c\x89", "bridge_at_night"}, {"\xe2\x99\xa8\xef\xb8\x8f", "hot_springs"}, {"\xf0\x9f\x8e\xa0", "carousel_horse"},
1042 {"\xf0\x9f\x9b\x9d", "playground_slide"}, {"\xf0\x9f\x8e\xa1", "ferris_wheel"}, {"\xf0\x9f\x8e\xa2", "roller_coaster"},
1043 {"\xf0\x9f\x92\x88", "barber_pole"}, {"\xf0\x9f\x8e\xaa", "circus_tent"}, {"\xf0\x9f\x9a\x82", "locomotive"},
1044 {"\xf0\x9f\x9a\x83", "railway_car"}, {"\xf0\x9f\x9a\x84", "high-speed_train"}, {"\xf0\x9f\x9a\x85", "bullet_train"},
1045 {"\xf0\x9f\x9a\x86", "train"}, {"\xf0\x9f\x9a\x87", "metro"}, {"\xf0\x9f\x9a\x88", "light_rail"},
1046 {"\xf0\x9f\x9a\x89", "station"}, {"\xf0\x9f\x9a\x8a", "tram"}, {"\xf0\x9f\x9a\x9d", "monorail"},
1047 {"\xf0\x9f\x9a\x9e", "mountain_railway"}, {"\xf0\x9f\x9a\x8b", "tram_car"}, {"\xf0\x9f\x9a\x8c", "bus"},
1048 {"\xf0\x9f\x9a\x8d", "oncoming_bus"}, {"\xf0\x9f\x9a\x8e", "trolleybus"}, {"\xf0\x9f\x9a\x90", "minibus"},
1049 {"\xf0\x9f\x9a\x91", "ambulance"}, {"\xf0\x9f\x9a\x92", "fire_engine"}, {"\xf0\x9f\x9a\x93", "police_car"},
1050 {"\xf0\x9f\x9a\x94", "oncoming_police_car"}, {"\xf0\x9f\x9a\x95", "taxi"}, {"\xf0\x9f\x9a\x96", "oncoming_taxi"},
1051 {"\xf0\x9f\x9a\x97", "automobile"}, {"\xf0\x9f\x9a\x98", "oncoming_automobile"}, {"\xf0\x9f\x9a\x99", "sport_utility_vehicle"},
1052 {"\xf0\x9f\x9b\xbb", "pickup_truck"}, {"\xf0\x9f\x9a\x9a", "delivery_truck"}, {"\xf0\x9f\x9a\x9b", "articulated_lorry"},
1053 {"\xf0\x9f\x9a\x9c", "tractor"}, {"\xf0\x9f\x8f\x8e\xef\xb8\x8f", "racing_car"}, {"\xf0\x9f\x8f\x8d\xef\xb8\x8f", "motorcycle"},
1054 {"\xf0\x9f\x9b\xb5", "motor_scooter"}, {"\xf0\x9f\xa6\xbd", "manual_wheelchair"}, {"\xf0\x9f\xa6\xbc", "motorized_wheelchair"},
1055 {"\xf0\x9f\x9b\xba", "auto_rickshaw"}, {"\xf0\x9f\x9a\xb2", "bicycle"}, {"\xf0\x9f\x9b\xb4", "kick_scooter"},
1056 {"\xf0\x9f\x9b\xb9", "skateboard"}, {"\xf0\x9f\x9b\xbc", "roller_skate"}, {"\xf0\x9f\x9a\x8f", "bus_stop"},
1057 {"\xf0\x9f\x9b\xa3\xef\xb8\x8f", "motorway"}, {"\xf0\x9f\x9b\xa4\xef\xb8\x8f", "railway_track"}, {"\xf0\x9f\x9b\xa2\xef\xb8\x8f", "oil_drum"},
1058 {"\xe2\x9b\xbd", "fuel_pump"}, {"\xf0\x9f\x9b\x9e", "wheel"}, {"\xf0\x9f\x9a\xa8", "police_car_light"},
1059 {"\xf0\x9f\x9a\xa5", "horizontal_traffic_light"}, {"\xf0\x9f\x9a\xa6", "vertical_traffic_light"}, {"\xf0\x9f\x9b\x91", "stop_sign"},
1060 {"\xf0\x9f\x9a\xa7", "construction"}, {"\xe2\x9a\x93", "anchor"}, {"\xf0\x9f\x9b\x9f", "ring_buoy"},
1061 {"\xe2\x9b\xb5", "sailboat"}, {"\xf0\x9f\x9b\xb6", "canoe"}, {"\xf0\x9f\x9a\xa4", "speedboat"},
1062 {"\xf0\x9f\x9b\xb3\xef\xb8\x8f", "passenger_ship"}, {"\xe2\x9b\xb4\xef\xb8\x8f", "ferry"}, {"\xf0\x9f\x9b\xa5\xef\xb8\x8f", "motor_boat"},
1063 {"\xf0\x9f\x9a\xa2", "ship"}, {"\xe2\x9c\x88\xef\xb8\x8f", "airplane"}, {"\xf0\x9f\x9b\xa9\xef\xb8\x8f", "small_airplane"},
1064 {"\xf0\x9f\x9b\xab", "airplane_departure"}, {"\xf0\x9f\x9b\xac", "airplane_arrival"}, {"\xf0\x9f\xaa\x82", "parachute"},
1065 {"\xf0\x9f\x92\xba", "seat"}, {"\xf0\x9f\x9a\x81", "helicopter"}, {"\xf0\x9f\x9a\x9f", "suspension_railway"},
1066 {"\xf0\x9f\x9a\xa0", "mountain_cableway"}, {"\xf0\x9f\x9a\xa1", "aerial_tramway"}, {"\xf0\x9f\x9b\xb0\xef\xb8\x8f", "satellite"},
1067 {"\xf0\x9f\x9a\x80", "rocket"}, {"\xf0\x9f\x9b\xb8", "flying_saucer"}, {"\xf0\x9f\x9b\xb8", "ufo"},
1068 {"\xf0\x9f\x9b\x8e\xef\xb8\x8f", "bellhop_bell"}, {"\xf0\x9f\xa7\xb3", "luggage"}, {"\xe2\x8c\x9b", "hourglass_done"},
1069 {"\xe2\x8f\xb3", "hourglass_not_done"}, {"\xe2\x8c\x9a", "watch"}, {"\xe2\x8f\xb0", "alarm_clock"},
1070 {"\xe2\x8f\xb1\xef\xb8\x8f", "stopwatch"}, {"\xe2\x8f\xb2\xef\xb8\x8f", "timer_clock"}, {"\xf0\x9f\x95\xb0\xef\xb8\x8f", "mantelpiece_clock"},
1071 {"\xf0\x9f\x95\x9b", "twelve_oclock"}, {"\xf0\x9f\x95\xa7", "twelve-thirty"}, {"\xf0\x9f\x95\x90", "one_oclock"},
1072 {"\xf0\x9f\x95\x9c", "one-thirty"}, {"\xf0\x9f\x95\x91", "two_oclock"}, {"\xf0\x9f\x95\x9d", "two-thirty"},
1073 {"\xf0\x9f\x95\x92", "three_oclock"}, {"\xf0\x9f\x95\x9e", "three-thirty"}, {"\xf0\x9f\x95\x93", "four_oclock"},
1074 {"\xf0\x9f\x95\x9f", "four-thirty"}, {"\xf0\x9f\x95\x94", "five_oclock"}, {"\xf0\x9f\x95\xa0", "five-thirty"},
1075 {"\xf0\x9f\x95\x95", "six_oclock"}, {"\xf0\x9f\x95\xa1", "six-thirty"}, {"\xf0\x9f\x95\x96", "seven_oclock"},
1076 {"\xf0\x9f\x95\xa2", "seven-thirty"}, {"\xf0\x9f\x95\x97", "eight_oclock"}, {"\xf0\x9f\x95\xa3", "eight-thirty"},
1077 {"\xf0\x9f\x95\x98", "nine_oclock"}, {"\xf0\x9f\x95\xa4", "nine-thirty"}, {"\xf0\x9f\x95\x99", "ten_oclock"},
1078 {"\xf0\x9f\x95\xa5", "ten-thirty"}, {"\xf0\x9f\x95\x9a", "eleven_oclock"}, {"\xf0\x9f\x95\xa6", "eleven-thirty"},
1079 {"\xf0\x9f\x8c\x91", "new_moon"}, {"\xf0\x9f\x8c\x92", "waxing_crescent_moon"}, {"\xf0\x9f\x8c\x93", "first_quarter_moon"},
1080 {"\xf0\x9f\x8c\x94", "waxing_gibbous_moon"}, {"\xf0\x9f\x8c\x95", "full_moon"}, {"\xf0\x9f\x8c\x96", "waning_gibbous_moon"},
1081 {"\xf0\x9f\x8c\x97", "last_quarter_moon"}, {"\xf0\x9f\x8c\x98", "waning_crescent_moon"}, {"\xf0\x9f\x8c\x99", "crescent_moon"},
1082 {"\xf0\x9f\x8c\x9a", "new_moon_face"}, {"\xf0\x9f\x8c\x9b", "first_quarter_moon_face"}, {"\xf0\x9f\x8c\x9c", "last_quarter_moon_face"},
1083 {"\xf0\x9f\x8c\xa1\xef\xb8\x8f", "thermometer"}, {"\xe2\x98\x80\xef\xb8\x8f", "sun"}, {"\xf0\x9f\x8c\x9d", "full_moon_face"},
1084 {"\xf0\x9f\x8c\x9e", "sun_with_face"}, {"\xf0\x9f\xaa\x90", "ringed_planet"}, {"\xe2\xad\x90", "star"},
1085 {"\xf0\x9f\x8c\x9f", "glowing_star"}, {"\xf0\x9f\x8c\x9f", "star2"}, {"\xf0\x9f\x8c\xa0", "shooting_star"},
1086 {"\xf0\x9f\x8c\x8c", "milky_way"}, {"\xe2\x98\x81\xef\xb8\x8f", "cloud"}, {"\xe2\x9b\x85", "sun_behind_cloud"},
1087 {"\xe2\x9b\x88\xef\xb8\x8f", "cloud_with_lightning_and_rain"}, {"\xf0\x9f\x8c\xa4\xef\xb8\x8f", "sun_behind_small_cloud"}, {"\xf0\x9f\x8c\xa5\xef\xb8\x8f", "sun_behind_large_cloud"},
1088 {"\xf0\x9f\x8c\xa6\xef\xb8\x8f", "sun_behind_rain_cloud"}, {"\xf0\x9f\x8c\xa7\xef\xb8\x8f", "cloud_with_rain"}, {"\xf0\x9f\x8c\xa8\xef\xb8\x8f", "cloud_with_snow"},
1089 {"\xf0\x9f\x8c\xa9\xef\xb8\x8f", "cloud_with_lightning"}, {"\xf0\x9f\x8c\xaa\xef\xb8\x8f", "tornado"}, {"\xf0\x9f\x8c\xab\xef\xb8\x8f", "fog"},
1090 {"\xf0\x9f\x8c\xac\xef\xb8\x8f", "wind_face"}, {"\xf0\x9f\x8c\x80", "cyclone"}, {"\xf0\x9f\x8c\x88", "rainbow"},
1091 {"\xf0\x9f\x8c\x82", "closed_umbrella"}, {"\xe2\x98\x82\xef\xb8\x8f", "umbrella"}, {"\xe2\x98\x94", "umbrella_with_rain_drops"},
1092 {"\xe2\x9b\xb1\xef\xb8\x8f", "umbrella_on_ground"}, {"\xe2\x9a\xa1", "high_voltage"}, {"\xe2\x9a\xa1", "zap"},
1093 {"\xe2\x9d\x84\xef\xb8\x8f", "snowflake"}, {"\xe2\x98\x83\xef\xb8\x8f", "snowman"}, {"\xe2\x9b\x84", "snowman_without_snow"},
1094 {"\xe2\x98\x84\xef\xb8\x8f", "comet"}, {"\xf0\x9f\x94\xa5", "fire"}, {"\xf0\x9f\x92\xa7", "droplet"},
1095 {"\xf0\x9f\x8c\x8a", "water_wave"}, {"\xf0\x9f\x8c\x8a", "wave"}, {"\xf0\x9f\x8e\x83", "jack-o-lantern"},
1096 {"\xf0\x9f\x8e\x84", "christmas_tree"}, {"\xf0\x9f\x8e\x86", "fireworks"}, {"\xf0\x9f\x8e\x87", "sparkler"},
1097 {"\xf0\x9f\xa7\xa8", "firecracker"}, {"\xe2\x9c\xa8", "sparkles"}, {"\xf0\x9f\x8e\x88", "balloon"},
1098 {"\xf0\x9f\x8e\x89", "party_popper"}, {"\xf0\x9f\x8e\x89", "tada"}, {"\xf0\x9f\x8e\x8a", "confetti_ball"},
1099 {"\xf0\x9f\x8e\x8a", "confetti"}, {"\xf0\x9f\x8e\x8b", "tanabata_tree"}, {"\xf0\x9f\x8e\x8d", "pine_decoration"},
1100 {"\xf0\x9f\x8e\x8e", "japanese_dolls"}, {"\xf0\x9f\x8e\x8f", "carp_streamer"}, {"\xf0\x9f\x8e\x90", "wind_chime"},
1101 {"\xf0\x9f\x8e\x91", "moon_viewing_ceremony"}, {"\xf0\x9f\xa7\xa7", "red_envelope"}, {"\xf0\x9f\x8e\x80", "ribbon"},
1102 {"\xf0\x9f\x8e\x81", "wrapped_gift"}, {"\xf0\x9f\x8e\x97\xef\xb8\x8f", "reminder_ribbon"}, {"\xf0\x9f\x8e\x9f\xef\xb8\x8f", "admission_tickets"},
1103 {"\xf0\x9f\x8e\xab", "ticket"}, {"\xf0\x9f\x8e\x96\xef\xb8\x8f", "military_medal"}, {"\xf0\x9f\x8f\x86", "trophy"},
1104 {"\xf0\x9f\x8f\x85", "sports_medal"}, {"\xf0\x9f\xa5\x87", "1st_place_medal"}, {"\xf0\x9f\xa5\x88", "2nd_place_medal"},
1105 {"\xf0\x9f\xa5\x89", "3rd_place_medal"}, {"\xe2\x9a\xbd", "soccer_ball"}, {"\xe2\x9a\xbe", "baseball"},
1106 {"\xf0\x9f\xa5\x8e", "softball"}, {"\xf0\x9f\x8f\x80", "basketball"}, {"\xf0\x9f\x8f\x90", "volleyball"},
1107 {"\xf0\x9f\x8f\x88", "american_football"}, {"\xf0\x9f\x8f\x89", "rugby_football"}, {"\xf0\x9f\x8e\xbe", "tennis"},
1108 {"\xf0\x9f\xa5\x8f", "flying_disc"}, {"\xf0\x9f\x8e\xb3", "bowling"}, {"\xf0\x9f\x8f\x8f", "cricket_game"},
1109 {"\xf0\x9f\x8f\x91", "field_hockey"}, {"\xf0\x9f\x8f\x92", "ice_hockey"}, {"\xf0\x9f\xa5\x8d", "lacrosse"},
1110 {"\xf0\x9f\x8f\x93", "ping_pong"}, {"\xf0\x9f\x8f\xb8", "badminton"}, {"\xf0\x9f\xa5\x8a", "boxing_glove"},
1111 {"\xf0\x9f\xa5\x8b", "martial_arts_uniform"}, {"\xf0\x9f\xa5\x85", "goal_net"}, {"\xe2\x9b\xb3", "flag_in_hole"},
1112 {"\xe2\x9b\xb8\xef\xb8\x8f", "ice_skate"}, {"\xf0\x9f\x8e\xa3", "fishing_pole"}, {"\xf0\x9f\xa4\xbf", "diving_mask"},
1113 {"\xf0\x9f\x8e\xbd", "running_shirt"}, {"\xf0\x9f\x8e\xbf", "skis"}, {"\xf0\x9f\x9b\xb7", "sled"},
1114 {"\xf0\x9f\xa5\x8c", "curling_stone"}, {"\xf0\x9f\x8e\xaf", "bullseye"}, {"\xf0\x9f\x8e\xaf", "dart"},
1115 {"\xf0\x9f\xaa\x80", "yo-yo"}, {"\xf0\x9f\xaa\x81", "kite"}, {"\xf0\x9f\x94\xab", "water_pistol"},
1116 {"\xf0\x9f\x8e\xb1", "pool_8_ball"}, {"\xf0\x9f\x94\xae", "crystal_ball"}, {"\xf0\x9f\xaa\x84", "magic_wand"},
1117 {"\xf0\x9f\x8e\xae", "video_game"}, {"\xf0\x9f\x95\xb9\xef\xb8\x8f", "joystick"}, {"\xf0\x9f\x8e\xb0", "slot_machine"},
1118 {"\xf0\x9f\x8e\xb2", "game_die"}, {"\xf0\x9f\xa7\xa9", "puzzle_piece"}, {"\xf0\x9f\xa7\xb8", "teddy_bear"},
1119 {"\xf0\x9f\xaa\x85", "piata"}, {"\xf0\x9f\xaa\xa9", "mirror_ball"}, {"\xf0\x9f\xaa\x86", "nesting_dolls"},
1120 {"\xe2\x99\xa0\xef\xb8\x8f", "spade_suit"}, {"\xe2\x99\xa5\xef\xb8\x8f", "heart_suit"}, {"\xe2\x99\xa6\xef\xb8\x8f", "diamond_suit"},
1121 {"\xe2\x99\xa3\xef\xb8\x8f", "club_suit"}, {"\xe2\x99\x9f\xef\xb8\x8f", "chess_pawn"}, {"\xf0\x9f\x83\x8f", "joker"},
1122 {"\xf0\x9f\x80\x84", "mahjong_red_dragon"}, {"\xf0\x9f\x8e\xb4", "flower_playing_cards"}, {"\xf0\x9f\x8e\xad", "performing_arts"},
1123 {"\xf0\x9f\x96\xbc\xef\xb8\x8f", "framed_picture"}, {"\xf0\x9f\x8e\xa8", "artist_palette"}, {"\xf0\x9f\xa7\xb5", "thread"},
1124 {"\xf0\x9f\xaa\xa1", "sewing_needle"}, {"\xf0\x9f\xa7\xb6", "yarn"}, {"\xf0\x9f\xaa\xa2", "knot"},
1125 {"\xf0\x9f\x91\x93", "glasses"}, {"\xf0\x9f\xa5\xbd", "goggles"}, {"\xf0\x9f\xa5\xbc", "lab_coat"},
1126 {"\xf0\x9f\xa6\xba", "safety_vest"}, {"\xf0\x9f\x91\x94", "necktie"}, {"\xf0\x9f\x91\x95", "t-shirt"},
1127 {"\xf0\x9f\x91\x96", "jeans"}, {"\xf0\x9f\xa7\xa3", "scarf"}, {"\xf0\x9f\xa7\xa4", "gloves"},
1128 {"\xf0\x9f\xa7\xa5", "coat"}, {"\xf0\x9f\xa7\xa6", "socks"}, {"\xf0\x9f\x91\x97", "dress"},
1129 {"\xf0\x9f\x91\x98", "kimono"}, {"\xf0\x9f\xa5\xbb", "sari"}, {"\xf0\x9f\xa9\xb1", "one-piece_swimsuit"},
1130 {"\xf0\x9f\xa9\xb2", "briefs"}, {"\xf0\x9f\xa9\xb3", "shorts"}, {"\xf0\x9f\x91\x99", "bikini"},
1131 {"\xf0\x9f\x91\x9a", "womans_clothes"}, {"\xf0\x9f\xaa\xad", "folding_hand_fan"}, {"\xf0\x9f\x91\x9b", "purse"},
1132 {"\xf0\x9f\x91\x9c", "handbag"}, {"\xf0\x9f\x91\x9d", "clutch_bag"}, {"\xf0\x9f\x9b\x8d\xef\xb8\x8f", "shopping_bags"},
1133 {"\xf0\x9f\x8e\x92", "backpack"}, {"\xf0\x9f\xa9\xb4", "thong_sandal"}, {"\xf0\x9f\x91\x9e", "mans_shoe"},
1134 {"\xf0\x9f\x91\x9f", "running_shoe"}, {"\xf0\x9f\xa5\xbe", "hiking_boot"}, {"\xf0\x9f\xa5\xbf", "flat_shoe"},
1135 {"\xf0\x9f\x91\xa0", "high-heeled_shoe"}, {"\xf0\x9f\x91\xa1", "womans_sandal"}, {"\xf0\x9f\xa9\xb0", "ballet_shoes"},
1136 {"\xf0\x9f\x91\xa2", "womans_boot"}, {"\xf0\x9f\xaa\xae", "hair_pick"}, {"\xf0\x9f\x91\x91", "crown"},
1137 {"\xf0\x9f\x91\x92", "womans_hat"}, {"\xf0\x9f\x8e\xa9", "top_hat"}, {"\xf0\x9f\x8e\x93", "graduation_cap"},
1138 {"\xf0\x9f\xa7\xa2", "billed_cap"}, {"\xf0\x9f\xaa\x96", "military_helmet"}, {"\xe2\x9b\x91\xef\xb8\x8f", "rescue_workers_helmet"},
1139 {"\xf0\x9f\x93\xbf", "prayer_beads"}, {"\xf0\x9f\x92\x84", "lipstick"}, {"\xf0\x9f\x92\x8d", "ring"},
1140 {"\xf0\x9f\x92\x8e", "gem_stone"}, {"\xf0\x9f\x92\x8e", "gem"}, {"\xf0\x9f\x94\x87", "muted_speaker"},
1141 {"\xf0\x9f\x94\x88", "speaker_low_volume"}, {"\xf0\x9f\x94\x89", "speaker_medium_volume"}, {"\xf0\x9f\x94\x8a", "speaker_high_volume"},
1142 {"\xf0\x9f\x93\xa2", "loudspeaker"}, {"\xf0\x9f\x93\xa3", "megaphone"}, {"\xf0\x9f\x93\xaf", "postal_horn"},
1143 {"\xf0\x9f\x94\x94", "bell"}, {"\xf0\x9f\x94\x95", "bell_with_slash"}, {"\xf0\x9f\x8e\xbc", "musical_score"},
1144 {"\xf0\x9f\x8e\xb5", "musical_note"}, {"\xf0\x9f\x8e\xb5", "notes"}, {"\xf0\x9f\x8e\xb6", "musical_notes"},
1145 {"\xf0\x9f\x8e\x99\xef\xb8\x8f", "studio_microphone"}, {"\xf0\x9f\x8e\x9a\xef\xb8\x8f", "level_slider"}, {"\xf0\x9f\x8e\x9b\xef\xb8\x8f", "control_knobs"},
1146 {"\xf0\x9f\x8e\xa4", "microphone"}, {"\xf0\x9f\x8e\xa7", "headphone"}, {"\xf0\x9f\x93\xbb", "radio"},
1147 {"\xf0\x9f\x8e\xb7", "saxophone"}, {"\xf0\x9f\xaa\x97", "accordion"}, {"\xf0\x9f\x8e\xb8", "guitar"},
1148 {"\xf0\x9f\x8e\xb9", "musical_keyboard"}, {"\xf0\x9f\x8e\xba", "trumpet"}, {"\xf0\x9f\x8e\xbb", "violin"},
1149 {"\xf0\x9f\xaa\x95", "banjo"}, {"\xf0\x9f\xa5\x81", "drum"}, {"\xf0\x9f\xaa\x98", "long_drum"},
1150 {"\xf0\x9f\xaa\x87", "maracas"}, {"\xf0\x9f\xaa\x88", "flute"}, {"\xf0\x9f\xaa\x89", "harp"},
1151 {"\xf0\x9f\x93\xb1", "mobile_phone"}, {"\xf0\x9f\x93\xb2", "mobile_phone_with_arrow"}, {"\xe2\x98\x8e\xef\xb8\x8f", "telephone"},
1152 {"\xf0\x9f\x93\x9e", "telephone_receiver"}, {"\xf0\x9f\x93\x9f", "pager"}, {"\xf0\x9f\x93\xa0", "fax_machine"},
1153 {"\xf0\x9f\x94\x8b", "battery"}, {"\xf0\x9f\xaa\xab", "low_battery"}, {"\xf0\x9f\x94\x8c", "electric_plug"},
1154 {"\xf0\x9f\x92\xbb", "laptop"}, {"\xf0\x9f\x96\xa5\xef\xb8\x8f", "desktop_computer"}, {"\xf0\x9f\x96\xa5\xef\xb8\x8f", "desktop"},
1155 {"\xf0\x9f\x96\xa8\xef\xb8\x8f", "printer"}, {"\xe2\x8c\xa8\xef\xb8\x8f", "keyboard"}, {"\xf0\x9f\x96\xb1\xef\xb8\x8f", "computer_mouse"},
1156 {"\xf0\x9f\x96\xb2\xef\xb8\x8f", "trackball"}, {"\xf0\x9f\x92\xbd", "computer_disk"}, {"\xf0\x9f\x92\xbe", "floppy_disk"},
1157 {"\xf0\x9f\x92\xbf", "optical_disk"}, {"\xf0\x9f\x93\x80", "dvd"}, {"\xf0\x9f\xa7\xae", "abacus"},
1158 {"\xf0\x9f\x8e\xa5", "movie_camera"}, {"\xf0\x9f\x8e\x9e\xef\xb8\x8f", "film_frames"}, {"\xf0\x9f\x93\xbd\xef\xb8\x8f", "film_projector"},
1159 {"\xf0\x9f\x8e\xac", "clapper_board"}, {"\xf0\x9f\x8e\xac", "clapper"}, {"\xf0\x9f\x93\xba", "television"},
1160 {"\xf0\x9f\x93\xb7", "camera"}, {"\xf0\x9f\x93\xb8", "camera_with_flash"}, {"\xf0\x9f\x93\xb9", "video_camera"},
1161 {"\xf0\x9f\x93\xbc", "videocassette"}, {"\xf0\x9f\x94\x8d", "magnifying_glass_tilted_left"}, {"\xf0\x9f\x94\x8e", "magnifying_glass_tilted_right"},
1162 {"\xf0\x9f\x95\xaf\xef\xb8\x8f", "candle"}, {"\xf0\x9f\x92\xa1", "light_bulb"}, {"\xf0\x9f\x92\xa1", "bulb"},
1163 {"\xf0\x9f\x94\xa6", "flashlight"}, {"\xf0\x9f\x8f\xae", "red_paper_lantern"}, {"\xf0\x9f\xaa\x94", "diya_lamp"},
1164 {"\xf0\x9f\x93\x94", "notebook_with_decorative_cover"}, {"\xf0\x9f\x93\x95", "closed_book"}, {"\xf0\x9f\x93\x96", "open_book"},
1165 {"\xf0\x9f\x93\x96", "book"}, {"\xf0\x9f\x93\x97", "green_book"}, {"\xf0\x9f\x93\x98", "blue_book"},
1166 {"\xf0\x9f\x93\x99", "orange_book"}, {"\xf0\x9f\x93\x9a", "books"}, {"\xf0\x9f\x93\x93", "notebook"},
1167 {"\xf0\x9f\x93\x92", "ledger"}, {"\xf0\x9f\x93\x83", "page_with_curl"}, {"\xf0\x9f\x93\x9c", "scroll"},
1168 {"\xf0\x9f\x93\x84", "page_facing_up"}, {"\xf0\x9f\x93\xb0", "newspaper"}, {"\xf0\x9f\x97\x9e\xef\xb8\x8f", "rolled-up_newspaper"},
1169 {"\xf0\x9f\x93\x91", "bookmark_tabs"}, {"\xf0\x9f\x94\x96", "bookmark"}, {"\xf0\x9f\x8f\xb7\xef\xb8\x8f", "label"},
1170 {"\xf0\x9f\x92\xb0", "money_bag"}, {"\xf0\x9f\xaa\x99", "coin"}, {"\xf0\x9f\x92\xb4", "yen_banknote"},
1171 {"\xf0\x9f\x92\xb5", "dollar_banknote"}, {"\xf0\x9f\x92\xb6", "euro_banknote"}, {"\xf0\x9f\x92\xb7", "pound_banknote"},
1172 {"\xf0\x9f\x92\xb8", "money_with_wings"}, {"\xf0\x9f\x92\xb3", "credit_card"}, {"\xf0\x9f\xa7\xbe", "receipt"},
1173 {"\xf0\x9f\x92\xb9", "chart_increasing_with_yen"}, {"\xe2\x9c\x89\xef\xb8\x8f", "envelope"}, {"\xf0\x9f\x93\xa7", "e-mail"},
1174 {"\xf0\x9f\x93\xa8", "incoming_envelope"}, {"\xf0\x9f\x93\xa9", "envelope_with_arrow"}, {"\xf0\x9f\x93\xa4", "outbox_tray"},
1175 {"\xf0\x9f\x93\xa5", "inbox_tray"}, {"\xf0\x9f\x93\xa6", "package"}, {"\xf0\x9f\x93\xab", "closed_mailbox_with_raised_flag"},
1176 {"\xf0\x9f\x93\xaa", "closed_mailbox_with_lowered_flag"}, {"\xf0\x9f\x93\xac", "open_mailbox_with_raised_flag"}, {"\xf0\x9f\x93\xad", "open_mailbox_with_lowered_flag"},
1177 {"\xf0\x9f\x93\xae", "postbox"}, {"\xf0\x9f\x97\xb3\xef\xb8\x8f", "ballot_box_with_ballot"}, {"\xe2\x9c\x8f\xef\xb8\x8f", "pencil"},
1178 {"\xe2\x9c\x92\xef\xb8\x8f", "black_nib"}, {"\xf0\x9f\x96\x8b\xef\xb8\x8f", "fountain_pen"}, {"\xf0\x9f\x96\x8a\xef\xb8\x8f", "pen"},
1179 {"\xf0\x9f\x96\x8c\xef\xb8\x8f", "paintbrush"}, {"\xf0\x9f\x96\x8d\xef\xb8\x8f", "crayon"}, {"\xf0\x9f\x93\x9d", "memo"},
1180 {"\xf0\x9f\x92\xbc", "briefcase"}, {"\xf0\x9f\x93\x81", "file_folder"}, {"\xf0\x9f\x93\x82", "open_file_folder"},
1181 {"\xf0\x9f\x97\x82\xef\xb8\x8f", "card_index_dividers"}, {"\xf0\x9f\x93\x85", "calendar"}, {"\xf0\x9f\x93\x86", "tear-off_calendar"},
1182 {"\xf0\x9f\x97\x92\xef\xb8\x8f", "spiral_notepad"}, {"\xf0\x9f\x97\x93\xef\xb8\x8f", "spiral_calendar"}, {"\xf0\x9f\x93\x87", "card_index"},
1183 {"\xf0\x9f\x93\x88", "chart_increasing"}, {"\xf0\x9f\x93\x89", "chart_decreasing"}, {"\xf0\x9f\x93\x8a", "bar_chart"},
1184 {"\xf0\x9f\x93\x8b", "clipboard"}, {"\xf0\x9f\x93\x8c", "pushpin"}, {"\xf0\x9f\x93\x8d", "round_pushpin"},
1185 {"\xf0\x9f\x93\x8e", "paperclip"}, {"\xf0\x9f\x96\x87\xef\xb8\x8f", "linked_paperclips"}, {"\xf0\x9f\x93\x8f", "straight_ruler"},
1186 {"\xf0\x9f\x93\x90", "triangular_ruler"}, {"\xe2\x9c\x82\xef\xb8\x8f", "scissors"}, {"\xf0\x9f\x97\x83\xef\xb8\x8f", "card_file_box"},
1187 {"\xf0\x9f\x97\x84\xef\xb8\x8f", "file_cabinet"}, {"\xf0\x9f\x97\x91\xef\xb8\x8f", "wastebasket"}, {"\xf0\x9f\x94\x92", "locked"},
1188 {"\xf0\x9f\x94\x92", "lock"}, {"\xf0\x9f\x94\x93", "unlocked"}, {"\xf0\x9f\x94\x93", "unlock"},
1189 {"\xf0\x9f\x94\x8f", "locked_with_pen"}, {"\xf0\x9f\x94\x90", "locked_with_key"}, {"\xf0\x9f\x94\x91", "key"},
1190 {"\xf0\x9f\x97\x9d\xef\xb8\x8f", "old_key"}, {"\xf0\x9f\x94\xa8", "hammer"}, {"\xf0\x9f\xaa\x93", "axe"},
1191 {"\xe2\x9b\x8f\xef\xb8\x8f", "pick"}, {"\xe2\x9a\x92\xef\xb8\x8f", "hammer_and_pick"}, {"\xf0\x9f\x9b\xa0\xef\xb8\x8f", "hammer_and_wrench"},
1192 {"\xf0\x9f\x97\xa1\xef\xb8\x8f", "dagger"}, {"\xe2\x9a\x94\xef\xb8\x8f", "crossed_swords"}, {"\xf0\x9f\x92\xa3", "bomb"},
1193 {"\xf0\x9f\xaa\x83", "boomerang"}, {"\xf0\x9f\x8f\xb9", "bow_and_arrow"}, {"\xf0\x9f\x9b\xa1\xef\xb8\x8f", "shield"},
1194 {"\xf0\x9f\xaa\x9a", "carpentry_saw"}, {"\xf0\x9f\x94\xa7", "wrench"}, {"\xf0\x9f\xaa\x9b", "screwdriver"},
1195 {"\xf0\x9f\x94\xa9", "nut_and_bolt"}, {"\xe2\x9a\x99\xef\xb8\x8f", "gear"}, {"\xf0\x9f\x97\x9c\xef\xb8\x8f", "clamp"},
1196 {"\xe2\x9a\x96\xef\xb8\x8f", "balance_scale"}, {"\xf0\x9f\xa6\xaf", "white_cane"}, {"\xf0\x9f\x94\x97", "link"},
1197 {"\xe2\x9b\x93\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\xa5", "broken_chain"}, {"\xe2\x9b\x93\xef\xb8\x8f", "chains"}, {"\xf0\x9f\xaa\x9d", "hook"},
1198 {"\xf0\x9f\xa7\xb0", "toolbox"}, {"\xf0\x9f\xa7\xb2", "magnet"}, {"\xf0\x9f\xaa\x9c", "ladder"},
1199 {"\xf0\x9f\xaa\x8f", "shovel"}, {"\xe2\x9a\x97\xef\xb8\x8f", "alembic"}, {"\xf0\x9f\xa7\xaa", "test_tube"},
1200 {"\xf0\x9f\xa7\xab", "petri_dish"}, {"\xf0\x9f\xa7\xac", "dna"}, {"\xf0\x9f\x94\xac", "microscope"},
1201 {"\xf0\x9f\x94\xad", "telescope"}, {"\xf0\x9f\x93\xa1", "satellite_antenna"}, {"\xf0\x9f\x92\x89", "syringe"},
1202 {"\xf0\x9f\xa9\xb8", "drop_of_blood"}, {"\xf0\x9f\x92\x8a", "pill"}, {"\xf0\x9f\xa9\xb9", "adhesive_bandage"},
1203 {"\xf0\x9f\xa9\xbc", "crutch"}, {"\xf0\x9f\xa9\xba", "stethoscope"}, {"\xf0\x9f\xa9\xbb", "x-ray"},
1204 {"\xf0\x9f\x9a\xaa", "door"}, {"\xf0\x9f\x9b\x97", "elevator"}, {"\xf0\x9f\xaa\x9e", "mirror"},
1205 {"\xf0\x9f\xaa\x9f", "window"}, {"\xf0\x9f\x9b\x8f\xef\xb8\x8f", "bed"}, {"\xf0\x9f\x9b\x8b\xef\xb8\x8f", "couch_and_lamp"},
1206 {"\xf0\x9f\xaa\x91", "chair"}, {"\xf0\x9f\x9a\xbd", "toilet"}, {"\xf0\x9f\xaa\xa0", "plunger"},
1207 {"\xf0\x9f\x9a\xbf", "shower"}, {"\xf0\x9f\x9b\x81", "bathtub"}, {"\xf0\x9f\xaa\xa4", "mouse_trap"},
1208 {"\xf0\x9f\xaa\x92", "razor"}, {"\xf0\x9f\xa7\xb4", "lotion_bottle"}, {"\xf0\x9f\xa7\xb7", "safety_pin"},
1209 {"\xf0\x9f\xa7\xb9", "broom"}, {"\xf0\x9f\xa7\xba", "basket"}, {"\xf0\x9f\xa7\xbb", "roll_of_paper"},
1210 {"\xf0\x9f\xaa\xa3", "bucket"}, {"\xf0\x9f\xa7\xbc", "soap"}, {"\xf0\x9f\xab\xa7", "bubbles"},
1211 {"\xf0\x9f\xaa\xa5", "toothbrush"}, {"\xf0\x9f\xa7\xbd", "sponge"}, {"\xf0\x9f\xa7\xaf", "fire_extinguisher"},
1212 {"\xf0\x9f\x9b\x92", "shopping_cart"}, {"\xf0\x9f\x9a\xac", "cigarette"}, {"\xe2\x9a\xb0\xef\xb8\x8f", "coffin"},
1213 {"\xf0\x9f\xaa\xa6", "headstone"}, {"\xe2\x9a\xb1\xef\xb8\x8f", "funeral_urn"}, {"\xf0\x9f\xa7\xbf", "nazar_amulet"},
1214 {"\xf0\x9f\xaa\xac", "hamsa"}, {"\xf0\x9f\x97\xbf", "moai"}, {"\xf0\x9f\xaa\xa7", "placard"},
1215 {"\xf0\x9f\xaa\xaa", "identification_card"}, {"\xf0\x9f\x8f\xa7", "atm_sign"}, {"\xf0\x9f\x9a\xae", "litter_in_bin_sign"},
1216 {"\xf0\x9f\x9a\xb0", "potable_water"}, {"\xe2\x99\xbf", "wheelchair_symbol"}, {"\xf0\x9f\x9a\xb9", "mens_room"},
1217 {"\xf0\x9f\x9a\xba", "womens_room"}, {"\xf0\x9f\x9a\xbb", "restroom"}, {"\xf0\x9f\x9a\xbc", "baby_symbol"},
1218 {"\xf0\x9f\x9a\xbe", "water_closet"}, {"\xf0\x9f\x9b\x82", "passport_control"}, {"\xf0\x9f\x9b\x83", "customs"},
1219 {"\xf0\x9f\x9b\x84", "baggage_claim"}, {"\xf0\x9f\x9b\x85", "left_luggage"}, {"\xe2\x9a\xa0\xef\xb8\x8f", "warning"},
1220 {"\xf0\x9f\x9a\xb8", "children_crossing"}, {"\xe2\x9b\x94", "no_entry"}, {"\xf0\x9f\x9a\xab", "prohibited"},
1221 {"\xf0\x9f\x9a\xb3", "no_bicycles"}, {"\xf0\x9f\x9a\xad", "no_smoking"}, {"\xf0\x9f\x9a\xaf", "no_littering"},
1222 {"\xf0\x9f\x9a\xb1", "non-potable_water"}, {"\xf0\x9f\x9a\xb7", "no_pedestrians"}, {"\xf0\x9f\x93\xb5", "no_mobile_phones"},
1223 {"\xf0\x9f\x94\x9e", "no_one_under_eighteen"}, {"\xe2\x98\xa2\xef\xb8\x8f", "radioactive"}, {"\xe2\x98\xa3\xef\xb8\x8f", "biohazard"},
1224 {"\xe2\xac\x86\xef\xb8\x8f", "up_arrow"}, {"\xe2\x86\x97\xef\xb8\x8f", "up-right_arrow"}, {"\xe2\x9e\xa1\xef\xb8\x8f", "right_arrow"},
1225 {"\xe2\x86\x98\xef\xb8\x8f", "down-right_arrow"}, {"\xe2\xac\x87\xef\xb8\x8f", "down_arrow"}, {"\xe2\x86\x99\xef\xb8\x8f", "down-left_arrow"},
1226 {"\xe2\xac\x85\xef\xb8\x8f", "left_arrow"}, {"\xe2\x86\x96\xef\xb8\x8f", "up-left_arrow"}, {"\xe2\x86\x95\xef\xb8\x8f", "up-down_arrow"},
1227 {"\xe2\x86\x94\xef\xb8\x8f", "left-right_arrow"}, {"\xe2\x86\xa9\xef\xb8\x8f", "right_arrow_curving_left"}, {"\xe2\x86\xaa\xef\xb8\x8f", "left_arrow_curving_right"},
1228 {"\xe2\xa4\xb4\xef\xb8\x8f", "right_arrow_curving_up"}, {"\xe2\xa4\xb5\xef\xb8\x8f", "right_arrow_curving_down"}, {"\xf0\x9f\x94\x83", "clockwise_vertical_arrows"},
1229 {"\xf0\x9f\x94\x84", "counterclockwise_arrows_button"}, {"\xf0\x9f\x94\x99", "back_arrow"}, {"\xf0\x9f\x94\x9a", "end_arrow"},
1230 {"\xf0\x9f\x94\x9b", "on_arrow"}, {"\xf0\x9f\x94\x9c", "soon_arrow"}, {"\xf0\x9f\x94\x9d", "top_arrow"},
1231 {"\xf0\x9f\x9b\x90", "place_of_worship"}, {"\xe2\x9a\x9b\xef\xb8\x8f", "atom_symbol"}, {"\xf0\x9f\x95\x89\xef\xb8\x8f", "om"},
1232 {"\xe2\x9c\xa1\xef\xb8\x8f", "star_of_david"}, {"\xe2\x98\xb8\xef\xb8\x8f", "wheel_of_dharma"}, {"\xe2\x98\xaf\xef\xb8\x8f", "yin_yang"},
1233 {"\xe2\x9c\x9d\xef\xb8\x8f", "latin_cross"}, {"\xe2\x98\xa6\xef\xb8\x8f", "orthodox_cross"}, {"\xe2\x98\xaa\xef\xb8\x8f", "star_and_crescent"},
1234 {"\xe2\x98\xae\xef\xb8\x8f", "peace_symbol"}, {"\xf0\x9f\x95\x8e", "menorah"}, {"\xf0\x9f\x94\xaf", "dotted_six-pointed_star"},
1235 {"\xf0\x9f\xaa\xaf", "khanda"}, {"\xe2\x99\x88", "aries"}, {"\xe2\x99\x89", "taurus"},
1236 {"\xe2\x99\x8a", "gemini"}, {"\xe2\x99\x8b", "cancer"}, {"\xe2\x99\x8c", "leo"},
1237 {"\xe2\x99\x8d", "virgo"}, {"\xe2\x99\x8e", "libra"}, {"\xe2\x99\x8f", "scorpio"},
1238 {"\xe2\x99\x90", "sagittarius"}, {"\xe2\x99\x91", "capricorn"}, {"\xe2\x99\x92", "aquarius"},
1239 {"\xe2\x99\x93", "pisces"}, {"\xe2\x9b\x8e", "ophiuchus"}, {"\xf0\x9f\x94\x80", "shuffle_tracks_button"},
1240 {"\xf0\x9f\x94\x81", "repeat_button"}, {"\xf0\x9f\x94\x82", "repeat_single_button"}, {"\xe2\x96\xb6\xef\xb8\x8f", "play_button"},
1241 {"\xe2\x8f\xa9", "fast-forward_button"}, {"\xe2\x8f\xad\xef\xb8\x8f", "next_track_button"}, {"\xe2\x8f\xaf\xef\xb8\x8f", "play_or_pause_button"},
1242 {"\xe2\x97\x80\xef\xb8\x8f", "reverse_button"}, {"\xe2\x8f\xaa", "fast_reverse_button"}, {"\xe2\x8f\xae\xef\xb8\x8f", "last_track_button"},
1243 {"\xf0\x9f\x94\xbc", "upwards_button"}, {"\xe2\x8f\xab", "fast_up_button"}, {"\xf0\x9f\x94\xbd", "downwards_button"},
1244 {"\xe2\x8f\xac", "fast_down_button"}, {"\xe2\x8f\xb8\xef\xb8\x8f", "pause_button"}, {"\xe2\x8f\xb9\xef\xb8\x8f", "stop_button"},
1245 {"\xe2\x8f\xba\xef\xb8\x8f", "record_button"}, {"\xe2\x8f\x8f\xef\xb8\x8f", "eject_button"}, {"\xf0\x9f\x8e\xa6", "cinema"},
1246 {"\xf0\x9f\x94\x85", "dim_button"}, {"\xf0\x9f\x94\x86", "bright_button"}, {"\xf0\x9f\x93\xb6", "antenna_bars"},
1247 {"\xf0\x9f\x9b\x9c", "wireless"}, {"\xf0\x9f\x93\xb3", "vibration_mode"}, {"\xf0\x9f\x93\xb4", "mobile_phone_off"},
1248 {"\xe2\x99\x80\xef\xb8\x8f", "female_sign"}, {"\xe2\x99\x82\xef\xb8\x8f", "male_sign"}, {"\xe2\x9a\xa7\xef\xb8\x8f", "transgender_symbol"},
1249 {"\xe2\x9c\x96\xef\xb8\x8f", "multiply"}, {"\xe2\x9e\x95", "plus"}, {"\xe2\x9e\x96", "minus"},
1250 {"\xe2\x9e\x97", "divide"}, {"\xf0\x9f\x9f\xb0", "heavy_equals_sign"}, {"\xe2\x99\xbe\xef\xb8\x8f", "infinity"},
1251 {"\xe2\x80\xbc\xef\xb8\x8f", "double_exclamation_mark"}, {"\xe2\x81\x89\xef\xb8\x8f", "exclamation_question_mark"}, {"\xe2\x9d\x93", "red_question_mark"},
1252 {"\xe2\x9d\x93", "question"}, {"\xe2\x9d\x94", "white_question_mark"}, {"\xe2\x9d\x95", "white_exclamation_mark"},
1253 {"\xe2\x9d\x97", "red_exclamation_mark"}, {"\xe2\x9d\x97", "exclamation"}, {"\xe3\x80\xb0\xef\xb8\x8f", "wavy_dash"},
1254 {"\xf0\x9f\x92\xb1", "currency_exchange"}, {"\xf0\x9f\x92\xb2", "heavy_dollar_sign"}, {"\xe2\x9a\x95\xef\xb8\x8f", "medical_symbol"},
1255 {"\xe2\x99\xbb\xef\xb8\x8f", "recycling_symbol"}, {"\xe2\x99\xbb\xef\xb8\x8f", "recycle"}, {"\xe2\x9a\x9c\xef\xb8\x8f", "fleur-de-lis"},
1256 {"\xf0\x9f\x94\xb1", "trident_emblem"}, {"\xf0\x9f\x93\x9b", "name_badge"}, {"\xf0\x9f\x94\xb0", "japanese_symbol_for_beginner"},
1257 {"\xe2\xad\x95", "hollow_red_circle"}, {"\xe2\x9c\x85", "check_mark_button"}, {"\xe2\x9c\x85", "white_check_mark"},
1258 {"\xe2\x98\x91\xef\xb8\x8f", "check_box_with_check"}, {"\xe2\x9c\x94\xef\xb8\x8f", "check_mark"}, {"\xe2\x9d\x8c", "cross_mark"},
1259 {"\xe2\x9d\x8c", "x"}, {"\xe2\x9d\x8e", "cross_mark_button"}, {"\xe2\x9e\xb0", "curly_loop"},
1260 {"\xe2\x9e\xbf", "double_curly_loop"}, {"\xe3\x80\xbd\xef\xb8\x8f", "part_alternation_mark"}, {"\xe2\x9c\xb3\xef\xb8\x8f", "eight-spoked_asterisk"},
1261 {"\xe2\x9c\xb4\xef\xb8\x8f", "eight-pointed_star"}, {"\xe2\x9d\x87\xef\xb8\x8f", "sparkle"}, {"\xc2\xa9\xef\xb8\x8f", "copyright"},
1262 {"\xc2\xae\xef\xb8\x8f", "registered"}, {"\xe2\x84\xa2\xef\xb8\x8f", "trade_mark"}, {"\xf0\x9f\xab\x9f", "splatter"},
1263 {"\x23\xef\xb8\x8f\xe2\x83\xa3", "keycap_"}, {"\x30\xef\xb8\x8f\xe2\x83\xa3", "keycap_0"}, {"\x31\xef\xb8\x8f\xe2\x83\xa3", "keycap_1"},
1264 {"\x32\xef\xb8\x8f\xe2\x83\xa3", "keycap_2"}, {"\x33\xef\xb8\x8f\xe2\x83\xa3", "keycap_3"}, {"\x34\xef\xb8\x8f\xe2\x83\xa3", "keycap_4"},
1265 {"\x35\xef\xb8\x8f\xe2\x83\xa3", "keycap_5"}, {"\x36\xef\xb8\x8f\xe2\x83\xa3", "keycap_6"}, {"\x37\xef\xb8\x8f\xe2\x83\xa3", "keycap_7"},
1266 {"\x38\xef\xb8\x8f\xe2\x83\xa3", "keycap_8"}, {"\x39\xef\xb8\x8f\xe2\x83\xa3", "keycap_9"}, {"\xf0\x9f\x94\x9f", "keycap_10"},
1267 {"\xf0\x9f\x94\xa0", "input_latin_uppercase"}, {"\xf0\x9f\x94\xa1", "input_latin_lowercase"}, {"\xf0\x9f\x94\xa2", "input_numbers"},
1268 {"\xf0\x9f\x94\xa3", "input_symbols"}, {"\xf0\x9f\x94\xa4", "input_latin_letters"}, {"\xf0\x9f\x85\xb0\xef\xb8\x8f", "a_button_blood_type"},
1269 {"\xf0\x9f\x86\x8e", "ab_button_blood_type"}, {"\xf0\x9f\x85\xb1\xef\xb8\x8f", "b_button_blood_type"}, {"\xf0\x9f\x86\x91", "cl_button"},
1270 {"\xf0\x9f\x86\x92", "cool_button"}, {"\xf0\x9f\x86\x93", "free_button"}, {"\xe2\x84\xb9\xef\xb8\x8f", "information"},
1271 {"\xf0\x9f\x86\x94", "id_button"}, {"\xe2\x93\x82\xef\xb8\x8f", "circled_m"}, {"\xf0\x9f\x86\x95", "new_button"},
1272 {"\xf0\x9f\x86\x95", "new"}, {"\xf0\x9f\x86\x96", "ng_button"}, {"\xf0\x9f\x85\xbe\xef\xb8\x8f", "o_button_blood_type"},
1273 {"\xf0\x9f\x86\x97", "ok_button"}, {"\xf0\x9f\x86\x97", "ok"}, {"\xf0\x9f\x85\xbf\xef\xb8\x8f", "p_button"},
1274 {"\xf0\x9f\x86\x98", "sos_button"}, {"\xf0\x9f\x86\x99", "up_button"}, {"\xf0\x9f\x86\x99", "up"},
1275 {"\xf0\x9f\x86\x9a", "vs_button"}, {"\xf0\x9f\x88\x81", "japanese_here_button"}, {"\xf0\x9f\x88\x82\xef\xb8\x8f", "japanese_service_charge_button"},
1276 {"\xf0\x9f\x88\xb7\xef\xb8\x8f", "japanese_monthly_amount_button"}, {"\xf0\x9f\x88\xb6", "japanese_not_free_of_charge_button"}, {"\xf0\x9f\x88\xaf", "japanese_reserved_button"},
1277 {"\xf0\x9f\x89\x90", "japanese_bargain_button"}, {"\xf0\x9f\x88\xb9", "japanese_discount_button"}, {"\xf0\x9f\x88\x9a", "japanese_free_of_charge_button"},
1278 {"\xf0\x9f\x88\xb2", "japanese_prohibited_button"}, {"\xf0\x9f\x89\x91", "japanese_acceptable_button"}, {"\xf0\x9f\x88\xb8", "japanese_application_button"},
1279 {"\xf0\x9f\x88\xb4", "japanese_passing_grade_button"}, {"\xf0\x9f\x88\xb3", "japanese_vacancy_button"}, {"\xe3\x8a\x97\xef\xb8\x8f", "japanese_congratulations_button"},
1280 {"\xe3\x8a\x99\xef\xb8\x8f", "japanese_secret_button"}, {"\xf0\x9f\x88\xba", "japanese_open_for_business_button"}, {"\xf0\x9f\x88\xb5", "japanese_no_vacancy_button"},
1281 {"\xf0\x9f\x94\xb4", "red_circle"}, {"\xf0\x9f\x9f\xa0", "orange_circle"}, {"\xf0\x9f\x9f\xa1", "yellow_circle"},
1282 {"\xf0\x9f\x9f\xa2", "green_circle"}, {"\xf0\x9f\x94\xb5", "blue_circle"}, {"\xf0\x9f\x9f\xa3", "purple_circle"},
1283 {"\xf0\x9f\x9f\xa4", "brown_circle"}, {"\xe2\x9a\xab", "black_circle"}, {"\xe2\x9a\xaa", "white_circle"},
1284 {"\xf0\x9f\x9f\xa5", "red_square"}, {"\xf0\x9f\x9f\xa7", "orange_square"}, {"\xf0\x9f\x9f\xa8", "yellow_square"},
1285 {"\xf0\x9f\x9f\xa9", "green_square"}, {"\xf0\x9f\x9f\xa6", "blue_square"}, {"\xf0\x9f\x9f\xaa", "purple_square"},
1286 {"\xf0\x9f\x9f\xab", "brown_square"}, {"\xe2\xac\x9b", "black_large_square"}, {"\xe2\xac\x9c", "white_large_square"},
1287 {"\xe2\x97\xbc\xef\xb8\x8f", "black_medium_square"}, {"\xe2\x97\xbb\xef\xb8\x8f", "white_medium_square"}, {"\xe2\x97\xbe", "black_medium-small_square"},
1288 {"\xe2\x97\xbd", "white_medium-small_square"}, {"\xe2\x96\xaa\xef\xb8\x8f", "black_small_square"}, {"\xe2\x96\xab\xef\xb8\x8f", "white_small_square"},
1289 {"\xf0\x9f\x94\xb6", "large_orange_diamond"}, {"\xf0\x9f\x94\xb7", "large_blue_diamond"}, {"\xf0\x9f\x94\xb8", "small_orange_diamond"},
1290 {"\xf0\x9f\x94\xb9", "small_blue_diamond"}, {"\xf0\x9f\x94\xba", "red_triangle_pointed_up"}, {"\xf0\x9f\x94\xbb", "red_triangle_pointed_down"},
1291 {"\xf0\x9f\x92\xa0", "diamond_with_a_dot"}, {"\xf0\x9f\x94\x98", "radio_button"}, {"\xf0\x9f\x94\xb3", "white_square_button"},
1292 {"\xf0\x9f\x94\xb2", "black_square_button"}, {"\xf0\x9f\x8f\x81", "chequered_flag"}, {"\xf0\x9f\x9a\xa9", "triangular_flag"},
1293 {"\xf0\x9f\x8e\x8c", "crossed_flags"}, {"\xf0\x9f\x8f\xb4", "black_flag"}, {"\xf0\x9f\x8f\xb3\xef\xb8\x8f", "white_flag"},
1294 {"\xf0\x9f\x8f\xb3\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x8c\x88", "rainbow_flag"}, {"\xf0\x9f\x8f\xb3\xef\xb8\x8f\xe2\x80\x8d\xe2\x9a\xa7\xef\xb8\x8f", "transgender_flag"}, {"\xf0\x9f\x8f\xb4\xe2\x80\x8d\xe2\x98\xa0\xef\xb8\x8f", "pirate_flag"},
1295 {"\xf0\x9f\x8f\xb4\xf3\xa0\x81\xa7\xf3\xa0\x81\xa2\xf3\xa0\x81\xa5\xf3\xa0\x81\xae\xf3\xa0\x81\xa7\xf3\xa0\x81\xbf", "flag_england"}, {"\xf0\x9f\x8f\xb4\xf3\xa0\x81\xa7\xf3\xa0\x81\xa2\xf3\xa0\x81\xb3\xf3\xa0\x81\xa3\xf3\xa0\x81\xb4\xf3\xa0\x81\xbf", "flag_scotland"}, {"\xf0\x9f\x8f\xb4\xf3\xa0\x81\xa7\xf3\xa0\x81\xa2\xf3\xa0\x81\xb7\xf3\xa0\x81\xac\xf3\xa0\x81\xb3\xf3\xa0\x81\xbf", "flag_wales"},
1296 }
1297 langNames = map[string]string{
1298 "en": "English",
1299 "ja": "日本語",
1300 "pt": "Português",
1301 }
1302 translations = map[string]map[string]string{
1303 "en": {
1304 // Login page.
1305 "subtitle": "the digital home for the music scene",
1306 "login": "login",
1307 "requesting": "requesting...",
1308 "err_no_id": "no identity - open signer to add one",
1309 "language": "Language",
1310 "theme": "Theme",
1311 "dark": "dark",
1312 "light": "light",
1313
1314 // Top bar / nav.
1315 "back": "\u2190 back",
1316 "feed": "feed",
1317 "compose": "compose",
1318 "messaging": "messaging",
1319 "settings": "settings",
1320 "notifications": "notifications",
1321 "no_notifications": "no notifications yet",
1322 "about": "about",
1323
1324 // Feed.
1325 "connecting": "connecting...",
1326 "loading_translator": "loading translator...",
1327 "show_more": "show more",
1328 "show_less": "show less",
1329 "note_not_found": "Note not found",
1330 "reposted": "reposted",
1331
1332 // Thread view.
1333 "loading_thread": "loading thread...",
1334 "thread_empty": "thread not found",
1335 "replying_to": "replying to...",
1336 "reply_not_found": "note not found",
1337
1338 // Profile.
1339 "notes": "notes",
1340 "follows": "follows",
1341 "relays": "relays",
1342 "mutes": "mutes",
1343 "no_follows": "no follows data",
1344 "no_relays": "no relay data",
1345 "no_mutes": "no mutes data",
1346 "following": "following",
1347 "muted": "muted",
1348 "copy": "copy",
1349 "qr": "qr",
1350 "message": "message",
1351 "loading": "loading...",
1352 "relay_info": "relay info",
1353 "relay_fail": "failed to fetch relay info",
1354
1355 // Messaging.
1356 "new_chat": " | new chat",
1357 "no_convos": "no conversations yet",
1358 "npub_placeholder": "npub or hex pubkey",
1359 "go": "go",
1360 "msg_placeholder": "message...",
1361 "send": "send",
1362 "dm_notice": "encrypted DMs require the <b>musiquay signer</b> extension",
1363 "ratchet": "ratchet",
1364 "relay_leak_warn": "some of the selected relays may leak metadata",
1365
1366 // About.
1367 "developed_by": "developed by:",
1368 "tagline": "Can Youse Paradigm?",
1369 "about_donkey": "Every hour you don't zap, a donkey eats another cabbage. You can stop this. \U0001FABE",
1370
1371 // Settings.
1372 "settings_title": "Settings",
1373 "lang_label": "Interface language",
1374 "theme_label": "Appearance",
1375
1376 // Relay settings.
1377 "relays_section": "Relays",
1378 "relays_active": "Active relays",
1379 "relays_required": "Required capabilities",
1380 "relays_req_nip42": "NIP-42 authentication",
1381 "relays_req_nip70": "NIP-70 protected events",
1382 "relays_block_payment": "Block payment-required relays",
1383 "relays_block_restrict": "Block restricted-write relays",
1384 "relays_blocklist": "Blocklist",
1385 "relays_blocklist_empty": "(none)",
1386 "relays_no_nip11": "(no NIP-11)",
1387
1388 // Signer.
1389 "signer": "signer",
1390 "no_signer_backend": "No signer detected. Install the extension.",
1391 "no_vault": "No vault found. Create a new one.",
1392 "hd_keychain": "HD Keychain",
1393 "vault_password": "Vault password",
1394 "generate_keychain": "Generate New Keychain",
1395 "generating": "Generating keychain...",
1396 "create_failed": "Failed to create vault.",
1397 "restore_seed": "Restore from Seed Phrase",
1398 "seed_placeholder": "12-word seed phrase",
1399 "account_placeholder": "Account number (default: 1)",
1400 "restore_keychain": "Restore Keychain",
1401 "restoring": "Restoring keychain...",
1402 "restore_failed": "Invalid seed phrase or vault creation failed.",
1403 "deriving_n": "Deriving identity",
1404 "import_vault": "Import Vault File",
1405 "choose_vault": "Choose Vault File...",
1406 "vault_imported": "Vault imported. Unlock with your password.",
1407 "invalid_vault": "Invalid vault file.",
1408 "write_seed": "Write Down Your Seed Phrase",
1409 "seed_warning": "This is the ONLY way to recover your identities. Write these words down and store them safely. They will not be shown again.",
1410 "copy_clipboard": "Copy to Clipboard",
1411 "copied": "Copied!",
1412 "saved_it": "I've Saved It",
1413 "vault_locked": "Vault is locked.",
1414 "password": "Password",
1415 "unlock": "Unlock",
1416 "deriving_key": "Deriving key, this may take a minute...",
1417 "wrong_password": "Wrong password.",
1418 "unlock_failed_kdf": "Unlock failed - key derivation crashed. See log.",
1419 "identities": "Identities",
1420 "nickname_placeholder": "Nickname for new identity",
1421 "derive_new": "Derive New Identity",
1422 "deriving": "Deriving...",
1423 "show_seed": "Show Seed Phrase",
1424 "export_vault": "Export Vault",
1425 "export_password_prompt": "Choose a password to protect the exported vault:",
1426 "confirm_password": "Confirm password",
1427 "password_required": "Password is required.",
1428 "passwords_mismatch": "Passwords do not match.",
1429 "export_failed": "Export failed.",
1430 "cancel": "Cancel",
1431 "lock_vault": "Lock Vault",
1432 "logout": "Logout",
1433 "logout_confirm": "This will erase all keys and log you out. Are you sure?",
1434 "use": "Use",
1435 "publish": "Publish",
1436 "add": "Add",
1437 "edit_profile": "edit profile",
1438 "save": "Save",
1439 "profile_name": "Display name",
1440 "profile_about": "About",
1441 "profile_picture": "Picture URL",
1442 "profile_banner": "Banner URL",
1443 "profile_nip05": "NIP-05 (user@domain)",
1444 "profile_website": "Website",
1445 "profile_lud16": "Lightning address",
1446 "profile_saved": "Profile published",
1447
1448 // Wallet (NWC).
1449 "wallet": "Wallet",
1450 "wallet_no_wallet": "No wallet connected. Paste a Nostr Wallet Connect URI below.",
1451 "wallet_add": "Add Wallet",
1452 "wallet_add_another": "Add another wallet",
1453 "wallet_add_hint": "Paste a nostr+walletconnect:// URI from Alby Hub, Mutiny, LNbits, etc.",
1454 "wallet_alias_placeholder": "Alias (optional)",
1455 "wallet_adding": "Adding...",
1456 "wallet_add_failed": "Could not add wallet. Check the URI.",
1457 "wallet_remove_confirm": "Remove this wallet connection?",
1458 "wallet_refresh": "Refresh",
1459 "wallet_loading_balance": "loading...",
1460 "wallet_sats": "sats",
1461 "wallet_send": "Send",
1462 "wallet_recipient_placeholder": "lnbc... or user@domain",
1463 "wallet_amount_placeholder": "Amount (sats, Lightning address only)",
1464 "wallet_comment_placeholder": "Comment (optional)",
1465 "wallet_pay": "Pay",
1466 "wallet_paying": "Paying...",
1467 "wallet_pay_ok": "Paid",
1468 "wallet_pay_failed": "Payment failed",
1469 "wallet_need_amount": "Amount required for Lightning addresses.",
1470 "wallet_unknown_recipient": "Unknown recipient. Use a BOLT11 invoice or lightning address.",
1471 },
1472 "ja": {
1473 "subtitle": "音楽シーンのためのデジタルホーム",
1474 "login": "ログイン",
1475 "requesting": "リクエスト中...",
1476 "err_no_id": "IDがありません - 署名を開いて追加してください",
1477 "language": "言語",
1478 "theme": "テーマ",
1479 "dark": "ダーク",
1480 "light": "ライト",
1481
1482 "back": "\u2190 戻る",
1483 "feed": "フィード",
1484 "compose": "作成",
1485 "messaging": "メッセージ",
1486 "settings": "設定",
1487 "notifications": "通知",
1488 "no_notifications": "通知はまだありません",
1489 "about": "概要",
1490
1491 "connecting": "接続中...",
1492 "loading_translator": "翻訳モジュール読み込み中...",
1493 "show_more": "もっと見る",
1494 "show_less": "閉じる",
1495 "note_not_found": "ノートが見つかりません",
1496 "reposted": "リポスト",
1497
1498 "loading_thread": "スレッド読み込み中...",
1499 "thread_empty": "スレッドが見つかりません",
1500 "replying_to": "返信先...",
1501 "reply_not_found": "ノートが見つかりません",
1502
1503 "notes": "ノート",
1504 "follows": "フォロー",
1505 "relays": "リレー",
1506 "mutes": "ミュート",
1507 "no_follows": "フォローデータなし",
1508 "no_relays": "リレーデータなし",
1509 "no_mutes": "ミュートデータなし",
1510 "following": "フォロー中",
1511 "muted": "ミュート中",
1512 "copy": "コピー",
1513 "qr": "QR",
1514 "message": "メッセージ",
1515 "loading": "読み込み中...",
1516 "relay_info": "リレー情報",
1517 "relay_fail": "リレー情報の取得に失敗しました",
1518
1519 "new_chat": " | 新しいチャット",
1520 "no_convos": "会話がまだありません",
1521 "npub_placeholder": "npubまたは16進公開鍵",
1522 "go": "実行",
1523 "msg_placeholder": "メッセージ...",
1524 "send": "送信",
1525 "dm_notice": "暗号化DMには<b>musiquay signer</b>拡張が必要です",
1526 "ratchet": "ラチェット",
1527 "relay_leak_warn": "選択されたリレーの一部がメタデータを漏洩する可能性があります",
1528
1529 "developed_by": "開発者:",
1530 "tagline": "Can Youse Paradigm?",
1531 "about_donkey": "zapしないと毎時間ロバがキャベツを食べてしまいます。止められるのはあなたです。\U0001FABE",
1532
1533 "settings_title": "設定",
1534 "lang_label": "表示言語",
1535 "theme_label": "外観",
1536
1537 "relays_section": "リレー",
1538 "relays_active": "アクティブなリレー",
1539 "relays_required": "必須機能",
1540 "relays_req_nip42": "NIP-42 認証",
1541 "relays_req_nip70": "NIP-70 保護イベント",
1542 "relays_block_payment": "有料リレーをブロック",
1543 "relays_block_restrict": "書き込み制限リレーをブロック",
1544 "relays_blocklist": "ブロックリスト",
1545 "relays_blocklist_empty": "(なし)",
1546 "relays_no_nip11": "(NIP-11なし)",
1547
1548 "signer": "署名",
1549 "no_vault": "金庫が見つかりません。新規作成してください。",
1550 "hd_keychain": "HDキーチェーン",
1551 "vault_password": "金庫のパスワード",
1552 "generate_keychain": "新しいキーチェーンを生成",
1553 "generating": "キーチェーン生成中...",
1554 "create_failed": "金庫の作成に失敗しました。",
1555 "restore_seed": "シードフレーズから復元",
1556 "seed_placeholder": "12語のシードフレーズ",
1557 "account_placeholder": "アカウント番号(デフォルト: 1)",
1558 "restore_keychain": "キーチェーンを復元",
1559 "restoring": "キーチェーン復元中...",
1560 "restore_failed": "シードフレーズが無効か、金庫の作成に失敗しました。",
1561 "deriving_n": "ID導出中",
1562 "import_vault": "金庫ファイルをインポート",
1563 "choose_vault": "金庫ファイルを選択...",
1564 "vault_imported": "金庫をインポートしました。パスワードでロック解除してください。",
1565 "invalid_vault": "無効な金庫ファイルです。",
1566 "write_seed": "シードフレーズを書き留めてください",
1567 "seed_warning": "これがIDを復元する唯一の方法です。これらの単語を書き留めて安全に保管してください。再表示されません。",
1568 "copy_clipboard": "クリップボードにコピー",
1569 "copied": "コピー済み!",
1570 "saved_it": "保存しました",
1571 "vault_locked": "金庫はロックされています。",
1572 "password": "パスワード",
1573 "unlock": "ロック解除",
1574 "deriving_key": "鍵を導出中です。しばらくお待ちください...",
1575 "wrong_password": "パスワードが違います。",
1576 "identities": "ID一覧",
1577 "nickname_placeholder": "新しいIDのニックネーム",
1578 "derive_new": "新しいIDを導出",
1579 "deriving": "導出中...",
1580 "show_seed": "シードフレーズを表示",
1581 "export_vault": "金庫をエクスポート",
1582 "export_password_prompt": "エクスポートの保護パスワードを入力してください:",
1583 "confirm_password": "パスワード確認",
1584 "password_required": "パスワードが必要です。",
1585 "passwords_mismatch": "パスワードが一致しません。",
1586 "export_failed": "エクスポートに失敗しました。",
1587 "cancel": "キャンセル",
1588 "lock_vault": "金庫をロック",
1589 "logout": "ログアウト",
1590 "logout_confirm": "すべての鍵が削除され、ログアウトします。よろしいですか?",
1591 "use": "使用",
1592 "publish": "公開",
1593 "add": "追加",
1594
1595 // Wallet (NWC).
1596 "wallet": "ウォレット",
1597 "wallet_no_wallet": "ウォレットが接続されていません。下に Nostr Wallet Connect URI を貼り付けてください。",
1598 "wallet_add": "ウォレットを追加",
1599 "wallet_add_another": "別のウォレットを追加",
1600 "wallet_add_hint": "Alby Hub、Mutiny、LNbits などの nostr+walletconnect:// URI を貼り付けてください。",
1601 "wallet_alias_placeholder": "別名(任意)",
1602 "wallet_adding": "追加中...",
1603 "wallet_add_failed": "ウォレットを追加できませんでした。URI を確認してください。",
1604 "wallet_remove_confirm": "このウォレット接続を削除しますか?",
1605 "wallet_refresh": "更新",
1606 "wallet_loading_balance": "読み込み中...",
1607 "wallet_sats": "sats",
1608 "wallet_send": "送信",
1609 "wallet_recipient_placeholder": "lnbc... または user@domain",
1610 "wallet_amount_placeholder": "金額 (sats、Lightning アドレスのみ)",
1611 "wallet_comment_placeholder": "コメント(任意)",
1612 "wallet_pay": "支払う",
1613 "wallet_paying": "支払い中...",
1614 "wallet_pay_ok": "支払い完了",
1615 "wallet_pay_failed": "支払い失敗",
1616 "wallet_need_amount": "Lightning アドレスには金額が必要です。",
1617 "wallet_unknown_recipient": "不明な受取人。BOLT11 インボイスまたは Lightning アドレスを使用してください。",
1618 },
1619 "pt": {
1620 "subtitle": "a casa digital da cena musical",
1621 "login": "entrar",
1622 "requesting": "solicitando...",
1623 "err_no_id": "sem identidade - abra o signer para adicionar uma",
1624 "language": "Idioma",
1625 "theme": "Tema",
1626 "dark": "escuro",
1627 "light": "claro",
1628
1629 "back": "\u2190 voltar",
1630 "feed": "feed",
1631 "compose": "compor",
1632 "messaging": "mensagens",
1633 "settings": "configurações",
1634 "notifications": "notificações",
1635 "no_notifications": "nenhuma notificação ainda",
1636 "about": "sobre",
1637
1638 "connecting": "conectando...",
1639 "loading_translator": "carregando tradutor...",
1640 "show_more": "mostrar mais",
1641 "show_less": "mostrar menos",
1642 "note_not_found": "Nota não encontrada",
1643 "reposted": "repostou",
1644
1645 "loading_thread": "carregando thread...",
1646 "thread_empty": "thread não encontrada",
1647 "replying_to": "respondendo a...",
1648 "reply_not_found": "nota não encontrada",
1649
1650 "notes": "notas",
1651 "follows": "seguindo",
1652 "relays": "relays",
1653 "mutes": "silenciados",
1654 "no_follows": "sem dados de seguidos",
1655 "no_relays": "sem dados de relays",
1656 "no_mutes": "sem dados de silenciados",
1657 "following": "seguindo",
1658 "muted": "silenciados",
1659 "copy": "copiar",
1660 "qr": "qr",
1661 "message": "mensagem",
1662 "loading": "carregando...",
1663 "relay_info": "info do relay",
1664 "relay_fail": "falha ao buscar info do relay",
1665
1666 "new_chat": " | novo chat",
1667 "no_convos": "nenhuma conversa ainda",
1668 "npub_placeholder": "npub ou chave pública hex",
1669 "go": "ir",
1670 "msg_placeholder": "mensagem...",
1671 "send": "enviar",
1672 "dm_notice": "DMs encriptadas requerem a extensão <b>musiquay signer</b>",
1673 "ratchet": "ratchet",
1674 "relay_leak_warn": "alguns dos relays selecionados podem vazar metadados",
1675
1676 "developed_by": "desenvolvido por:",
1677 "tagline": "Can Youse Paradigm?",
1678 "about_donkey": "A cada hora que você não dá zap, um burro come outro repolho. Você pode impedir isso. \U0001FABE",
1679
1680 "settings_title": "Configurações",
1681 "lang_label": "Idioma da interface",
1682 "theme_label": "Aparência",
1683
1684 "relays_section": "Relays",
1685 "relays_active": "Relays ativos",
1686 "relays_required": "Capacidades necessárias",
1687 "relays_req_nip42": "NIP-42 autenticação",
1688 "relays_req_nip70": "NIP-70 eventos protegidos",
1689 "relays_block_payment": "Bloquear relays pagos",
1690 "relays_block_restrict": "Bloquear relays com escrita restrita",
1691 "relays_blocklist": "Lista de bloqueio",
1692 "relays_blocklist_empty": "(nenhum)",
1693 "relays_no_nip11": "(sem NIP-11)",
1694
1695 "signer": "assinador",
1696 "no_vault": "Nenhum cofre encontrado. Crie um novo.",
1697 "hd_keychain": "Chaveiro HD",
1698 "vault_password": "Senha do cofre",
1699 "generate_keychain": "Gerar Novo Chaveiro",
1700 "generating": "Gerando chaveiro...",
1701 "create_failed": "Falha ao criar cofre.",
1702 "restore_seed": "Restaurar com Frase Semente",
1703 "seed_placeholder": "Frase semente de 12 palavras",
1704 "account_placeholder": "Número da conta (padrão: 1)",
1705 "restore_keychain": "Restaurar Chaveiro",
1706 "restoring": "Restaurando chaveiro...",
1707 "restore_failed": "Frase semente inválida ou falha ao criar cofre.",
1708 "deriving_n": "Derivando identidade",
1709 "import_vault": "Importar Arquivo de Cofre",
1710 "choose_vault": "Escolher Arquivo de Cofre...",
1711 "vault_imported": "Cofre importado. Desbloqueie com sua senha.",
1712 "invalid_vault": "Arquivo de cofre inválido.",
1713 "write_seed": "Anote Sua Frase Semente",
1714 "seed_warning": "Esta é a ÚNICA forma de recuperar suas identidades. Anote estas palavras e guarde-as em segurança. Elas não serão exibidas novamente.",
1715 "copy_clipboard": "Copiar para Área de Transferência",
1716 "copied": "Copiado!",
1717 "saved_it": "Já Salvei",
1718 "vault_locked": "Cofre está bloqueado.",
1719 "password": "Senha",
1720 "unlock": "Desbloquear",
1721 "deriving_key": "Derivando chave, isso pode levar um minuto...",
1722 "wrong_password": "Senha incorreta.",
1723 "identities": "Identidades",
1724 "nickname_placeholder": "Apelido para nova identidade",
1725 "derive_new": "Derivar Nova Identidade",
1726 "deriving": "Derivando...",
1727 "show_seed": "Mostrar Frase Semente",
1728 "export_vault": "Exportar Cofre",
1729 "export_password_prompt": "Escolha uma senha para proteger o cofre exportado:",
1730 "confirm_password": "Confirmar senha",
1731 "password_required": "Senha obrigatória.",
1732 "passwords_mismatch": "As senhas não coincidem.",
1733 "export_failed": "Falha na exportação.",
1734 "cancel": "Cancelar",
1735 "lock_vault": "Bloquear Cofre",
1736 "logout": "Sair",
1737 "logout_confirm": "Isso apagará todas as chaves e encerrará a sessão. Tem certeza?",
1738 "use": "Usar",
1739 "publish": "Publicar",
1740 "add": "Adicionar",
1741
1742 // Wallet (NWC).
1743 "wallet": "Carteira",
1744 "wallet_no_wallet": "Nenhuma carteira conectada. Cole um URI Nostr Wallet Connect abaixo.",
1745 "wallet_add": "Adicionar Carteira",
1746 "wallet_add_another": "Adicionar outra carteira",
1747 "wallet_add_hint": "Cole um URI nostr+walletconnect:// do Alby Hub, Mutiny, LNbits, etc.",
1748 "wallet_alias_placeholder": "Apelido (opcional)",
1749 "wallet_adding": "Adicionando...",
1750 "wallet_add_failed": "Não foi possível adicionar a carteira. Verifique o URI.",
1751 "wallet_remove_confirm": "Remover esta conexão de carteira?",
1752 "wallet_refresh": "Atualizar",
1753 "wallet_loading_balance": "carregando...",
1754 "wallet_sats": "sats",
1755 "wallet_send": "Enviar",
1756 "wallet_recipient_placeholder": "lnbc... ou user@domain",
1757 "wallet_amount_placeholder": "Valor (sats, apenas para endereço Lightning)",
1758 "wallet_comment_placeholder": "Comentário (opcional)",
1759 "wallet_pay": "Pagar",
1760 "wallet_paying": "Pagando...",
1761 "wallet_pay_ok": "Pago",
1762 "wallet_pay_failed": "Falha no pagamento",
1763 "wallet_need_amount": "Valor obrigatório para endereços Lightning.",
1764 "wallet_unknown_recipient": "Destinatário desconhecido. Use uma fatura BOLT11 ou endereço Lightning.",
1765 },
1766 }
1767 qrVersions = [41]qrVersionInfo{
1768 {},
1769 {26, [4]qrBlockInfo{{1, 7}, {1, 10}, {1, 13}, {1, 17}}}, // 1
1770 {44, [4]qrBlockInfo{{1, 10}, {1, 16}, {1, 22}, {1, 28}}}, // 2
1771 {70, [4]qrBlockInfo{{1, 15}, {1, 26}, {2, 18}, {2, 22}}}, // 3
1772 {100, [4]qrBlockInfo{{1, 20}, {2, 18}, {2, 26}, {4, 16}}}, // 4
1773 {134, [4]qrBlockInfo{{1, 26}, {2, 24}, {4, 18}, {4, 22}}}, // 5
1774 {172, [4]qrBlockInfo{{2, 18}, {4, 16}, {4, 24}, {4, 28}}}, // 6
1775 {196, [4]qrBlockInfo{{2, 20}, {4, 18}, {6, 18}, {5, 26}}}, // 7
1776 {242, [4]qrBlockInfo{{2, 24}, {4, 22}, {6, 22}, {6, 26}}}, // 8
1777 {292, [4]qrBlockInfo{{2, 30}, {5, 22}, {8, 20}, {8, 24}}}, // 9
1778 {346, [4]qrBlockInfo{{4, 18}, {5, 26}, {8, 24}, {8, 28}}}, // 10
1779 {404, [4]qrBlockInfo{{4, 20}, {5, 30}, {8, 28}, {11, 24}}}, // 11
1780 {466, [4]qrBlockInfo{{4, 24}, {8, 22}, {10, 26}, {11, 28}}}, // 12
1781 {532, [4]qrBlockInfo{{4, 26}, {9, 22}, {12, 24}, {16, 22}}}, // 13
1782 {581, [4]qrBlockInfo{{4, 30}, {9, 24}, {16, 20}, {16, 24}}}, // 14
1783 {655, [4]qrBlockInfo{{6, 22}, {10, 24}, {12, 30}, {18, 24}}}, // 15
1784 {733, [4]qrBlockInfo{{6, 24}, {10, 28}, {17, 24}, {16, 30}}}, // 16
1785 {815, [4]qrBlockInfo{{6, 28}, {11, 28}, {16, 28}, {19, 28}}}, // 17
1786 {901, [4]qrBlockInfo{{6, 30}, {13, 26}, {18, 28}, {21, 28}}}, // 18
1787 {991, [4]qrBlockInfo{{7, 28}, {14, 26}, {21, 26}, {25, 26}}}, // 19
1788 {1085, [4]qrBlockInfo{{8, 28}, {16, 26}, {20, 30}, {25, 28}}}, // 20
1789 {1156, [4]qrBlockInfo{{8, 28}, {17, 26}, {23, 28}, {25, 30}}}, // 21
1790 {1258, [4]qrBlockInfo{{9, 28}, {17, 28}, {23, 30}, {34, 24}}}, // 22
1791 {1364, [4]qrBlockInfo{{9, 30}, {18, 28}, {25, 30}, {30, 30}}}, // 23
1792 {1474, [4]qrBlockInfo{{10, 30}, {20, 28}, {27, 30}, {32, 30}}}, // 24
1793 {1588, [4]qrBlockInfo{{12, 26}, {21, 28}, {29, 30}, {35, 30}}}, // 25
1794 {1706, [4]qrBlockInfo{{12, 28}, {23, 28}, {34, 28}, {37, 30}}}, // 26
1795 {1828, [4]qrBlockInfo{{12, 30}, {25, 28}, {34, 30}, {40, 30}}}, // 27
1796 {1921, [4]qrBlockInfo{{13, 30}, {26, 28}, {35, 30}, {42, 30}}}, // 28
1797 {2051, [4]qrBlockInfo{{14, 30}, {28, 28}, {38, 30}, {45, 30}}}, // 29
1798 {2185, [4]qrBlockInfo{{15, 30}, {29, 28}, {40, 30}, {48, 30}}}, // 30
1799 {2323, [4]qrBlockInfo{{16, 30}, {31, 28}, {43, 30}, {51, 30}}}, // 31
1800 {2465, [4]qrBlockInfo{{17, 30}, {33, 28}, {45, 30}, {54, 30}}}, // 32
1801 {2611, [4]qrBlockInfo{{18, 30}, {35, 28}, {48, 30}, {57, 30}}}, // 33
1802 {2761, [4]qrBlockInfo{{19, 30}, {37, 28}, {51, 30}, {60, 30}}}, // 34
1803 {2876, [4]qrBlockInfo{{19, 30}, {38, 28}, {53, 30}, {63, 30}}}, // 35
1804 {3034, [4]qrBlockInfo{{20, 30}, {40, 28}, {56, 30}, {66, 30}}}, // 36
1805 {3196, [4]qrBlockInfo{{21, 30}, {43, 28}, {59, 30}, {70, 30}}}, // 37
1806 {3362, [4]qrBlockInfo{{22, 30}, {45, 28}, {62, 30}, {74, 30}}}, // 38
1807 {3532, [4]qrBlockInfo{{24, 30}, {47, 28}, {65, 30}, {77, 30}}}, // 39
1808 {3706, [4]qrBlockInfo{{25, 30}, {49, 28}, {68, 30}, {81, 30}}}, // 40
1809 }
1810 qrAlignPositions = [41][]int32{
1811 {}, // 0 unused
1812 {}, // 1
1813 {6, 18}, // 2
1814 {6, 22}, // 3
1815 {6, 26}, // 4
1816 {6, 30}, // 5
1817 {6, 34}, // 6
1818 {6, 22, 38}, // 7
1819 {6, 24, 42}, // 8
1820 {6, 26, 46}, // 9
1821 {6, 28, 50}, // 10
1822 {6, 30, 54}, // 11
1823 {6, 32, 58}, // 12
1824 {6, 34, 62}, // 13
1825 {6, 26, 46, 66}, // 14
1826 {6, 26, 48, 70}, // 15
1827 {6, 26, 50, 74}, // 16
1828 {6, 30, 54, 78}, // 17
1829 {6, 30, 56, 82}, // 18
1830 {6, 30, 58, 86}, // 19
1831 {6, 34, 62, 90}, // 20
1832 {6, 28, 50, 72, 94}, // 21
1833 {6, 26, 50, 74, 98}, // 22
1834 {6, 30, 54, 78, 102}, // 23
1835 {6, 28, 54, 80, 106}, // 24
1836 {6, 32, 58, 84, 110}, // 25
1837 {6, 30, 58, 86, 114}, // 26
1838 {6, 34, 62, 90, 118}, // 27
1839 {6, 26, 50, 74, 98, 122}, // 28
1840 {6, 30, 54, 78, 102, 126}, // 29
1841 {6, 26, 52, 78, 104, 130}, // 30
1842 {6, 30, 56, 82, 108, 134}, // 31
1843 {6, 34, 60, 86, 112, 138}, // 32
1844 {6, 30, 58, 86, 114, 142}, // 33
1845 {6, 34, 62, 90, 118, 146}, // 34
1846 {6, 30, 54, 78, 102, 126, 150}, // 35
1847 {6, 24, 50, 76, 102, 128, 154}, // 36
1848 {6, 28, 54, 80, 106, 132, 158}, // 37
1849 {6, 32, 58, 84, 110, 136, 162}, // 38
1850 {6, 26, 54, 82, 110, 138, 166}, // 39
1851 {6, 30, 58, 86, 114, 142, 170}, // 40
1852 }
1853 }
1854
1855 func main() {
1856 initAppGlobals()
1857
1858 initState()
1859 dom.ConsoleLog("starting musiquay " | version)
1860 dom.FetchText("/__version", func(body string) {
1861 if body == "" {
1862 return
1863 }
1864 sv := helpers.JsonGetString(body, "v")
1865 if sv == "" {
1866 return
1867 }
1868 sbase := sv
1869 for i := 0; i < len(sv); i++ {
1870 if sv[i] == '+' {
1871 sbase = sv[:i]
1872 break
1873 }
1874 }
1875 cbase := version
1876 if len(cbase) > 0 && cbase[0] == 'v' {
1877 cbase = cbase[1:]
1878 }
1879 if sbase != cbase {
1880 dom.ConsoleLog("version mismatch: local=" | version | " server=" | sv)
1881 }
1882 })
1883 initLang()
1884 loadRelayPolicy()
1885 // Always add the host relay as first fallback - it's the musiquay relay serving this page.
1886 localRelay := localRelayURL()
1887 appSt.defaultRelays = []string{localRelay} | appSt.defaultRelays
1888 dom.ConsoleLog("local relay: " | localRelay)
1889 appSt.blossomServerURL = localstorage.GetItem(lsKeyBlossomServer)
1890 appSt.invidiousURL = localstorage.GetItem(lsKeyInvidiousURL)
1891 appSt.campionURL = localstorage.GetItem(lsKeyCampionURL)
1892 appSt.devMode = localstorage.GetItem(lsKeyDevMode) == "1"
1893 themePref := localstorage.GetItem(lsKeyTheme)
1894 if themePref != "" {
1895 appSt.isDark = themePref == "dark"
1896 } else {
1897 appSt.isDark = true
1898 }
1899 htmlEl := dom.QuerySelector("html")
1900 if appSt.isDark {
1901 dom.AddClass(htmlEl, "dark")
1902 } else {
1903 dom.RemoveClass(htmlEl, "dark")
1904 }
1905 appSt.root = dom.GetElementById("app-root")
1906 dom.SetInnerHTML(appSt.root, "")
1907 dom.SetAttribute(appSt.root, "data-version", version)
1908 savedPK := localstorage.GetItem(lsKeyPubkey)
1909 if savedPK != "" {
1910 restoreSession(savedPK, 24)
1911 } else {
1912 showLogin()
1913 }
1914 }
1915
1916 // --- Theme ---
1917
1918 func toggleTheme() {
1919 el := dom.QuerySelector("html")
1920 appSt.isDark = !appSt.isDark
1921 if appSt.isDark {
1922 dom.AddClass(el, "dark")
1923 localstorage.SetItem(lsKeyTheme, "dark")
1924 } else {
1925 dom.RemoveClass(el, "dark")
1926 localstorage.SetItem(lsKeyTheme, "light")
1927 }
1928 updateThemeIcon()
1929 }
1930
1931 // logActivity appends an entry to the activity log, newest first.
1932 //
1933 // The log is a fixed ring presized once to activityLogMax. The obvious form -
1934 // activityLog = []*logEntry{e} | activityLog - allocated a fresh
1935 // activityLogMax+1 entry backing array on EVERY entry and abandoned the
1936 // previous one in whatever arena was current, which at a wasm dispatch entry is
1937 // the root arena. Nothing reclaims a root-arena allocation: arenas release
1938 // whole frames and a package-level slice lives for the process, so a boot that
1939 // logged its few hundred steps leaked a few megabytes and kept leaking for the
1940 // life of the tab. The size is known, so the buffer is presized and written
1941 // through a cursor instead of ever entering the growth regime.
1942 func logActivity(action, summary, detail string) {
1943 e := &logEntry{
1944 ts: dom.NowSeconds(),
1945 action: action,
1946 summary: summary,
1947 detail: detail,
1948 }
1949 if appSt.activityLog == nil {
1950 appSt.activityLog = []*logEntry{:activityLogMax}
1951 }
1952 appSt.activityLog[appSt.activityLogNext%activityLogMax] = e
1953 appSt.activityLogNext++
1954 if appSt.logListEl != 0 {
1955 prependLogEntry(e)
1956 }
1957 }
1958
1959 // activityLogLen is the number of live entries, capped at activityLogMax.
1960 func activityLogLen() (n int32) {
1961 if appSt.activityLogNext > activityLogMax {
1962 return activityLogMax
1963 }
1964 return appSt.activityLogNext
1965 }
1966
1967 // activityEntry returns entry i counted from the newest (0 = newest).
1968 func activityEntry(i int32) (e *logEntry) {
1969 return appSt.activityLog[(appSt.activityLogNext-1-i)%activityLogMax]
1970 }
1971
1972 // updateLogBtnHighlight sets the log button color: accent while boot is in
1973 // progress, muted otherwise.
1974 func updateLogBtnHighlight() {
1975 if appSt.logBtn == 0 {
1976 return
1977 }
1978 if appSt.bootInProgress {
1979 dom.SetStyle(appSt.logBtn, "color", "var(--accent)")
1980 } else {
1981 dom.SetStyle(appSt.logBtn, "color", "var(--muted)")
1982 }
1983 }
1984
1985 // bootDone marks bootstrap as complete and transitions the UI from the
1986 // activity log page to the feed. Idempotent - extra calls are no-ops.
1987 func bootDone() {
1988 if !appSt.bootInProgress {
1989 return
1990 }
1991 appSt.bootInProgress = false
1992 logActivity("boot", "bootstrap complete", "")
1993 updateLogBtnHighlight()
1994 if appSt.activePage == "log" {
1995 switchPage("feed")
1996 }
1997 }
1998
1999 // formatHMS returns HH:MM:SS local time for unix seconds ts.
2000 func formatHMS(ts int64) (s string) {
2001 off := int64(dom.TimezoneOffsetSeconds())
2002 local := ts + off
2003 if local < 0 {
2004 local = 0
2005 }
2006 day := local % 86400
2007 h := day / 3600
2008 m := (day % 3600) / 60
2009 sec := day % 60
2010 pad := func(v int64) string {
2011 if v < 10 {
2012 return "0" | i64toa(v)
2013 }
2014 return i64toa(v)
2015 }
2016 return pad(h) | ":" | pad(m) | ":" | pad(sec)
2017 }
2018
2019 // prependLogEntry inserts a single log entry row at the top of logListEl.
2020 // The row shows "HH:MM:SS [action] summary" and toggles detail on click.
2021 func prependLogEntry(e *logEntry) {
2022 row := dom.CreateElement("div")
2023 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
2024 dom.SetStyle(row, "padding", "4px 0")
2025
2026 header := dom.CreateElement("div")
2027 dom.SetStyle(header, "cursor", "pointer")
2028 dom.SetStyle(header, "display", "flex")
2029 dom.SetStyle(header, "gap", "8px")
2030 dom.SetStyle(header, "alignItems", "baseline")
2031
2032 tsSpan := dom.CreateElement("span")
2033 dom.SetTextContent(tsSpan, formatHMS(e.ts))
2034 dom.SetStyle(tsSpan, "color", "var(--muted)")
2035 dom.SetStyle(tsSpan, "flexShrink", "0")
2036 dom.AppendChild(header, tsSpan)
2037
2038 actSpan := dom.CreateElement("span")
2039 dom.SetTextContent(actSpan, "[" | e.action | "]")
2040 dom.SetStyle(actSpan, "color", "var(--accent)")
2041 dom.SetStyle(actSpan, "flexShrink", "0")
2042 dom.AppendChild(header, actSpan)
2043
2044 sumSpan := dom.CreateElement("span")
2045 dom.SetTextContent(sumSpan, e.summary)
2046 dom.SetStyle(sumSpan, "color", "var(--fg)")
2047 dom.SetStyle(sumSpan, "overflow", "hidden")
2048 dom.SetStyle(sumSpan, "textOverflow", "ellipsis")
2049 dom.SetStyle(sumSpan, "whiteSpace", "nowrap")
2050 dom.AppendChild(header, sumSpan)
2051
2052 dom.AppendChild(row, header)
2053
2054 if e.detail != "" {
2055 detailEl := dom.CreateElement("pre")
2056 dom.SetTextContent(detailEl, e.detail)
2057 dom.SetStyle(detailEl, "display", "none")
2058 dom.SetStyle(detailEl, "margin", "4px 0 0 0")
2059 dom.SetStyle(detailEl, "padding", "6px 8px")
2060 dom.SetStyle(detailEl, "background", "var(--bg2)")
2061 dom.SetStyle(detailEl, "color", "var(--muted)")
2062 dom.SetStyle(detailEl, "fontSize", "11px")
2063 dom.SetStyle(detailEl, "whiteSpace", "pre-wrap")
2064 dom.SetStyle(detailEl, "wordBreak", "break-all")
2065 dom.SetStyle(detailEl, "borderRadius", "3px")
2066 dom.AppendChild(row, detailEl)
2067 open := false
2068 dom.AddEventListener(header, "click", dom.RegisterCallback(func() {
2069 open = !open
2070 if open {
2071 dom.SetStyle(detailEl, "display", "block")
2072 } else {
2073 dom.SetStyle(detailEl, "display", "none")
2074 }
2075 }))
2076 }
2077
2078 // Insert at top of list.
2079 first := dom.FirstChild(appSt.logListEl)
2080 if first == 0 {
2081 dom.AppendChild(appSt.logListEl, row)
2082 } else {
2083 dom.InsertBefore(appSt.logListEl, row, first)
2084 }
2085 }
2086
2087 const svgSun = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="4" stroke="currentColor" stroke-width="1.5"/><path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.3 3.3l1.4 1.4M13.3 13.3l1.4 1.4M3.3 14.7l1.4-1.4M13.3 4.7l1.4-1.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`
2088 const svgMoon = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M15.1 10.4A6.5 6.5 0 0 1 7.6 2.9 6.5 6.5 0 1 0 15.1 10.4z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
2089
2090 func updateThemeIcon() {
2091 if appSt.themeBtn == 0 {
2092 return // not yet created (login screen)
2093 }
2094 if appSt.isDark {
2095 dom.SetInnerHTML(appSt.themeBtn, svgSun)
2096 } else {
2097 dom.SetInnerHTML(appSt.themeBtn, svgMoon)
2098 }
2099 }
2100
2101 // --- Login screen ---
2102
2103 func isMobile() (ok bool) {
2104 ua := dom.UserAgent()
2105 n := len(ua)
2106 for i := 0; i+7 <= n; i++ {
2107 if ua[i:i+7] == "Android" {
2108 return true
2109 }
2110 }
2111 for i := 0; i+6 <= n; i++ {
2112 if ua[i:i+6] == "Mobile" {
2113 return true
2114 }
2115 }
2116 return false
2117 }
2118
2119 func showAboutModal() {
2120 switchPage("about")
2121 }
2122
2123 // restoreSession resumes the saved identity once the signer worker is up and
2124 // the vault is unlocked. The signer is a worker that boots asynchronously, so
2125 // a single check races it and used to discard the saved pubkey outright,
2126 // leaving the app on the login screen even when the vault was about to be
2127 // usable. The saved key is kept: a later unlock can still resume the session.
2128 func restoreSession(savedPK string, tries int32) {
2129 if tries <= 0 {
2130 showLogin()
2131 return
2132 }
2133 initSigner()
2134 signer.IsInstalled(func(ok bool) {
2135 if !ok {
2136 dom.SetTimeout(func() { restoreSession(savedPK, tries-1) }, 250)
2137 return
2138 }
2139 signer.GetVaultStatus(func(status string) {
2140 if status != "unlocked" {
2141 dom.SetTimeout(func() { restoreSession(savedPK, tries-1) }, 250)
2142 return
2143 }
2144 signer.SwitchIdentity(savedPK, func(ok bool) {
2145 if !ok {
2146 showLogin()
2147 return
2148 }
2149 appSt.pubhex = savedPK
2150 bootstrapEncKey(func() {
2151 showApp()
2152 })
2153 })
2154 })
2155 })
2156 }
2157
2158 func showLogin() {
2159 clearChildren(appSt.root)
2160
2161 // Allow scrolling on login page (body has overflow:hidden for the app).
2162 dom.SetStyle(dom.Body(), "overflow", "auto")
2163
2164 wrap := dom.CreateElement("div")
2165 dom.SetStyle(wrap, "display", "flex")
2166 dom.SetStyle(wrap, "alignItems", "center")
2167 dom.SetStyle(wrap, "flexDirection", "column")
2168 dom.SetStyle(wrap, "padding", "48px 16px")
2169
2170 // musiquay logo.
2171 logoDiv := dom.CreateElement("div")
2172 dom.SetStyle(logoDiv, "width", "180px")
2173 dom.SetStyle(logoDiv, "height", "180px")
2174 dom.SetStyle(logoDiv, "marginBottom", "16px")
2175 dom.SetStyle(logoDiv, "color", "var(--accent)")
2176 dom.FetchText("/musiquay-logo.svg", func(svg string) {
2177 dom.SetInnerHTML(logoDiv, svg)
2178 svgEl := dom.FirstElementChild(logoDiv)
2179 if svgEl != 0 {
2180 dom.SetAttribute(svgEl, "width", "100%")
2181 dom.SetAttribute(svgEl, "height", "100%")
2182 }
2183 })
2184 dom.AppendChild(wrap, logoDiv)
2185
2186 // Title.
2187 h1 := dom.CreateElement("h1")
2188 dom.SetTextContent(h1, "MUSIQUAY")
2189 dom.SetStyle(h1, "color", "var(--accent)")
2190 dom.SetStyle(h1, "fontSize", "48px")
2191 dom.SetStyle(h1, "marginBottom", "4px")
2192 dom.AppendChild(wrap, h1)
2193
2194 verTag := dom.CreateElement("span")
2195 dom.SetTextContent(verTag, version)
2196 dom.SetStyle(verTag, "color", "var(--muted)")
2197 dom.SetStyle(verTag, "fontSize", "12px")
2198 dom.AppendChild(wrap, verTag)
2199
2200 sub := dom.CreateElement("p")
2201 dom.SetStyle(sub, "color", "var(--muted)")
2202 dom.SetStyle(sub, "fontSize", "14px")
2203 dom.SetStyle(sub, "marginBottom", "32px")
2204 dom.SetStyle(sub, "textAlign", "center")
2205 dom.SetTextContent(sub, t("subtitle"))
2206 dom.AppendChild(wrap, sub)
2207
2208 // Show login flow.
2209 errEl := dom.CreateElement("div")
2210 dom.SetStyle(errEl, "color", "#e55")
2211 dom.SetStyle(errEl, "fontSize", "13px")
2212 dom.SetStyle(errEl, "marginBottom", "12px")
2213 dom.SetStyle(errEl, "minHeight", "18px")
2214 dom.AppendChild(wrap, errEl)
2215
2216 btn := dom.CreateElement("button")
2217 dom.SetTextContent(btn, t("login"))
2218 dom.SetAttribute(btn, "type", "button")
2219 dom.SetStyle(btn, "padding", "10px 32px")
2220 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
2221 dom.SetStyle(btn, "fontSize", "14px")
2222 dom.SetStyle(btn, "background", "var(--accent)")
2223 dom.SetStyle(btn, "color", "#fff")
2224 dom.SetStyle(btn, "border", "none")
2225 dom.SetStyle(btn, "borderRadius", "4px")
2226 dom.SetStyle(btn, "cursor", "pointer")
2227 dom.AppendChild(wrap, btn)
2228
2229
2230 appendLoginFooter(wrap)
2231 dom.SetStyle(wrap, "paddingBottom", "48px")
2232 dom.AppendChild(appSt.root, wrap)
2233
2234 buildStatusBar(false)
2235 initSigner()
2236
2237 // Auto-login: if signer is ready with exactly one usable identity, skip the login page.
2238 signer.IsInstalled(func(ok bool) {
2239 if !ok {
2240 return
2241 }
2242 signer.GetVaultStatus(func(status string) {
2243 if status != "unlocked" {
2244 return
2245 }
2246 signer.GetPublicKey(func(hex string) {
2247 if hex == "" {
2248 return
2249 }
2250 dom.ConsoleLog("[login] auto-login pubkey: " | hex)
2251 appSt.pubhex = hex
2252 localstorage.SetItem(lsKeyPubkey, appSt.pubhex)
2253 clearChildren(appSt.root)
2254 bootstrapEncKey(func() {
2255 showApp()
2256 })
2257 })
2258 })
2259 })
2260
2261 cb := dom.RegisterCallback(func() {
2262 signer.GetVaultStatus(func(status string) {
2263 if status == "none" || status == "locked" {
2264 toggleSignerPanel()
2265 return
2266 }
2267 dom.SetTextContent(btn, t("requesting"))
2268 signer.GetPublicKey(func(hex string) {
2269 if hex == "" {
2270 dom.SetTextContent(errEl, t("err_no_id"))
2271 dom.SetTextContent(btn, t("login"))
2272 toggleSignerPanel()
2273 return
2274 }
2275 dom.ConsoleLog("[login] manual-login pubkey: " | hex)
2276 appSt.pubhex = hex
2277 localstorage.SetItem(lsKeyPubkey, appSt.pubhex)
2278 clearChildren(appSt.root)
2279 bootstrapEncKey(func() {
2280 showApp()
2281 })
2282 })
2283 })
2284 })
2285 dom.AddEventListener(btn, "click", cb)
2286 }
2287
2288 func appendLoginFooter(wrap dom.Element) {
2289 footer := dom.CreateElement("div")
2290 dom.SetStyle(footer, "display", "flex")
2291 dom.SetStyle(footer, "flexDirection", "column")
2292 dom.SetStyle(footer, "gap", "12px")
2293 dom.SetStyle(footer, "marginTop", "24px")
2294 dom.SetStyle(footer, "alignItems", "center")
2295
2296 // Language chooser.
2297 langBox := dom.CreateElement("div")
2298 dom.SetStyle(langBox, "display", "flex")
2299 dom.SetStyle(langBox, "alignItems", "center")
2300 dom.SetStyle(langBox, "gap", "6px")
2301
2302 langLabel := dom.CreateElement("span")
2303 dom.SetTextContent(langLabel, t("language"))
2304 dom.SetStyle(langLabel, "fontSize", "12px")
2305 dom.SetStyle(langLabel, "color", "var(--muted)")
2306 dom.AppendChild(langBox, langLabel)
2307
2308 langSel := dom.CreateElement("select")
2309 dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
2310 dom.SetStyle(langSel, "fontSize", "12px")
2311 dom.SetStyle(langSel, "background", "var(--bg2)")
2312 dom.SetStyle(langSel, "color", "var(--fg)")
2313 dom.SetStyle(langSel, "border", "1px solid var(--border)")
2314 dom.SetStyle(langSel, "borderRadius", "4px")
2315 dom.SetStyle(langSel, "padding", "4px 8px")
2316 for code, name := range langNames {
2317 opt := dom.CreateElement("option")
2318 dom.SetAttribute(opt, "value", code)
2319 dom.SetTextContent(opt, name)
2320 if code == appSt.currentLang {
2321 dom.SetAttribute(opt, "selected", "selected")
2322 }
2323 dom.AppendChild(langSel, opt)
2324 }
2325 dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
2326 val := dom.GetProperty(langSel, "value")
2327 setLang(val)
2328 showLogin() // re-render with new language
2329 }))
2330 dom.AppendChild(langBox, langSel)
2331 dom.AppendChild(footer, langBox)
2332
2333 // Theme toggle.
2334 themeBox := dom.CreateElement("div")
2335 dom.SetStyle(themeBox, "display", "flex")
2336 dom.SetStyle(themeBox, "alignItems", "center")
2337 dom.SetStyle(themeBox, "gap", "6px")
2338
2339 themeLabel := dom.CreateElement("span")
2340 dom.SetTextContent(themeLabel, t("theme"))
2341 dom.SetStyle(themeLabel, "fontSize", "12px")
2342 dom.SetStyle(themeLabel, "color", "var(--muted)")
2343 dom.AppendChild(themeBox, themeLabel)
2344
2345 themeToggle := dom.CreateElement("button")
2346 if appSt.isDark {
2347 dom.SetTextContent(themeToggle, t("light"))
2348 } else {
2349 dom.SetTextContent(themeToggle, t("dark"))
2350 }
2351 dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
2352 dom.SetStyle(themeToggle, "fontSize", "12px")
2353 dom.SetStyle(themeToggle, "background", "var(--bg2)")
2354 dom.SetStyle(themeToggle, "color", "var(--fg)")
2355 dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
2356 dom.SetStyle(themeToggle, "borderRadius", "4px")
2357 dom.SetStyle(themeToggle, "padding", "4px 12px")
2358 dom.SetStyle(themeToggle, "cursor", "pointer")
2359 dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
2360 toggleTheme()
2361 if appSt.isDark {
2362 dom.SetTextContent(themeToggle, t("light"))
2363 } else {
2364 dom.SetTextContent(themeToggle, t("dark"))
2365 }
2366 }))
2367 dom.AppendChild(themeBox, themeToggle)
2368 dom.AppendChild(footer, themeBox)
2369
2370 // Hard refresh button - clears SW cache and reloads.
2371 optBox := dom.CreateElement("div")
2372 dom.SetStyle(optBox, "display", "flex")
2373 dom.SetStyle(optBox, "alignItems", "center")
2374 dom.SetStyle(optBox, "gap", "4px")
2375 refreshBtnw1 := dom.CreateElement("button")
2376 dom.SetTextContent(refreshBtnw1, "\u21bb")
2377 dom.SetStyle(refreshBtnw1, "fontSize", "14px")
2378 dom.SetStyle(refreshBtnw1, "background", "transparent")
2379 dom.SetStyle(refreshBtnw1, "border", "1px solid var(--border)")
2380 dom.SetStyle(refreshBtnw1, "borderRadius", "4px")
2381 dom.SetStyle(refreshBtnw1, "color", "var(--muted)")
2382 dom.SetStyle(refreshBtnw1, "cursor", "pointer")
2383 dom.SetStyle(refreshBtnw1, "padding", "2px 8px")
2384 dom.SetStyle(refreshBtnw1, "marginLeft", "4px")
2385 dom.AddEventListener(refreshBtnw1, "click", dom.RegisterCallback(func() {
2386 dom.HardRefresh()
2387 }))
2388 dom.AppendChild(optBox, refreshBtnw1)
2389
2390 dom.AppendChild(footer, optBox)
2391
2392 // Dev mode toggle - disables translation worker.
2393 devBox := dom.CreateElement("div")
2394 dom.SetStyle(devBox, "display", "flex")
2395 dom.SetStyle(devBox, "alignItems", "center")
2396 dom.SetStyle(devBox, "gap", "6px")
2397
2398 devLabel := dom.CreateElement("span")
2399 dom.SetTextContent(devLabel, "dev mode")
2400 dom.SetStyle(devLabel, "fontSize", "12px")
2401 dom.SetStyle(devLabel, "color", "var(--muted)")
2402 dom.AppendChild(devBox, devLabel)
2403
2404 devToggle := dom.CreateElement("button")
2405 if appSt.devMode {
2406 dom.SetTextContent(devToggle, "on")
2407 dom.SetStyle(devToggle, "color", "var(--accent)")
2408 } else {
2409 dom.SetTextContent(devToggle, "off")
2410 dom.SetStyle(devToggle, "color", "var(--muted)")
2411 }
2412 dom.SetStyle(devToggle, "fontFamily", "'Fira Code', monospace")
2413 dom.SetStyle(devToggle, "fontSize", "12px")
2414 dom.SetStyle(devToggle, "background", "var(--bg2)")
2415 dom.SetStyle(devToggle, "border", "1px solid var(--border)")
2416 dom.SetStyle(devToggle, "borderRadius", "4px")
2417 dom.SetStyle(devToggle, "padding", "4px 12px")
2418 dom.SetStyle(devToggle, "cursor", "pointer")
2419 dom.AddEventListener(devToggle, "click", dom.RegisterCallback(func() {
2420 appSt.devMode = !appSt.devMode
2421 if appSt.devMode {
2422 localstorage.SetItem(lsKeyDevMode, "1")
2423 dom.SetTextContent(devToggle, "on")
2424 dom.SetStyle(devToggle, "color", "var(--accent)")
2425 } else {
2426 localstorage.RemoveItem(lsKeyDevMode)
2427 dom.SetTextContent(devToggle, "off")
2428 dom.SetStyle(devToggle, "color", "var(--muted)")
2429 }
2430 }))
2431 dom.AppendChild(devBox, devToggle)
2432 dom.AppendChild(footer, devBox)
2433
2434 dom.AppendChild(wrap, footer)
2435 }
2436
2437 // --- Sidebar ---
2438
2439 const svgCompose = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M12.3 2.3l3.4 3.4L6 15.4l-4 1 1-4L12.3 2.3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10.5 4.1l3.4 3.4" stroke="currentColor" stroke-width="1.5"/></svg>`
2440 const svgFeed = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M3 3h12M6 7h9M6 11h9M3 15h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`
2441 const svgChat = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 3h14v9H10l-2 3-2-3H2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`
2442 const svgGear = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M7.5 2h3l.4 2.1a5.5 5.5 0 0 1 1.3.7L14.3 4l1.5 2.6-1.7 1.4a5.6 5.6 0 0 1 0 1.5l1.7 1.4-1.5 2.6-2.1-.8a5.5 5.5 0 0 1-1.3.7L10.5 16h-3l-.4-2.1a5.5 5.5 0 0 1-1.3-.7L3.7 14l-1.5-2.6 1.7-1.4a5.6 5.6 0 0 1 0-1.5L2.2 7.1 3.7 4.5l2.1.8a5.5 5.5 0 0 1 1.3-.7L7.5 2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><circle cx="9" cy="9.3" r="2" stroke="currentColor" stroke-width="1.3"/></svg>`
2443 const svgBell = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M13.5 6.5a4.5 4.5 0 1 0-9 0c0 5-2 6.5-2 6.5h13s-2-1.5-2-6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.8 15a1.5 1.5 0 0 0 2.4 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
2444
2445 // Action button SVGs - same stroke style as sidebar icons.
2446 const svgReply = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M2 3h14v9H10l-2 3-2-3H2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`
2447 const svgQuote = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M2 3h14v9H10l-2 3-2-3H2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="6" cy="7.5" r="0.8" fill="currentColor"/><circle cx="9" cy="7.5" r="0.8" fill="currentColor"/><circle cx="12" cy="7.5" r="0.8" fill="currentColor"/></svg>`
2448 const svgRepost = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M12.5 3l2.5 2.5-2.5 2.5M5.5 15l-2.5-2.5 2.5-2.5M15 5.5H6M3 12.5h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
2449 const svgReact = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.5"/><circle cx="6.5" cy="7.5" r="0.8" fill="currentColor"/><circle cx="11.5" cy="7.5" r="0.8" fill="currentColor"/><path d="M6 11a3.5 3.5 0 0 0 6 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`
2450 const svgZap = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M12 1L4 10h4l-2 7 8-9h-4l2-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" fill="none"/></svg>`
2451
2452 func makeSidebarIcon(svgHTML string, active bool) (e dom.Element) {
2453 btn := dom.CreateElement("button")
2454 dom.SetStyle(btn, "width", "36px")
2455 dom.SetStyle(btn, "height", "36px")
2456 dom.SetStyle(btn, "border", "none")
2457 dom.SetStyle(btn, "borderRadius", "6px")
2458 dom.SetStyle(btn, "cursor", "pointer")
2459 dom.SetStyle(btn, "display", "flex")
2460 dom.SetStyle(btn, "alignItems", "center")
2461 dom.SetStyle(btn, "justifyContent", "center")
2462 dom.SetStyle(btn, "padding", "0")
2463 dom.SetStyle(btn, "color", "var(--fg)")
2464 if active {
2465 dom.SetStyle(btn, "background", "var(--accent)")
2466 dom.SetStyle(btn, "color", "#fff")
2467 } else {
2468 dom.SetStyle(btn, "background", "transparent")
2469 }
2470 dom.SetInnerHTML(btn, svgHTML)
2471 return btn
2472 }
2473
2474 // teardownPage sends CLOSE for page-specific subscriptions on page exit.
2475 // stopInlineMedia restores the clickable YouTube thumbnail for any active
2476 // Invidious inline embeds. The wrapper's original click handler (registered by
2477 // attachYTLightboxes) is still in place so tapping the thumbnail re-embeds.
2478 func stopInlineMedia(container dom.Element) {
2479 el := dom.QuerySelectorFrom(container, "[data-inv-ytid]")
2480 for el != 0 {
2481 ytID := dom.GetAttribute(el, "data-inv-ytid")
2482 dom.RemoveAttribute(el, "data-inv-ytid")
2483
2484 // Clear iframe + controls.
2485 dom.ReleaseChildren(el)
2486 dom.SetInnerHTML(el, "")
2487
2488 // Restore the YouTube thumbnail so the user can tap to re-embed.
2489 if ytID != "" {
2490 thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
2491 img := dom.CreateElement("img")
2492 dom.SetAttribute(img, "referrerpolicy", "no-referrer")
2493 dom.SetStyle(img, "display", "block")
2494 dom.SetStyle(img, "maxWidth", "100%")
2495 dom.SetStyle(img, "borderRadius", "8px")
2496 dom.SetStyle(img, "objectFit", "contain")
2497 setMediaSrc(img, thumbURL)
2498 dom.AppendChild(el, img)
2499 }
2500 dom.SetStyle(el, "cursor", "pointer")
2501
2502 el = dom.QuerySelectorFrom(container, "[data-inv-ytid]")
2503 }
2504 }
2505
2506 func teardownPage(page string) {
2507 if page == "feed" {
2508 stopInlineMedia(appSt.feedContainer)
2509 stopInlineMedia(appSt.threadContainer)
2510 }
2511 if page == "search" {
2512 routeMsg("[\"CLOSE\",\"search\"]")
2513 appSt.searchSeen = map[string]bool{}
2514 // Always restore topBarRight and other hidden elements on exit.
2515 if appSt.searchBarActive {
2516 deactivateSearchBar(appSt.searchBtnGlobal)
2517 }
2518 }
2519 if page == "notifications" {
2520 // Cancel pending timers so their closures don't fire after page exit.
2521 if appSt.notifMoreTimer != 0 {
2522 dom.ClearTimeout(appSt.notifMoreTimer)
2523 appSt.notifMoreTimer = 0
2524 }
2525 if appSt.notifRefTimer != 0 {
2526 dom.ClearTimeout(appSt.notifRefTimer)
2527 appSt.notifRefTimer = 0
2528 }
2529 // Release accumulated notif row callbacks to prevent indefinite
2530 // growth of the Moxie callback table across multiple page visits.
2531 releaseNotifRowCBs()
2532 appSt.notifBuilt = false
2533 // Clear avatar pending queue; the DOM elements are gone.
2534 for pk := range appSt.pendingNotifAvatars {
2535 delete(appSt.pendingNotifAvatars, pk)
2536 }
2537 }
2538 }
2539
2540 func switchPage(name string) {
2541 closeFeedPopover()
2542 // Always close thread if open, even when re-selecting the feed tab.
2543 if appSt.threadOpen {
2544 closeNoteThread()
2545 if !appSt.navPop {
2546 dom.ReplaceState("/")
2547 }
2548 }
2549 if name == appSt.activePage {
2550 return
2551 }
2552 teardownPage(appSt.activePage)
2553 closeProfileNoteSub()
2554 if appSt.activePage == "profile" {
2555 appSt.profileViewPK = ""
2556 }
2557
2558 appSt.activePage = name
2559
2560 // Ensure top bar is in normal state.
2561 dom.SetStyle(appSt.topBackBtn, "display", "none")
2562 dom.SetStyle(appSt.feedSelectEl, "display", "none")
2563 dom.SetStyle(appSt.pageTitleEl, "display", "none")
2564 dom.SetStyle(appSt.notifBarGroup, "display", "none")
2565 if name == "feed" {
2566 dom.SetStyle(appSt.feedSelectEl, "display", "inline")
2567 } else if name == "home" {
2568 // The shell's empty page carries no title.
2569 } else if name == "notifications" {
2570 dom.SetStyle(appSt.notifBarGroup, "display", "flex")
2571 } else {
2572 dom.SetTextContent(appSt.pageTitleEl, t(name))
2573 dom.SetStyle(appSt.pageTitleEl, "display", "inline")
2574 }
2575
2576 // Hide all pages.
2577 dom.SetStyle(appSt.homePage, "display", "none")
2578 dom.SetStyle(appSt.feedPage, "display", "none")
2579 dom.SetStyle(appSt.msgPage, "display", "none")
2580 dom.SetStyle(appSt.profilePage, "display", "none")
2581 dom.SetStyle(appSt.settingsPage, "display", "none")
2582 dom.SetStyle(appSt.composePage, "display", "none")
2583 dom.SetStyle(appSt.aboutPage, "display", "none")
2584 dom.SetStyle(appSt.notifPage, "display", "none")
2585 dom.SetStyle(appSt.searchPage, "display", "none")
2586 if appSt.logPage != 0 {
2587 dom.SetStyle(appSt.logPage, "display", "none")
2588 }
2589
2590 // Clear sidebar highlights.
2591 dom.SetStyle(appSt.sidebarCompose, "background", "transparent")
2592 dom.SetStyle(appSt.sidebarCompose, "color", "var(--fg)")
2593 dom.SetStyle(appSt.sidebarFeed, "background", "transparent")
2594 dom.SetStyle(appSt.sidebarFeed, "color", "var(--fg)")
2595 if appSt.sidebarMsg != 0 {
2596 dom.SetStyle(appSt.sidebarMsg, "background", "transparent")
2597 dom.SetStyle(appSt.sidebarMsg, "color", "var(--fg)")
2598 }
2599 dom.SetStyle(appSt.sidebarNotif, "background", "transparent")
2600 dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)")
2601 dom.SetStyle(appSt.sidebarSettings, "background", "transparent")
2602 dom.SetStyle(appSt.sidebarSettings, "color", "var(--fg)")
2603
2604 switch name {
2605 case "home":
2606 dom.SetStyle(appSt.homePage, "display", "block")
2607 if !appSt.navPop {
2608 dom.PushState("/")
2609 }
2610 case "compose", "feed", "search":
2611 // The kind-1 forum pages are parked in this base: compose, feed, note
2612 // threads and search all land on the empty home page. Their page code
2613 // (composePage, feedPage, searchPage and the feed worker) stays in the
2614 // tree for when the forum is ported.
2615 dom.SetStyle(appSt.homePage, "display", "block")
2616 if !appSt.navPop {
2617 dom.PushState("/")
2618 }
2619 case "notifications":
2620 dom.SetStyle(appSt.notifPage, "display", "block")
2621 dom.SetStyle(appSt.sidebarNotif, "background", "var(--accent)")
2622 dom.SetStyle(appSt.sidebarNotif, "color", "#fff")
2623 appSt.notifUnread = false
2624 dom.SetStyle(appSt.notifDot, "display", "none")
2625 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
2626 if !appSt.notifBuilt {
2627 renderNotifPage()
2628 }
2629 if !appSt.navPop {
2630 dom.PushState("/notifications")
2631 }
2632 case "settings":
2633 dom.SetStyle(appSt.settingsPage, "display", "block")
2634 dom.SetStyle(appSt.sidebarSettings, "background", "var(--accent)")
2635 dom.SetStyle(appSt.sidebarSettings, "color", "#fff")
2636 renderSettings()
2637 if !appSt.navPop {
2638 dom.PushState("/settings")
2639 }
2640 case "profile":
2641 dom.SetStyle(appSt.profilePage, "display", "block")
2642 dom.SetStyle(appSt.pageTitleEl, "display", "none")
2643 dom.SetStyle(appSt.topBackBtn, "display", "inline")
2644 // Profile URL is pushed by showProfile, not here.
2645 case "about":
2646 dom.SetStyle(appSt.aboutPage, "display", "block")
2647 if !appSt.navPop {
2648 dom.PushState("/about")
2649 }
2650 case "log":
2651 dom.SetStyle(appSt.logPage, "display", "block")
2652 dom.SetTextContent(appSt.pageTitleEl, "activity log")
2653 dom.SetStyle(appSt.pageTitleEl, "display", "inline")
2654 if !appSt.navPop {
2655 dom.PushState("/log")
2656 }
2657 case "search":
2658 dom.SetStyle(appSt.searchPage, "display", "block")
2659 dom.SetStyle(appSt.pageTitleEl, "display", "none")
2660 // Restore search input value without activating the bar (topBarRight
2661 // stays visible). Bar is only activated by explicit user action.
2662 if appSt.searchCurrentQ != "" && appSt.searchBtnGlobal != 0 {
2663 dom.SetProperty(appSt.searchInputEl, "value", appSt.searchCurrentQ)
2664 }
2665 if !appSt.navPop {
2666 if appSt.searchCurrentQ != "" {
2667 dom.PushState("/search?q=" | dom.EncodeURIComponent(appSt.searchCurrentQ))
2668 } else {
2669 dom.PushState("/search")
2670 }
2671 }
2672 }
2673 }
2674
2675 // navigateToPath handles URL-based routing for back/forward and initial load.
2676 // fullPath may include a hash fragment, e.g. "/p/npub1...#follows".
2677 func navigateToPath(fullPath string) {
2678 path := fullPath
2679 hashw2 := ""
2680 for i := 0; i < len(fullPath); i++ {
2681 if fullPath[i] == '#' {
2682 path = fullPath[:i]
2683 hashw2 = fullPath[i+1:]
2684 break
2685 }
2686 }
2687
2688 // Musiquay base: every kind-1 forum route (feed and its relay/follows
2689 // modes, note threads, compose, search) lands on the empty home page. The
2690 // route parsing and the pages stay in the tree for the port.
2691 forumPath := path == "/" || path == "" || path == "/feed" ||
2692 path == "/feed/follows" || path == "/feed/relays" ||
2693 (len(path) > 12 && path[:12] == "/feed/relay/") ||
2694 (len(path) > 3 && path[:3] == "/t/") ||
2695 path == "/compose" || path == "/search" ||
2696 (len(path) >= 8 && path[:8] == "/search?")
2697 if forumPath {
2698 switchPage("home")
2699 } else if path == "/settings" {
2700 switchPage("settings")
2701 } else if path == "/notifications" {
2702 switchPage("notifications")
2703 } else if path == "/search" || (len(path) >= 8 && path[:8] == "/search?") {
2704 // Parse ?q= parameter.
2705 q := ""
2706 qi := strIndex(path, "?q=")
2707 if qi >= 0 {
2708 q = path[qi+3:]
2709 // URL-decode the query (basic: replace %20 etc.) - use raw for now.
2710 }
2711 if q != "" {
2712 executeSearch(q)
2713 } else {
2714 switchPage("search")
2715 }
2716 } else if path == "/about" {
2717 switchPage("about")
2718 } else if path == "/log" {
2719 switchPage("log")
2720 } else if len(path) > 3 && path[:3] == "/p/" {
2721 pk := npubToHex(path[3:])
2722 if pk != "" {
2723 showProfile(pk)
2724 if hashw2 != "" {
2725 selectProfileTab(hashw2, pk)
2726 }
2727 }
2728 }
2729 }
2730
2731 func npubToHex(npub string) (s string) {
2732 b := helpers.DecodeNpub(npub)
2733 if b == nil {
2734 return ""
2735 }
2736 return helpers.HexEncode(b)
2737 }
2738
2739 func initRouter() {
2740 if appSt.routerInited {
2741 return
2742 }
2743 appSt.routerInited = true
2744 dom.OnPopState(func(path string) {
2745 appSt.navPop = true
2746 navigateToPath(path)
2747 appSt.navPop = false
2748 })
2749 dom.InterceptInternalLinks(func(path string) {
2750 navigateToPath(path)
2751 })
2752
2753 // Navigate to initial URL if not root. At the root, show the home page
2754 // without pushing "/": switchPage pushes the route when navPop is false,
2755 // and pushing here would replace the URL this router is about to read -
2756 // every deep link (/settings, /notifications) landed on home.
2757 path := dom.GetPath()
2758 if path != "/" && path != "" {
2759 appSt.navPop = true
2760 navigateToPath(path)
2761 appSt.navPop = false
2762 } else {
2763 appSt.navPop = true
2764 switchPage("home")
2765 appSt.navPop = false
2766 dom.ReplaceState("/")
2767 }
2768 }
2769
2770 func makeProtoBtn(label string) (e dom.Element) {
2771 btn := dom.CreateElement("button")
2772 dom.SetTextContent(btn, label)
2773 dom.SetStyle(btn, "padding", "6px 8px")
2774 dom.SetStyle(btn, "border", "none")
2775 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
2776 dom.SetStyle(btn, "fontSize", "12px")
2777 dom.SetStyle(btn, "cursor", "default")
2778 dom.SetStyle(btn, "background", "transparent")
2779 dom.SetStyle(btn, "color", "var(--fg)")
2780 return btn
2781 }
2782
2783 // notifEventsReset presizes the notification buffer to one slot more than its
2784 // cap so that a push never has to grow it.
2785 //
2786 // The old form grew with push and trimmed by reslicing from either end, so the
2787 // backing array doubled and the old one was abandoned in whatever arena was
2788 // current - the root arena at a wasm dispatch entry, where nothing reclaims it
2789 // (arenas release whole frames and a package-level slice lives for the
2790 // process). A busy relay pushed a fresh notifEventsMax-entry array out every
2791 // notifEventsMax events, for the life of the tab. With one spare slot the push
2792 // fits, the copy-down gives the slot back, and a presized buffer never
2793 // allocates again.
2794 func notifEventsReset() {
2795 appSt.notifEvents = []*nostr.Event{:0:notifEventsMax + 1}
2796 }
2797
2798 // notifEventsTrim drops the oldest entries down to notifEventsMax in place.
2799 // The append path keeps the buffer oldest-first, so the oldest are at the front.
2800 func notifEventsTrim() {
2801 if len(appSt.notifEvents) > notifEventsMax {
2802 drop := int32(len(appSt.notifEvents)) - notifEventsMax
2803 for i := int32(0); i < notifEventsMax; i++ {
2804 appSt.notifEvents[i] = appSt.notifEvents[drop+i]
2805 }
2806 appSt.notifEvents = appSt.notifEvents[:notifEventsMax]
2807 }
2808 }
2809
2810 // notifEventsPrepend inserts ev at the front in place. This path keeps the
2811 // buffer newest-first, so the entry to drop when it is over the cap is the last
2812 // one - trimming from the front here would discard the entry just inserted. The
2813 // shift is a backward loop rather than copy so it cannot depend on copy's
2814 // overlap behaviour.
2815 func notifEventsPrepend(ev *nostr.Event) {
2816 if len(appSt.notifEvents) == cap(appSt.notifEvents) {
2817 appSt.notifEvents = appSt.notifEvents[:len(appSt.notifEvents)-1]
2818 }
2819 appSt.notifEvents = appSt.notifEvents[:len(appSt.notifEvents)+1]
2820 for i := int32(len(appSt.notifEvents)) - 1; i > 0; i-- {
2821 appSt.notifEvents[i] = appSt.notifEvents[i-1]
2822 }
2823 appSt.notifEvents[0] = ev
2824 if len(appSt.notifEvents) > notifEventsMax {
2825 appSt.notifEvents = appSt.notifEvents[:notifEventsMax]
2826 }
2827 }
2828
2829 // --- Main app ---
2830
2831 func showApp() {
2832 dom.SetStyle(dom.Body(), "overflow", "hidden")
2833 appSt.appReady = false
2834 appSt.bootInProgress = true
2835 appSt.activityLog = nil
2836 appSt.activityLogNext = 0
2837 appSt.seenOnSubs = map[string]func(){}
2838 logActivity("boot", "bootstrap start " | version, "pubkey: " | appSt.pubhex)
2839 // Preload NWC connections so zap button can offer NWC path without
2840 // requiring a settings-page visit.
2841 nwcRefreshList(nil)
2842 // Init profile view caches and filter sets.
2843 appSt.authorNames = map[string]string{}
2844 appSt.authorPics = map[string]string{}
2845 appSt.pendingNotes = map[string][]dom.Element{}
2846 appSt.pendingNotifAvatars = map[string][]dom.Element{}
2847 appSt.seenEvents = map[string]bool{}
2848 appSt.fetchedK0 = map[string]bool{}
2849 appSt.fetchedK0Old = map[string]bool{}
2850 appSt.fetchedK10k = map[string]bool{}
2851 appSt.fetchedK10kOld = map[string]bool{}
2852 appSt.retryRound = 0
2853 appSt.authorSubPK = map[string]string{}
2854 appSt.eventRelays = map[string][]string{}
2855 appSt.eventRelaysOld = map[string][]string{}
2856 appSt.pendingDeletes = map[string]string{}
2857 appSt.noteElements = map[string]dom.Element{}
2858 appSt.myFollows = nil
2859 appSt.myMutes = nil
2860 appSt.followSet = map[string]bool{}
2861 appSt.muteSet = map[string]bool{}
2862 appSt.profileContentCache = map[string]string{}
2863 appSt.profileFollowsCache = map[string][]string{}
2864 appSt.profileMutesCache = map[string][]string{}
2865 appSt.profileRelaysCache = map[string][]string{}
2866 appSt.profileNotesSeen = map[string]bool{}
2867 appSt.profileTabBtns = map[string]dom.Element{}
2868 appSt.profileWrappers = map[string]dom.Element{}
2869 appSt.profileWrapTabCont = map[string]dom.Element{}
2870 appSt.profileWrapTabs = map[string]map[string]dom.Element{}
2871 appSt.profileWrapTabSel = map[string]string{}
2872 appSt.profileWrapOrder = nil
2873 appSt.embedCallbacks = map[string][]string{}
2874 appSt.embedRelayHints = map[string][]string{}
2875 appSt.embedRequested = map[string]bool{}
2876 appSt.embedSubIDs = map[string][]string{}
2877 appSt.embedCoordCBs = map[string][]string{}
2878 appSt.embedCoordSubIDs = map[string]string{}
2879 appSt.threadEvents = map[string]*nostr.Event{}
2880 appSt.replyCache = map[string]string{}
2881 appSt.replyAvatarCache = map[string]string{}
2882 appSt.replyLineCache = map[string]string{}
2883 appSt.replyAuthorMap = map[string]string{}
2884 appSt.replyAuthorMapOld = map[string]string{}
2885 appSt.replyPending = map[string][]dom.Element{}
2886 appSt.replyHints = map[string]string{}
2887 appSt.replyNeedName = map[string][]dom.Element{}
2888 appSt.replyNeedNameOld = map[string][]dom.Element{}
2889 appSt.noteRepostCounts = map[string]map[string]bool{}
2890 appSt.noteReactionMap = map[string]map[string]map[string]bool{}
2891 appSt.noteReactionEls = map[string]dom.Element{}
2892 appSt.noteRepostEls = map[string]dom.Element{}
2893 appSt.noteZapTotals = map[string]int64{}
2894 appSt.noteZapSeen = map[string]map[string]bool{}
2895 appSt.noteZapEls = map[string]dom.Element{}
2896 appSt.pendingZapRows = map[string][]zapRowPending{}
2897 appSt.actionFetchedIDs = map[string]bool{}
2898 appSt.eventCache = map[string]*nostr.Event{}
2899 // Initialize "old" generation maps so range over nil is safe on first rotate.
2900 appSt.eventCacheOld = map[string]*nostr.Event{}
2901 appSt.seenEventsOld = map[string]bool{}
2902 appSt.noteElementsOld = map[string]dom.Element{}
2903 appSt.noteReactionMapOld = map[string]map[string]map[string]bool{}
2904 appSt.noteRepostCountsOld = map[string]map[string]bool{}
2905 appSt.noteZapSeenOld = map[string]map[string]bool{}
2906 appSt.actionFetchedIDsOld = map[string]bool{}
2907 appSt.authorNamesOld = map[string]string{}
2908 appSt.authorPicsOld = map[string]string{}
2909 appSt.embedCallbacksOld = map[string][]string{}
2910 appSt.embedRequestedOld = map[string]bool{}
2911 appSt.embedSubIDsOld = map[string][]string{}
2912 appSt.embedCoordCBsOld = map[string][]string{}
2913 appSt.replyCacheOld = map[string]string{}
2914 appSt.replyAvatarCacheOld = map[string]string{}
2915 appSt.replyLineCacheOld = map[string]string{}
2916 appSt.eventCacheCount = 0
2917 appSt.authorCacheCount = 0
2918 appSt.embedCacheCount = 0
2919 appSt.replyCount = 0
2920 appSt.editorDrafts = map[string]string{}
2921 appSt.notifSeen = map[string]bool{}
2922 notifEventsReset()
2923 releaseNotifRowCBs()
2924 appSt.notifUnread = false
2925 appSt.notifBuilt = false
2926 dom.IDBGet("settings", "notif-read-ts", func(val string) {
2927 if val != "" {
2928 appSt.notifReadTs = parseI64(val)
2929 }
2930 })
2931 if appSt.feedMode == "" {
2932 appSt.feedMode = "follows"
2933 }
2934 appSt.feedInitialLoad = true
2935 appSt.feedBuffer = nil
2936 initEmoji()
2937 initMentionPopup()
2938
2939 // Set up SW + relay-proxy worker communication. Both deliver into the
2940 // same dispatch (onSWMessage); message-type prefixes are unique per
2941 // source so they cannot collide.
2942 if !appSt.routerInited {
2943 dom.OnSWMessage(onSWMessage)
2944 relayproxy.OnMessage(onSWMessage)
2945 profile.OnMessage(onSWMessage)
2946 feed.OnMessage(onSWMessage)
2947 notif.OnMessage(onSWMessage)
2948 }
2949 routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
2950 sendEncKeyToSW()
2951
2952 // Load cached profiles from IndexedDB.
2953 // Populate name/pic view cache and send loaded pks to Profile Worker.
2954 var idbLoadedPKs []string
2955 dom.IDBGetAll("profiles", func(key, val string) {
2956 name := helpers.JsonGetString(val, "name")
2957 if len(name) == 0 {
2958 name = helpers.JsonGetString(val, "display_name")
2959 }
2960 pic := helpers.JsonGetString(val, "picture")
2961 if len(name) > 0 {
2962 setAuthorName(key, name)
2963 }
2964 if len(pic) > 0 {
2965 setAuthorPic(key, pic)
2966 }
2967 appSt.profileContentCache[key] = val
2968 idbLoadedPKs = mxutil.Ensure(idbLoadedPKs, 1)
2969 idbLoadedPKs = push(idbLoadedPKs, key)
2970 }, func() {
2971 appSt.idbLoaded = true
2972 // Fill pending note headers rendered before IDB finished.
2973 for pk, headers := range appSt.pendingNotes {
2974 name, nameOK := lookupAuthorName(pk)
2975 if !nameOK || len(name) == 0 {
2976 continue
2977 }
2978 pic, _ := lookupAuthorPic(pk)
2979 for _, h := range headers {
2980 updateNoteHeader(h, name, pic)
2981 }
2982 delete(appSt.pendingNotes, pk)
2983 }
2984 // Tell Profile Worker which pks are already cached so it skips fetching them.
2985 if len(idbLoadedPKs) > 0 {
2986 profile.Send(`["P_IDB_LOADED",` | buildJSONStrArr(idbLoadedPKs) | `]`)
2987 idbLoadedPKs = nil
2988 }
2989 // Re-render profile page if opened before IDB finished.
2990 if appSt.profileViewPK != "" && appSt.activePage == "profile" {
2991 renderProfilePage(appSt.profileViewPK)
2992 }
2993 })
2994
2995 // === Top bar ===
2996 bar := dom.CreateElement("div")
2997 dom.SetStyle(bar, "display", "flex")
2998 dom.SetStyle(bar, "alignItems", "center")
2999 dom.SetStyle(bar, "padding", "8px")
3000 dom.SetStyle(bar, "height", "48px")
3001 dom.SetStyle(bar, "boxSizing", "border-box")
3002 dom.SetStyle(bar, "background", "var(--bg2)")
3003 dom.SetStyle(bar, "position", "fixed")
3004 dom.SetStyle(bar, "top", "0")
3005 dom.SetStyle(bar, "left", "0")
3006 dom.SetStyle(bar, "right", "0")
3007 dom.SetStyle(bar, "zIndex", "100")
3008
3009 // Left: page title.
3010 left := dom.CreateElement("div")
3011 dom.SetStyle(left, "display", "flex")
3012 dom.SetStyle(left, "alignItems", "center")
3013 dom.SetStyle(left, "flex", "1")
3014 dom.SetStyle(left, "minWidth", "0")
3015
3016 // Logo in top-left.
3017 logo := dom.CreateElement("div")
3018 dom.SetStyle(logo, "width", "32px")
3019 dom.SetStyle(logo, "height", "32px")
3020 dom.SetStyle(logo, "flexShrink", "0")
3021 dom.SetStyle(logo, "color", "var(--accent)")
3022 dom.SetStyle(logo, "marginRight", "8px")
3023 dom.FetchText("/musiquay-logo.svg", func(svg string) {
3024 appSt.logoSVGCache = svg
3025 dom.SetInnerHTML(logo, svg)
3026 svgEl := dom.FirstElementChild(logo)
3027 if svgEl != 0 {
3028 dom.SetAttribute(svgEl, "width", "100%")
3029 dom.SetAttribute(svgEl, "height", "100%")
3030 }
3031 // Also populate the about page logo if it was built before fetch completed.
3032 if appSt.aboutLogoEl != 0 {
3033 dom.SetInnerHTML(appSt.aboutLogoEl, svg)
3034 al := dom.FirstElementChild(appSt.aboutLogoEl)
3035 if al != 0 {
3036 dom.SetAttribute(al, "width", "100%")
3037 dom.SetAttribute(al, "height", "100%")
3038 }
3039 }
3040 })
3041 dom.SetStyle(logo, "cursor", "pointer")
3042 dom.AddEventListener(logo, "click", dom.RegisterCallback(func() {
3043 showAboutModal()
3044 }))
3045 dom.AppendChild(left, logo)
3046
3047 appSt.topBackBtn = dom.CreateElement("span")
3048 dom.SetStyle(appSt.topBackBtn, "display", "none")
3049 dom.SetStyle(appSt.topBackBtn, "cursor", "pointer")
3050 dom.SetStyle(appSt.topBackBtn, "color", "var(--accent)")
3051 dom.SetStyle(appSt.topBackBtn, "fontSize", "18px")
3052 dom.SetStyle(appSt.topBackBtn, "fontWeight", "bold")
3053 dom.SetTextContent(appSt.topBackBtn, t("back"))
3054 dom.AddEventListener(appSt.topBackBtn, "click", dom.RegisterCallback(func() {
3055 dom.Back()
3056 }))
3057 dom.AppendChild(left, appSt.topBackBtn)
3058
3059 // Feed selector button (opens popup). Absolute-positioned so it sits at
3060 // the horizontal center of the top bar, independent of logo/back width.
3061 appSt.feedSelectEl = dom.CreateElement("button")
3062 dom.SetStyle(appSt.feedSelectEl, "position", "absolute")
3063 dom.SetStyle(appSt.feedSelectEl, "left", "50%")
3064 dom.SetStyle(appSt.feedSelectEl, "top", "50%")
3065 dom.SetStyle(appSt.feedSelectEl, "transform", "translate(-50%, -50%)")
3066 dom.SetStyle(appSt.feedSelectEl, "fontSize", "16px")
3067 dom.SetStyle(appSt.feedSelectEl, "fontWeight", "bold")
3068 dom.SetStyle(appSt.feedSelectEl, "background", "transparent")
3069 dom.SetStyle(appSt.feedSelectEl, "border", "none")
3070 dom.SetStyle(appSt.feedSelectEl, "color", "var(--fg)")
3071 dom.SetStyle(appSt.feedSelectEl, "cursor", "pointer")
3072 dom.SetStyle(appSt.feedSelectEl, "outline", "none")
3073 dom.SetStyle(appSt.feedSelectEl, "maxWidth", "200px")
3074 dom.SetStyle(appSt.feedSelectEl, "padding", "4px 8px")
3075 dom.SetStyle(appSt.feedSelectEl, "borderRadius", "4px")
3076 updateFeedBtnText()
3077 dom.AddEventListener(appSt.feedSelectEl, "click", dom.RegisterCallback(func() {
3078 toggleFeedPopover()
3079 }))
3080 dom.AppendChild(bar, appSt.feedSelectEl)
3081
3082 appSt.notifFilter = "all"
3083
3084 appSt.notifBarGroup = dom.CreateElement("div")
3085 dom.SetStyle(appSt.notifBarGroup, "position", "absolute")
3086 dom.SetStyle(appSt.notifBarGroup, "left", "50%")
3087 dom.SetStyle(appSt.notifBarGroup, "top", "50%")
3088 dom.SetStyle(appSt.notifBarGroup, "transform", "translate(-50%, -50%)")
3089 dom.SetStyle(appSt.notifBarGroup, "display", "none")
3090 dom.SetStyle(appSt.notifBarGroup, "alignItems", "center")
3091 dom.SetStyle(appSt.notifBarGroup, "gap", "8px")
3092
3093 appSt.notifSelectEl = dom.CreateElement("select")
3094 dom.SetStyle(appSt.notifSelectEl, "fontSize", "13px")
3095 dom.SetStyle(appSt.notifSelectEl, "background", "var(--bg)")
3096 dom.SetStyle(appSt.notifSelectEl, "border", "1px solid var(--border)")
3097 dom.SetStyle(appSt.notifSelectEl, "borderRadius", "4px")
3098 dom.SetStyle(appSt.notifSelectEl, "color", "var(--fg)")
3099 dom.SetStyle(appSt.notifSelectEl, "cursor", "pointer")
3100 dom.SetStyle(appSt.notifSelectEl, "outline", "none")
3101 dom.SetStyle(appSt.notifSelectEl, "padding", "3px 6px")
3102 dom.SetStyle(appSt.notifSelectEl, "fontFamily", "'Fira Code', monospace")
3103 for _, opt := range []string{"all", "mentions", "reactions", "zaps"} {
3104 o := dom.CreateElement("option")
3105 dom.SetAttribute(o, "value", opt)
3106 dom.SetTextContent(o, opt)
3107 dom.AppendChild(appSt.notifSelectEl, o)
3108 }
3109 dom.AddEventListener(appSt.notifSelectEl, "change", dom.RegisterCallback(func() {
3110 appSt.notifFilter = dom.GetProperty(appSt.notifSelectEl, "value")
3111 rebuildNotifRows()
3112 }))
3113 dom.AppendChild(appSt.notifBarGroup, appSt.notifSelectEl)
3114
3115 appSt.notifMarkBtn = dom.CreateElement("button")
3116 dom.SetTextContent(appSt.notifMarkBtn, "read")
3117 dom.SetStyle(appSt.notifMarkBtn, "fontSize", "12px")
3118 dom.SetStyle(appSt.notifMarkBtn, "padding", "3px 8px")
3119 dom.SetStyle(appSt.notifMarkBtn, "border", "1px solid var(--border)")
3120 dom.SetStyle(appSt.notifMarkBtn, "borderRadius", "4px")
3121 dom.SetStyle(appSt.notifMarkBtn, "background", "transparent")
3122 dom.SetStyle(appSt.notifMarkBtn, "color", "var(--fg)")
3123 dom.SetStyle(appSt.notifMarkBtn, "cursor", "pointer")
3124 dom.SetStyle(appSt.notifMarkBtn, "fontFamily", "'Fira Code', monospace")
3125 dom.AddEventListener(appSt.notifMarkBtn, "click", dom.RegisterCallback(func() {
3126 markNotifsRead()
3127 }))
3128 dom.AppendChild(appSt.notifBarGroup, appSt.notifMarkBtn)
3129 dom.AppendChild(bar, appSt.notifBarGroup)
3130
3131 appSt.pageTitleEl = dom.CreateElement("span")
3132 dom.SetStyle(appSt.pageTitleEl, "fontSize", "18px")
3133 dom.SetStyle(appSt.pageTitleEl, "fontWeight", "bold")
3134 dom.SetStyle(appSt.pageTitleEl, "display", "none")
3135 dom.AppendChild(left, appSt.pageTitleEl)
3136 appSt.topBarLeft = left
3137 dom.AppendChild(bar, left)
3138
3139 // Right: wallet + signer (gear) icon buttons.
3140 appSt.topBarRight = dom.CreateElement("div")
3141 right := appSt.topBarRight
3142 dom.SetStyle(right, "display", "flex")
3143 dom.SetStyle(right, "alignItems", "center")
3144 dom.SetStyle(right, "gap", "8px")
3145 dom.SetStyle(right, "flex", "1")
3146 dom.SetStyle(right, "justifyContent", "flex-end")
3147
3148 appSt.walletBtnEl = dom.CreateElement("button")
3149 dom.SetInnerHTML(appSt.walletBtnEl, `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2.5 6a1.5 1.5 0 0 1 1.5-1.5h9a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2.5 13V6z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M2.5 7.5h11M12 10.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`)
3150 dom.SetAttribute(appSt.walletBtnEl, "title", "wallet")
3151 dom.SetStyle(appSt.walletBtnEl, "background", "transparent")
3152 dom.SetStyle(appSt.walletBtnEl, "border", "none")
3153 dom.SetStyle(appSt.walletBtnEl, "color", "var(--muted)")
3154 dom.SetStyle(appSt.walletBtnEl, "height", "32px")
3155 dom.SetStyle(appSt.walletBtnEl, "width", "32px")
3156 dom.SetStyle(appSt.walletBtnEl, "display", "flex")
3157 dom.SetStyle(appSt.walletBtnEl, "alignItems", "center")
3158 dom.SetStyle(appSt.walletBtnEl, "justifyContent", "center")
3159 dom.SetStyle(appSt.walletBtnEl, "cursor", "pointer")
3160 dom.SetStyle(appSt.walletBtnEl, "borderRadius", "4px")
3161 dom.AddEventListener(appSt.walletBtnEl, "click", dom.RegisterCallback(func() {
3162 toggleWalletPanel()
3163 }))
3164 // Wallet moves to bottom-bar icon group; refreshBtn lives here in top-right.
3165 _ = appSt.walletBtnEl
3166
3167 dom.AppendChild(bar, right)
3168
3169 dom.AppendChild(appSt.root, bar)
3170
3171 // === Main layout: content only (icons moved to bottom bar) ===
3172 mainLayout := dom.CreateElement("div")
3173 dom.SetStyle(mainLayout, "position", "fixed")
3174 dom.SetStyle(mainLayout, "top", "48px")
3175 dom.SetStyle(mainLayout, "bottom", "36px")
3176 dom.SetStyle(mainLayout, "left", "0")
3177 dom.SetStyle(mainLayout, "right", "0")
3178 dom.SetStyle(mainLayout, "display", "flex")
3179
3180 appSt.sidebarCompose = makeSidebarIcon(svgCompose, false)
3181 dom.AddEventListener(appSt.sidebarCompose, "click", dom.RegisterCallback(func() {
3182 switchPage("compose")
3183 }))
3184
3185 appSt.sidebarFeed = makeSidebarIcon(svgFeed, true)
3186 dom.AddEventListener(appSt.sidebarFeed, "click", dom.RegisterCallback(func() {
3187 switchPage("feed")
3188 }))
3189
3190 // Messaging is disabled until the MLS/DM fix lands: no sidebar entry, no
3191 // messaging route, and relay-proxy MLS routing is off. See initMessaging.
3192 appSt.sidebarNotif = dom.CreateElement("button")
3193 dom.SetStyle(appSt.sidebarNotif, "width", "36px")
3194 dom.SetStyle(appSt.sidebarNotif, "height", "36px")
3195 dom.SetStyle(appSt.sidebarNotif, "border", "none")
3196 dom.SetStyle(appSt.sidebarNotif, "borderRadius", "6px")
3197 dom.SetStyle(appSt.sidebarNotif, "cursor", "pointer")
3198 dom.SetStyle(appSt.sidebarNotif, "display", "flex")
3199 dom.SetStyle(appSt.sidebarNotif, "alignItems", "center")
3200 dom.SetStyle(appSt.sidebarNotif, "justifyContent", "center")
3201 dom.SetStyle(appSt.sidebarNotif, "padding", "0")
3202 dom.SetStyle(appSt.sidebarNotif, "background", "transparent")
3203 dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)")
3204 dom.SetStyle(appSt.sidebarNotif, "position", "relative")
3205 dom.SetInnerHTML(appSt.sidebarNotif, svgBell)
3206 appSt.notifDot = dom.CreateElement("span")
3207 dom.SetStyle(appSt.notifDot, "position", "absolute")
3208 dom.SetStyle(appSt.notifDot, "top", "4px")
3209 dom.SetStyle(appSt.notifDot, "right", "5px")
3210 dom.SetStyle(appSt.notifDot, "width", "8px")
3211 dom.SetStyle(appSt.notifDot, "height", "8px")
3212 dom.SetStyle(appSt.notifDot, "borderRadius", "50%")
3213 dom.SetStyle(appSt.notifDot, "background", "var(--accent)")
3214 dom.SetStyle(appSt.notifDot, "display", "none")
3215 dom.AppendChild(appSt.sidebarNotif, appSt.notifDot)
3216 dom.AddEventListener(appSt.sidebarNotif, "click", dom.RegisterCallback(func() {
3217 switchPage("notifications")
3218 }))
3219
3220 appSt.sidebarSettings = makeSidebarIcon(svgGear, false)
3221 dom.AddEventListener(appSt.sidebarSettings, "click", dom.RegisterCallback(func() {
3222 switchPage("settings")
3223 }))
3224
3225 appSt.themeBtn = dom.CreateElement("button")
3226 dom.SetStyle(appSt.themeBtn, "background", "transparent")
3227 dom.SetStyle(appSt.themeBtn, "border", "none")
3228 dom.SetStyle(appSt.themeBtn, "width", "32px")
3229 dom.SetStyle(appSt.themeBtn, "height", "32px")
3230 dom.SetStyle(appSt.themeBtn, "cursor", "pointer")
3231 dom.SetStyle(appSt.themeBtn, "padding", "0")
3232 dom.SetStyle(appSt.themeBtn, "display", "flex")
3233 dom.SetStyle(appSt.themeBtn, "alignItems", "center")
3234 dom.SetStyle(appSt.themeBtn, "justifyContent", "center")
3235 dom.SetStyle(appSt.themeBtn, "color", "var(--muted)")
3236 updateThemeIcon()
3237 dom.AddEventListener(appSt.themeBtn, "click", dom.RegisterCallback(func() {
3238 toggleTheme()
3239 }))
3240
3241 // Content area.
3242 appSt.contentArea = dom.CreateElement("div")
3243 dom.SetAttribute(appSt.contentArea, "id", "content-area")
3244 dom.SetStyle(appSt.contentArea, "flex", "1")
3245 dom.SetStyle(appSt.contentArea, "minHeight", "0")
3246 dom.SetStyle(appSt.contentArea, "overflowY", "auto")
3247
3248 // Home page - the shell's empty main area.
3249 appSt.homePage = dom.CreateElement("div")
3250 dom.SetAttribute(appSt.homePage, "id", "home-page")
3251 dom.SetStyle(appSt.homePage, "display", "none")
3252 dom.SetStyle(appSt.homePage, "padding", "16px")
3253 dom.SetStyle(appSt.homePage, "maxWidth", "640px")
3254 dom.SetStyle(appSt.homePage, "margin", "0 auto")
3255 dom.SetStyle(appSt.homePage, "boxSizing", "border-box")
3256 dom.AppendChild(appSt.contentArea, appSt.homePage)
3257
3258 // Feed page (parked; see homePage above).
3259 appSt.feedPage = dom.CreateElement("div")
3260 dom.SetAttribute(appSt.feedPage, "id", "feed-page")
3261 dom.SetStyle(appSt.feedPage, "display", "none")
3262 dom.SetStyle(appSt.feedPage, "padding", "16px")
3263 dom.SetStyle(appSt.feedPage, "maxWidth", "640px")
3264 dom.SetStyle(appSt.feedPage, "margin", "0 auto")
3265 dom.SetStyle(appSt.feedPage, "boxSizing", "border-box")
3266
3267 // Loading spinner - shown until first feed event arrives.
3268 appSt.feedLoader = dom.CreateElement("div")
3269 dom.SetStyle(appSt.feedLoader, "display", "flex")
3270 dom.SetStyle(appSt.feedLoader, "flexDirection", "column")
3271 dom.SetStyle(appSt.feedLoader, "alignItems", "center")
3272 dom.SetStyle(appSt.feedLoader, "justifyContent", "center")
3273 dom.SetStyle(appSt.feedLoader, "padding", "64px 0")
3274 loaderImg := dom.CreateElement("div")
3275 dom.SetStyle(loaderImg, "width", "120px")
3276 dom.SetStyle(loaderImg, "height", "120px")
3277 dom.FetchText("/musiquay-loader.svg", func(svg string) {
3278 dom.SetInnerHTML(loaderImg, svg)
3279 svgEl := dom.FirstElementChild(loaderImg)
3280 if svgEl != 0 {
3281 dom.SetAttribute(svgEl, "width", "100%")
3282 dom.SetAttribute(svgEl, "height", "100%")
3283 }
3284 })
3285 dom.AppendChild(appSt.feedLoader, loaderImg)
3286 loaderText := dom.CreateElement("div")
3287 dom.SetTextContent(loaderText, t("connecting"))
3288 dom.SetStyle(loaderText, "marginTop", "16px")
3289 dom.SetStyle(loaderText, "color", "var(--muted)")
3290 dom.SetStyle(loaderText, "fontSize", "14px")
3291 dom.AppendChild(appSt.feedLoader, loaderText)
3292 dom.AppendChild(appSt.feedPage, appSt.feedLoader)
3293
3294 // Pull-to-refresh indicator (hidden until user pulls at top).
3295 pullIndicator := dom.CreateElement("div")
3296 dom.SetStyle(pullIndicator, "display", "none")
3297 dom.SetStyle(pullIndicator, "justifyContent", "center")
3298 dom.SetStyle(pullIndicator, "alignItems", "center")
3299 dom.SetStyle(pullIndicator, "padding", "12px 0")
3300 dom.SetStyle(pullIndicator, "color", "var(--muted)")
3301 dom.SetStyle(pullIndicator, "fontSize", "13px")
3302 dom.SetTextContent(pullIndicator, "\u21bb refreshing...")
3303 dom.AppendChild(appSt.feedPage, pullIndicator)
3304
3305 appSt.feedContainer = dom.CreateElement("div")
3306 dom.AppendChild(appSt.feedPage, appSt.feedContainer)
3307
3308 // "Load more" button - fallback for infinite scroll.
3309 appSt.loadMoreBtn = dom.CreateElement("div")
3310 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
3311 dom.SetStyle(appSt.loadMoreBtn, "textAlign", "center")
3312 dom.SetStyle(appSt.loadMoreBtn, "padding", "16px 0")
3313 loadMoreLink := dom.CreateElement("span")
3314 dom.SetTextContent(loadMoreLink, "load more")
3315 dom.SetStyle(loadMoreLink, "cursor", "pointer")
3316 dom.SetStyle(loadMoreLink, "color", "var(--accent)")
3317 dom.SetStyle(loadMoreLink, "fontSize", "14px")
3318 dom.AddEventListener(loadMoreLink, "click", dom.RegisterCallback(func() {
3319 if !appSt.feedLoading && !appSt.feedExhausted && appSt.oldestFeedTs > 0 {
3320 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
3321 loadOlderFeed()
3322 }
3323 }))
3324 dom.AppendChild(appSt.loadMoreBtn, loadMoreLink)
3325
3326 // Thread view (hidden overlay within feedPage).
3327 appSt.threadPage = dom.CreateElement("div")
3328 dom.SetAttribute(appSt.threadPage, "id", "thread-page")
3329 dom.SetStyle(appSt.threadPage, "display", "none")
3330
3331 appSt.threadContainer = dom.CreateElement("div")
3332 dom.AppendChild(appSt.threadPage, appSt.threadContainer)
3333 dom.AppendChild(appSt.feedPage, appSt.threadPage)
3334
3335 // Load-more after thread so it sits at the bottom in both views.
3336 dom.AppendChild(appSt.feedPage, appSt.loadMoreBtn)
3337
3338 dom.AppendChild(appSt.contentArea, appSt.feedPage)
3339
3340 // Pull-to-refresh: wheel up or touch pull at top triggers feed reload.
3341 dom.OnPullRefresh(appSt.contentArea, pullIndicator, func() {
3342 if appSt.activePage == "feed" && !appSt.refreshSpinning {
3343 onRefreshClick()
3344 dom.SetTimeout(func() {
3345 dom.SetStyle(pullIndicator, "display", "none")
3346 }, 1500)
3347 } else if appSt.activePage == "notifications" {
3348 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
3349 dom.SetTimeout(func() {
3350 dom.SetStyle(pullIndicator, "display", "none")
3351 }, 1500)
3352 }
3353 })
3354
3355 // Infinite scroll - load older events when near bottom.
3356 dom.AddEventListener(appSt.contentArea, "scroll", dom.RegisterCallback(func() {
3357 st := dom.GetProperty(appSt.contentArea, "scrollTop")
3358 ch := dom.GetProperty(appSt.contentArea, "clientHeight")
3359 sh := dom.GetProperty(appSt.contentArea, "scrollHeight")
3360 top := parseIntProp(st)
3361 height := parseIntProp(ch)
3362 total := parseIntProp(sh)
3363 nearBottom := top+height >= total-400
3364
3365 if appSt.activePage == "feed" {
3366 if top <= 10 && len(appSt.feedBuffer) > 0 {
3367 for _, bev := range appSt.feedBuffer {
3368 renderNote(bev)
3369 appSt.seenEvents[bev.ID] = true
3370 }
3371 appSt.feedBuffer = nil
3372 appSt.feedHasNew = false
3373 stopRefreshPulse()
3374 }
3375 if appSt.feedLoading || appSt.feedExhausted || appSt.oldestFeedTs == 0 {
3376 return
3377 }
3378 if nearBottom {
3379 loadOlderFeed()
3380 }
3381 } else if appSt.activePage == "notifications" {
3382 if appSt.notifLoading || appSt.notifExhausted || appSt.oldestNotifTs == 0 {
3383 return
3384 }
3385 if nearBottom {
3386 loadOlderNotifs()
3387 }
3388 } else if appSt.activePage == "search" {
3389 if nearBottom {
3390 appendSearchResults()
3391 }
3392 }
3393 }))
3394
3395 // Messaging page.
3396 appSt.msgPage = dom.CreateElement("div")
3397 dom.SetAttribute(appSt.msgPage, "id", "msg-page")
3398 dom.SetStyle(appSt.msgPage, "padding", "16px")
3399 dom.SetStyle(appSt.msgPage, "display", "none")
3400 dom.SetStyle(appSt.msgPage, "position", "relative")
3401 dom.SetStyle(appSt.msgPage, "height", "100%")
3402 dom.SetStyle(appSt.msgPage, "boxSizing", "border-box")
3403
3404 // Conversation list view.
3405 appSt.msgListContainer = dom.CreateElement("div")
3406 dom.AppendChild(appSt.msgPage, appSt.msgListContainer)
3407
3408 // Thread view (hidden by default).
3409 appSt.msgThreadContainer = dom.CreateElement("div")
3410 dom.SetStyle(appSt.msgThreadContainer, "display", "none")
3411 dom.SetStyle(appSt.msgThreadContainer, "flexDirection", "column")
3412 dom.SetStyle(appSt.msgThreadContainer, "position", "absolute")
3413 dom.SetStyle(appSt.msgThreadContainer, "top", "0")
3414 dom.SetStyle(appSt.msgThreadContainer, "left", "0")
3415 dom.SetStyle(appSt.msgThreadContainer, "right", "0")
3416 dom.SetStyle(appSt.msgThreadContainer, "bottom", "0")
3417 dom.SetStyle(appSt.msgThreadContainer, "background", "var(--bg)")
3418 dom.AppendChild(appSt.msgPage, appSt.msgThreadContainer)
3419
3420 appSt.msgView = "list"
3421
3422 dom.AppendChild(appSt.contentArea, appSt.msgPage)
3423
3424 // Profile page.
3425 appSt.profilePage = dom.CreateElement("div")
3426 dom.SetAttribute(appSt.profilePage, "id", "profile-page")
3427 dom.SetStyle(appSt.profilePage, "display", "none")
3428 dom.SetStyle(appSt.profilePage, "padding", "16px")
3429 dom.SetStyle(appSt.profilePage, "maxWidth", "640px")
3430 dom.SetStyle(appSt.profilePage, "margin", "0 auto")
3431 dom.SetStyle(appSt.profilePage, "boxSizing", "border-box")
3432 dom.AppendChild(appSt.contentArea, appSt.profilePage)
3433
3434 // Settings page.
3435 appSt.settingsPage = dom.CreateElement("div")
3436 dom.SetAttribute(appSt.settingsPage, "id", "settings-page")
3437 dom.SetStyle(appSt.settingsPage, "display", "none")
3438 dom.SetStyle(appSt.settingsPage, "padding", "16px")
3439 dom.SetStyle(appSt.settingsPage, "maxWidth", "640px")
3440 dom.SetStyle(appSt.settingsPage, "margin", "0 auto")
3441 dom.SetStyle(appSt.settingsPage, "boxSizing", "border-box")
3442 dom.AppendChild(appSt.contentArea, appSt.settingsPage)
3443
3444 // Compose page.
3445 appSt.composePage = dom.CreateElement("div")
3446 dom.SetAttribute(appSt.composePage, "id", "compose-page")
3447 dom.SetStyle(appSt.composePage, "display", "none")
3448 dom.SetStyle(appSt.composePage, "padding", "16px")
3449 dom.SetStyle(appSt.composePage, "maxWidth", "640px")
3450 dom.SetStyle(appSt.composePage, "margin", "0 auto")
3451 dom.SetStyle(appSt.composePage, "minHeight", "100%")
3452 dom.SetStyle(appSt.composePage, "boxSizing", "border-box")
3453 buildComposePage()
3454 dom.AppendChild(appSt.contentArea, appSt.composePage)
3455
3456 // About page.
3457 appSt.aboutPage = dom.CreateElement("div")
3458 dom.SetAttribute(appSt.aboutPage, "id", "about-page")
3459 dom.SetStyle(appSt.aboutPage, "display", "none")
3460 dom.SetStyle(appSt.aboutPage, "padding", "24px 16px")
3461 dom.SetStyle(appSt.aboutPage, "maxWidth", "640px")
3462 dom.SetStyle(appSt.aboutPage, "margin", "0 auto")
3463 dom.SetStyle(appSt.aboutPage, "boxSizing", "border-box")
3464 dom.SetStyle(appSt.aboutPage, "textAlign", "center")
3465
3466 aRefresh := dom.CreateElement("button")
3467 dom.SetTextContent(aRefresh, "hard refresh")
3468 dom.SetStyle(aRefresh, "fontFamily", "'Fira Code', monospace")
3469 dom.SetStyle(aRefresh, "fontSize", "13px")
3470 dom.SetStyle(aRefresh, "background", "transparent")
3471 dom.SetStyle(aRefresh, "border", "1px solid var(--border)")
3472 dom.SetStyle(aRefresh, "borderRadius", "4px")
3473 dom.SetStyle(aRefresh, "color", "var(--muted)")
3474 dom.SetStyle(aRefresh, "cursor", "pointer")
3475 dom.SetStyle(aRefresh, "padding", "6px 16px")
3476 dom.SetStyle(aRefresh, "marginBottom", "16px")
3477 dom.AddEventListener(aRefresh, "click", dom.RegisterCallback(func() {
3478 dom.HardRefresh()
3479 }))
3480 dom.AppendChild(appSt.aboutPage, aRefresh)
3481
3482 appSt.aboutLogoEl = dom.CreateElement("div")
3483 dom.SetStyle(appSt.aboutLogoEl, "width", "180px")
3484 dom.SetStyle(appSt.aboutLogoEl, "height", "180px")
3485 dom.SetStyle(appSt.aboutLogoEl, "margin", "0 auto 16px auto")
3486 dom.SetStyle(appSt.aboutLogoEl, "color", "var(--accent)")
3487 if appSt.logoSVGCache != "" {
3488 dom.SetInnerHTML(appSt.aboutLogoEl, appSt.logoSVGCache)
3489 aLogoSvg := dom.FirstElementChild(appSt.aboutLogoEl)
3490 if aLogoSvg != 0 {
3491 dom.SetAttribute(aLogoSvg, "width", "100%")
3492 dom.SetAttribute(aLogoSvg, "height", "100%")
3493 }
3494 }
3495 dom.AppendChild(appSt.aboutPage, appSt.aboutLogoEl)
3496
3497 aTitle := dom.CreateElement("h2")
3498 dom.SetTextContent(aTitle, "MUSIQUAY " | version)
3499 dom.SetStyle(aTitle, "fontSize", "20px")
3500 dom.SetStyle(aTitle, "margin", "0 0 20px 0")
3501 dom.SetStyle(aTitle, "color", "var(--accent)")
3502 dom.AppendChild(appSt.aboutPage, aTitle)
3503
3504 aDevLabel := dom.CreateElement("p")
3505 dom.SetTextContent(aDevLabel, t("developed_by"))
3506 dom.SetStyle(aDevLabel, "fontSize", "12px")
3507 dom.SetStyle(aDevLabel, "color", "var(--muted)")
3508 dom.SetStyle(aDevLabel, "marginBottom", "4px")
3509 dom.AppendChild(appSt.aboutPage, aDevLabel)
3510
3511 aNpub := dom.CreateElement("p")
3512 dom.SetStyle(aNpub, "fontSize", "11px")
3513 dom.SetStyle(aNpub, "color", "var(--fg)")
3514 dom.SetStyle(aNpub, "wordBreak", "break-all")
3515 dom.SetStyle(aNpub, "marginBottom", "20px")
3516 dom.SetStyle(aNpub, "fontFamily", "'Fira Code', monospace")
3517 dom.SetTextContent(aNpub, "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku")
3518 dom.AppendChild(appSt.aboutPage, aNpub)
3519
3520 aTagline := dom.CreateElement("p")
3521 dom.SetStyle(aTagline, "fontSize", "16px")
3522 dom.SetStyle(aTagline, "fontWeight", "bold")
3523 dom.SetStyle(aTagline, "color", "var(--fg)")
3524 dom.SetStyle(aTagline, "marginBottom", "12px")
3525 dom.SetTextContent(aTagline, t("tagline"))
3526 dom.AppendChild(appSt.aboutPage, aTagline)
3527
3528 aFunny := dom.CreateElement("p")
3529 dom.SetStyle(aFunny, "fontSize", "13px")
3530 dom.SetStyle(aFunny, "color", "var(--fg)")
3531 dom.SetStyle(aFunny, "marginBottom", "20px")
3532 dom.SetStyle(aFunny, "lineHeight", "1.6")
3533 dom.SetTextContent(aFunny, t("about_donkey"))
3534 dom.AppendChild(appSt.aboutPage, aFunny)
3535
3536 aLud := dom.CreateElement("span")
3537 dom.SetStyle(aLud, "fontSize", "13px")
3538 dom.SetStyle(aLud, "color", "var(--accent)")
3539 dom.SetStyle(aLud, "marginBottom", "24px")
3540 dom.SetStyle(aLud, "cursor", "pointer")
3541 dom.SetStyle(aLud, "display", "inline-block")
3542 dom.SetTextContent(aLud, "\xe2\x9a\xa1 leeringidol57@walletofsatoshi.com")
3543 dom.AddEventListener(aLud, "click", dom.RegisterCallback(func() {
3544 showQRModal("leeringidol57@walletofsatoshi.com")
3545 }))
3546 dom.AppendChild(appSt.aboutPage, aLud)
3547
3548 feliceLink := dom.CreateElement("a")
3549 dom.SetAttribute(feliceLink, "href", "https://git.smesh.lol/felice/raw/felice.apk")
3550 dom.SetAttribute(feliceLink, "target", "_blank")
3551 dom.SetStyle(feliceLink, "display", "block")
3552 dom.SetStyle(feliceLink, "color", "var(--accent)")
3553 dom.SetStyle(feliceLink, "fontSize", "13px")
3554 dom.SetStyle(feliceLink, "marginBottom", "4px")
3555 dom.SetTextContent(feliceLink, "download felice")
3556 dom.AppendChild(appSt.aboutPage, feliceLink)
3557
3558 feliceDesc := dom.CreateElement("p")
3559 dom.SetStyle(feliceDesc, "fontSize", "11px")
3560 dom.SetStyle(feliceDesc, "color", "var(--muted)")
3561 dom.SetStyle(feliceDesc, "lineHeight", "1.5")
3562 dom.SetStyle(feliceDesc, "marginBottom", "24px")
3563 dom.SetStyle(feliceDesc, "maxWidth", "400px")
3564 dom.SetStyle(feliceDesc, "margin", "0 auto 24px auto")
3565 dom.SetTextContent(feliceDesc, "fork of fennec-fdroid to use smesh.lol and musiquay signer extension on android without the mozilla security theatre")
3566 dom.AppendChild(appSt.aboutPage, feliceDesc)
3567
3568 dom.AppendChild(appSt.contentArea, appSt.aboutPage)
3569
3570 // Activity log page.
3571 appSt.logPage = dom.CreateElement("div")
3572 dom.SetAttribute(appSt.logPage, "id", "log-page")
3573 dom.SetStyle(appSt.logPage, "display", "none")
3574 dom.SetStyle(appSt.logPage, "padding", "12px")
3575 dom.SetStyle(appSt.logPage, "maxWidth", "780px")
3576 dom.SetStyle(appSt.logPage, "margin", "0 auto")
3577 dom.SetStyle(appSt.logPage, "boxSizing", "border-box")
3578 dom.SetStyle(appSt.logPage, "fontFamily", "'Fira Code', monospace")
3579 dom.SetStyle(appSt.logPage, "fontSize", "12px")
3580
3581 appSt.logListEl = dom.CreateElement("div")
3582 dom.AppendChild(appSt.logPage, appSt.logListEl)
3583 dom.AppendChild(appSt.contentArea, appSt.logPage)
3584
3585 // Pre-populate with any entries that accumulated during boot before this DOM was built.
3586 for i := activityLogLen() - 1; i >= 0; i-- {
3587 prependLogEntry(activityEntry(i))
3588 }
3589
3590 // Notifications page.
3591 appSt.notifPage = dom.CreateElement("div")
3592 dom.SetAttribute(appSt.notifPage, "id", "notif-page")
3593 dom.SetStyle(appSt.notifPage, "display", "none")
3594 dom.SetStyle(appSt.notifPage, "padding", "16px")
3595 dom.SetStyle(appSt.notifPage, "maxWidth", "640px")
3596 dom.SetStyle(appSt.notifPage, "margin", "0 auto")
3597 dom.SetStyle(appSt.notifPage, "boxSizing", "border-box")
3598
3599 appSt.notifContainer = dom.CreateElement("div")
3600 dom.AppendChild(appSt.notifPage, appSt.notifContainer)
3601 dom.AppendChild(appSt.contentArea, appSt.notifPage)
3602
3603 // Search page: flex-column so filter bar stays above scrolling results.
3604 appSt.searchPage = dom.CreateElement("div")
3605 dom.SetAttribute(appSt.searchPage, "id", "search-page")
3606 dom.SetStyle(appSt.searchPage, "display", "none")
3607 dom.SetStyle(appSt.searchPage, "maxWidth", "640px")
3608 dom.SetStyle(appSt.searchPage, "margin", "0 auto")
3609 dom.SetStyle(appSt.searchPage, "width", "100%")
3610 // searchFilterBarEl and searchContainer are appended dynamically by buildSearchFilterBar/executeSearch.
3611 appSt.searchContainer = dom.CreateElement("div")
3612 dom.AppendChild(appSt.searchPage, appSt.searchContainer)
3613 dom.AppendChild(appSt.contentArea, appSt.searchPage)
3614
3615 dom.AppendChild(mainLayout, appSt.contentArea)
3616 dom.AppendChild(appSt.root, mainLayout)
3617 appSt.activePage = "log"
3618 dom.SetStyle(appSt.logPage, "display", "block")
3619
3620 buildStatusBar(true)
3621
3622 // Icon group: fixed 4px gap between icons (same as old sidebar), centered
3623 // on screen via absolute positioning + translateX(-50%).
3624 iconGroup := dom.CreateElement("div")
3625 dom.SetStyle(iconGroup, "position", "absolute")
3626 dom.SetStyle(iconGroup, "left", "50%")
3627 dom.SetStyle(iconGroup, "top", "0")
3628 dom.SetStyle(iconGroup, "bottom", "0")
3629 dom.SetStyle(iconGroup, "transform", "translateX(-50%)")
3630 dom.SetStyle(iconGroup, "display", "flex")
3631 dom.SetStyle(iconGroup, "alignItems", "center")
3632 dom.SetStyle(iconGroup, "gap", "4px")
3633 if int32(appSt.userBtn) != 0 {
3634 dom.AppendChild(iconGroup, appSt.userBtn)
3635 }
3636 // The compose and feed buttons are not part of this base build; the
3637 // elements stay created above so switchPage's highlight resets stay valid.
3638 dom.AppendChild(iconGroup, appSt.sidebarNotif)
3639 dom.AppendChild(iconGroup, appSt.sidebarSettings)
3640 _ = appSt.walletBtnEl
3641 _ = appSt.themeBtn
3642 dom.AppendChild(appSt.bottomBar, iconGroup)
3643 // Activity log button - opens the log page. Highlighted while boot is in progress.
3644 appSt.logBtn = dom.CreateElement("button")
3645 dom.SetInnerHTML(appSt.logBtn, `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-9 4 18 3-9h4"/></svg>`)
3646 dom.SetAttribute(appSt.logBtn, "title", "activity log")
3647 dom.SetStyle(appSt.logBtn, "background", "transparent")
3648 dom.SetStyle(appSt.logBtn, "border", "none")
3649 dom.SetStyle(appSt.logBtn, "color", "var(--muted)")
3650 dom.SetStyle(appSt.logBtn, "width", "32px")
3651 dom.SetStyle(appSt.logBtn, "height", "32px")
3652 dom.SetStyle(appSt.logBtn, "cursor", "pointer")
3653 dom.SetStyle(appSt.logBtn, "padding", "0")
3654 dom.SetStyle(appSt.logBtn, "display", "flex")
3655 dom.SetStyle(appSt.logBtn, "alignItems", "center")
3656 dom.SetStyle(appSt.logBtn, "justifyContent", "center")
3657 dom.AddEventListener(appSt.logBtn, "click", dom.RegisterCallback(func() {
3658 switchPage("log")
3659 }))
3660 dom.AppendChild(appSt.topBarRight, appSt.logBtn)
3661 updateLogBtnHighlight()
3662
3663 // Search button + input row - leftmost item in topBarRight.
3664 appSt.searchSortDesc = true
3665 appSt.searchSeen = map[string]bool{}
3666
3667 searchBtn := dom.CreateElement("button")
3668 dom.SetInnerHTML(searchBtn, `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="4" stroke="currentColor" stroke-width="1.5"/><line x1="9.9" y1="9.9" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`)
3669 dom.SetAttribute(searchBtn, "title", "search")
3670 dom.SetStyle(searchBtn, "background", "transparent")
3671 dom.SetStyle(searchBtn, "border", "none")
3672 dom.SetStyle(searchBtn, "color", "var(--muted)")
3673 dom.SetStyle(searchBtn, "height", "32px")
3674 dom.SetStyle(searchBtn, "width", "32px")
3675 dom.SetStyle(searchBtn, "display", "flex")
3676 dom.SetStyle(searchBtn, "alignItems", "center")
3677 dom.SetStyle(searchBtn, "justifyContent", "center")
3678 dom.SetStyle(searchBtn, "cursor", "pointer")
3679 dom.SetStyle(searchBtn, "borderRadius", "4px")
3680 dom.SetStyle(searchBtn, "flexShrink", "0")
3681
3682 appSt.searchInputEl = dom.CreateElement("input")
3683 dom.SetAttribute(appSt.searchInputEl, "type", "text")
3684 dom.SetAttribute(appSt.searchInputEl, "placeholder", "#tag or text search")
3685 dom.SetStyle(appSt.searchInputEl, "display", "none")
3686 dom.SetStyle(appSt.searchInputEl, "flex", "1")
3687 dom.SetStyle(appSt.searchInputEl, "minWidth", "0")
3688 dom.SetStyle(appSt.searchInputEl, "background", "var(--bg)")
3689 dom.SetStyle(appSt.searchInputEl, "color", "var(--fg)")
3690 dom.SetStyle(appSt.searchInputEl, "border", "1px solid var(--border)")
3691 dom.SetStyle(appSt.searchInputEl, "borderRadius", "4px")
3692 dom.SetStyle(appSt.searchInputEl, "padding", "4px 8px")
3693 dom.SetStyle(appSt.searchInputEl, "fontSize", "14px")
3694 dom.SetStyle(appSt.searchInputEl, "fontFamily", "'Fira Code', monospace")
3695
3696 appSt.searchSubmitBtn = dom.CreateElement("button")
3697 dom.SetTextContent(appSt.searchSubmitBtn, "\xe2\x86\x92") // →
3698 dom.SetStyle(appSt.searchSubmitBtn, "display", "none")
3699 dom.SetStyle(appSt.searchSubmitBtn, "background", "var(--accent)")
3700 dom.SetStyle(appSt.searchSubmitBtn, "color", "#fff")
3701 dom.SetStyle(appSt.searchSubmitBtn, "border", "none")
3702 dom.SetStyle(appSt.searchSubmitBtn, "borderRadius", "4px")
3703 dom.SetStyle(appSt.searchSubmitBtn, "padding", "4px 10px")
3704 dom.SetStyle(appSt.searchSubmitBtn, "cursor", "pointer")
3705 dom.SetStyle(appSt.searchSubmitBtn, "fontSize", "16px")
3706 dom.SetStyle(appSt.searchSubmitBtn, "flexShrink", "0")
3707
3708 // 🔍 toggles search: click to open (highlighted), click again to close.
3709 appSt.searchBtnGlobal = searchBtn
3710 capturedSearchBtn := searchBtn
3711 dom.AddEventListener(searchBtn, "click", dom.RegisterCallback(func() {
3712 if appSt.searchBarActive {
3713 closeSearch(capturedSearchBtn)
3714 } else {
3715 activateSearchBar(capturedSearchBtn)
3716 }
3717 }))
3718
3719 submitSearch := func() {
3720 q := dom.GetProperty(appSt.searchInputEl, "value")
3721 if q != "" {
3722 executeSearch(q)
3723 }
3724 }
3725 capturedSubmit := submitSearch
3726 dom.AddEventListener(appSt.searchSubmitBtn, "click", dom.RegisterCallback(capturedSubmit))
3727 dom.OnKeydown(appSt.searchInputEl, func(key string, prevent func()) {
3728 if key == "Enter" {
3729 prevent()
3730 capturedSubmit()
3731 } else if key == "Escape" {
3732 closeSearch(capturedSearchBtn)
3733 }
3734 })
3735
3736 // Search lives in topBarLeft so it can replace center content when active.
3737 dom.AppendChild(appSt.topBarLeft, searchBtn)
3738 dom.AppendChild(appSt.topBarLeft, appSt.searchInputEl)
3739 dom.AppendChild(appSt.topBarLeft, appSt.searchSubmitBtn)
3740
3741 // Load saved relays; fall back to defaults on first run.
3742 if !loadRelayList() {
3743 logActivity("boot", "loadRelayList empty, using defaults", "")
3744 for _, url := range appSt.defaultRelays {
3745 addRelay(url, false)
3746 }
3747 }
3748 if len(appSt.relayURLs) == 0 {
3749 logActivity("boot", "relayURLs still empty, forcing defaults", "")
3750 for _, url := range appSt.defaultRelays {
3751 addRelay(url, false)
3752 }
3753 }
3754 {
3755 relayList := ""
3756 for i, u := range appSt.relayURLs {
3757 if i > 0 {
3758 relayList = relayList | "\n"
3759 }
3760 relayList = relayList | u
3761 }
3762 logActivity("boot", "loaded " | itoa(len(appSt.relayURLs)) | " relays", relayList)
3763 }
3764
3765 // Tell SW about relays and subscribe.
3766 sendWriteRelays()
3767 subscribeProfile()
3768 logActivity("boot", "subscribed to own profile", "kinds: 0,3,10002,10000,10050 for " | appSt.pubhex)
3769 // Feed subscription is deferred until kind 3 (follow list) arrives,
3770 // so we don't subscribe with a global filter then immediately
3771 // resubscribe when the follow list shows up (which clears the feed).
3772 // Fallback: if no kind 3 arrives within 3s, subscribe anyway.
3773 appSt.feedDeferTimer = dom.SetTimeout(func() {
3774 appSt.feedDeferTimer = 0
3775 if !appSt.feedSubscribed {
3776 appSt.feedSubscribed = true
3777 logActivity("boot", "feed defer timeout, subscribing anyway", "no kind 3 in 3s")
3778 subscribeFeed()
3779 subscribeNotifications()
3780 }
3781 bootDone()
3782 }, 3000)
3783
3784 // Publish any kind 0 queued during identity derivation (relays are now live).
3785 flushPendingK0()
3786
3787 // Wire up browser history navigation.
3788 initRouter()
3789
3790 // Check for signer extension.
3791 initSigner()
3792
3793 // Scroll-to-top floaty button.
3794 scrollTopBtn := dom.CreateElement("button")
3795 dom.SetStyle(scrollTopBtn, "position", "fixed")
3796 dom.SetStyle(scrollTopBtn, "bottom", "48px")
3797 dom.SetStyle(scrollTopBtn, "right", "12px")
3798 dom.SetStyle(scrollTopBtn, "width", "36px")
3799 dom.SetStyle(scrollTopBtn, "height", "36px")
3800 dom.SetStyle(scrollTopBtn, "borderRadius", "50%")
3801 dom.SetStyle(scrollTopBtn, "background", "var(--bg2)")
3802 dom.SetStyle(scrollTopBtn, "color", "var(--fg)")
3803 dom.SetStyle(scrollTopBtn, "border", "1px solid var(--border)")
3804 dom.SetStyle(scrollTopBtn, "cursor", "pointer")
3805 dom.SetStyle(scrollTopBtn, "display", "none")
3806 dom.SetStyle(scrollTopBtn, "alignItems", "center")
3807 dom.SetStyle(scrollTopBtn, "justifyContent", "center")
3808 dom.SetStyle(scrollTopBtn, "zIndex", "99")
3809 dom.SetStyle(scrollTopBtn, "padding", "0")
3810 dom.SetInnerHTML(scrollTopBtn, `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`)
3811 dom.AddEventListener(scrollTopBtn, "click", dom.RegisterCallback(func() {
3812 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
3813 }))
3814 dom.AppendChild(dom.Body(), scrollTopBtn)
3815
3816 dom.AddEventListener(appSt.contentArea, "scroll", dom.RegisterCallback(func() {
3817 st := parseIntProp(dom.GetProperty(appSt.contentArea, "scrollTop"))
3818 show := st > 300 && (appSt.activePage == "feed" || appSt.activePage == "notifications" || appSt.activePage == "thread")
3819 if show {
3820 dom.SetStyle(scrollTopBtn, "display", "inline-flex")
3821 } else {
3822 dom.SetStyle(scrollTopBtn, "display", "none")
3823 }
3824 }))
3825
3826 appSt.appReady = true
3827 }
3828
3829 // buildStatusBar creates the bottom bar, relay popover, and signer popover.
3830 // withUser=true adds the avatar+name section (main app).
3831 func buildStatusBar(withUser bool) {
3832 appSt.bottomBar = dom.CreateElement("div")
3833 dom.SetStyle(appSt.bottomBar, "position", "fixed")
3834 dom.SetStyle(appSt.bottomBar, "bottom", "0")
3835 dom.SetStyle(appSt.bottomBar, "left", "0")
3836 dom.SetStyle(appSt.bottomBar, "right", "0")
3837 dom.SetStyle(appSt.bottomBar, "height", "36px")
3838 dom.SetStyle(appSt.bottomBar, "display", "flex")
3839 dom.SetStyle(appSt.bottomBar, "alignItems", "center")
3840 dom.SetStyle(appSt.bottomBar, "padding", "0 6px")
3841 dom.SetStyle(appSt.bottomBar, "gap", "8px")
3842 dom.SetStyle(appSt.bottomBar, "background", "var(--bg2)")
3843 dom.SetStyle(appSt.bottomBar, "fontSize", "12px")
3844 dom.SetStyle(appSt.bottomBar, "color", "var(--fg)")
3845 dom.SetStyle(appSt.bottomBar, "borderTop", "none")
3846 dom.SetStyle(appSt.bottomBar, "zIndex", "100")
3847
3848 if withUser {
3849 appSt.userBtn = dom.CreateElement("button")
3850 dom.SetStyle(appSt.userBtn, "display", "flex")
3851 dom.SetStyle(appSt.userBtn, "alignItems", "center")
3852 dom.SetStyle(appSt.userBtn, "justifyContent", "center")
3853 dom.SetStyle(appSt.userBtn, "padding", "0")
3854 dom.SetStyle(appSt.userBtn, "background", "transparent")
3855 dom.SetStyle(appSt.userBtn, "border", "none")
3856 dom.SetStyle(appSt.userBtn, "borderRadius", "6px")
3857 dom.SetStyle(appSt.userBtn, "cursor", "pointer")
3858 dom.SetStyle(appSt.userBtn, "width", "36px")
3859 dom.SetStyle(appSt.userBtn, "height", "36px")
3860
3861 appSt.avatarEl = dom.CreateElement("img")
3862 dom.SetAttribute(appSt.avatarEl, "referrerpolicy", "no-referrer")
3863 dom.SetAttribute(appSt.avatarEl, "width", "24")
3864 dom.SetAttribute(appSt.avatarEl, "height", "24")
3865 dom.SetStyle(appSt.avatarEl, "borderRadius", "50%")
3866 dom.SetStyle(appSt.avatarEl, "objectFit", "cover")
3867 dom.SetStyle(appSt.avatarEl, "display", "block")
3868 dom.SetAttribute(appSt.avatarEl, "onerror", "this.style.display='none'")
3869 dom.AppendChild(appSt.userBtn, appSt.avatarEl)
3870
3871 // nameEl kept (exists for other code paths) but detached - username
3872 // no longer appears in the status bar.
3873 appSt.nameEl = dom.CreateElement("span")
3874
3875 dom.AddEventListener(appSt.userBtn, "click", dom.RegisterCallback(func() {
3876 toggleSignerPanel()
3877 }))
3878 // userBtn is appended to iconGroup by showApp.
3879 }
3880
3881 appSt.statusEl = dom.CreateElement("button")
3882 dom.SetTextContent(appSt.statusEl, t("connecting"))
3883 dom.SetStyle(appSt.statusEl, "fontFamily", "'Fira Code', monospace")
3884 dom.SetStyle(appSt.statusEl, "fontSize", "12px")
3885 dom.SetStyle(appSt.statusEl, "background", "transparent")
3886 dom.SetStyle(appSt.statusEl, "border", "none")
3887 dom.SetStyle(appSt.statusEl, "color", "var(--muted)")
3888 dom.SetStyle(appSt.statusEl, "cursor", "pointer")
3889 dom.SetStyle(appSt.statusEl, "padding", "4px 8px")
3890 dom.SetStyle(appSt.statusEl, "borderRadius", "4px")
3891 dom.SetStyle(appSt.statusEl, "marginLeft", "auto")
3892 dom.AddEventListener(appSt.statusEl, "click", dom.RegisterCallback(func() {
3893 togglePopover()
3894 }))
3895 // statusEl (relays button) is created but not attached - all relay info is
3896 // already visible on the settings page.
3897 _ = appSt.statusEl
3898
3899 dom.AppendChild(appSt.root, appSt.bottomBar)
3900
3901 // Relay popover (hidden).
3902 appSt.popoverEl = dom.CreateElement("div")
3903 dom.SetStyle(appSt.popoverEl, "position", "fixed")
3904 dom.SetStyle(appSt.popoverEl, "bottom", "37px")
3905 dom.SetStyle(appSt.popoverEl, "right", "12px")
3906 dom.SetStyle(appSt.popoverEl, "width", "340px")
3907 dom.SetStyle(appSt.popoverEl, "maxWidth", "calc(100vw - 24px)")
3908 dom.SetStyle(appSt.popoverEl, "background", "var(--bg2)")
3909 dom.SetStyle(appSt.popoverEl, "border", "1px solid var(--border)")
3910 dom.SetStyle(appSt.popoverEl, "borderRadius", "8px")
3911 dom.SetStyle(appSt.popoverEl, "padding", "12px 16px")
3912 dom.SetStyle(appSt.popoverEl, "fontSize", "12px")
3913 dom.SetStyle(appSt.popoverEl, "display", "none")
3914 dom.SetStyle(appSt.popoverEl, "zIndex", "99")
3915
3916 appSt.popoverSummary = dom.CreateElement("div")
3917 dom.SetTextContent(appSt.popoverSummary, t("connecting"))
3918 dom.SetStyle(appSt.popoverSummary, "marginBottom", "8px")
3919 dom.SetStyle(appSt.popoverSummary, "color", "var(--muted)")
3920 dom.AppendChild(appSt.popoverEl, appSt.popoverSummary)
3921
3922 // Broadcast button - at the bottom of the relay popover.
3923 appSt.broadcastRow = dom.CreateElement("div")
3924 dom.SetStyle(appSt.broadcastRow, "borderTop", "1px solid var(--border)")
3925 dom.SetStyle(appSt.broadcastRow, "marginTop", "8px")
3926 dom.SetStyle(appSt.broadcastRow, "paddingTop", "8px")
3927 dom.SetStyle(appSt.broadcastRow, "display", "none")
3928 appSt.broadcastBtnEl = dom.CreateElement("span")
3929 dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile")
3930 dom.SetStyle(appSt.broadcastBtnEl, "cursor", "pointer")
3931 dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--accent)")
3932 dom.SetStyle(appSt.broadcastBtnEl, "fontFamily", "'Fira Code', monospace")
3933 dom.AddEventListener(appSt.broadcastBtnEl, "click", dom.RegisterCallback(func() {
3934 if appSt.pubhex == "" {
3935 return
3936 }
3937 dom.SetTextContent(appSt.broadcastBtnEl, "broadcasting...")
3938 dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--muted)")
3939 msg := "[\"BROADCAST\"," | jstr(appSt.pubhex) | ",["
3940 for i, url := range appSt.relayURLs {
3941 if i > 0 {
3942 msg = msg | ","
3943 }
3944 msg = msg | jstr(url)
3945 }
3946 routeMsg(msg | "]]")
3947 }))
3948 dom.AppendChild(appSt.broadcastRow, appSt.broadcastBtnEl)
3949 dom.AppendChild(appSt.popoverEl, appSt.broadcastRow)
3950 if appSt.pubhex != "" {
3951 dom.SetStyle(appSt.broadcastRow, "display", "block")
3952 }
3953
3954 // popoverEl (relays popup) is created but not attached - relays are on the
3955 // settings page. Downstream code still mutates the detached element harmlessly.
3956 _ = appSt.popoverEl
3957
3958 // Signer popover (hidden).
3959 buildSignerPanel()
3960 dom.AppendChild(appSt.root, appSt.signerPanel)
3961
3962 // Wallet popover (hidden).
3963 buildWalletPanel()
3964 // walletPanel is attached by renderSettings() at the bottom of the settings page.
3965
3966 // Feed selector popover (hidden).
3967 appSt.feedPopoverEl = dom.CreateElement("div")
3968 dom.SetStyle(appSt.feedPopoverEl, "position", "fixed")
3969 dom.SetStyle(appSt.feedPopoverEl, "top", "48px")
3970 dom.SetStyle(appSt.feedPopoverEl, "left", "50%")
3971 dom.SetStyle(appSt.feedPopoverEl, "transform", "translateX(-50%)")
3972 dom.SetStyle(appSt.feedPopoverEl, "width", "220px")
3973 dom.SetStyle(appSt.feedPopoverEl, "maxWidth", "calc(100vw - 24px)")
3974 dom.SetStyle(appSt.feedPopoverEl, "background", "var(--bg2)")
3975 dom.SetStyle(appSt.feedPopoverEl, "border", "1px solid var(--border)")
3976 dom.SetStyle(appSt.feedPopoverEl, "borderRadius", "8px")
3977 dom.SetStyle(appSt.feedPopoverEl, "padding", "8px")
3978 dom.SetStyle(appSt.feedPopoverEl, "fontSize", "14px")
3979 dom.SetStyle(appSt.feedPopoverEl, "display", "none")
3980 dom.SetStyle(appSt.feedPopoverEl, "zIndex", "99")
3981 dom.AppendChild(appSt.root, appSt.feedPopoverEl)
3982 }
3983
3984 // relayCap is a parsed NIP-11 Relay Information Document snapshot.
3985 // Values are populated asynchronously after addRelay; fetchedOK=false means
3986 // either the fetch hasn't completed yet or the relay was unreachable.
3987 type relayCap struct {
3988 name string
3989 adminPubkey string // hex, from NIP-11 "pubkey" field (operator identity)
3990 supportedNIPs []int32 // NIP-11 "supported_nips" array
3991 authRequired bool // limitation.auth_required
3992 paymentReq bool // limitation.payment_required
3993 restrictedW bool // limitation.restricted_writes
3994 fetchedOK bool // true once a non-empty NIP-11 document has been parsed
3995 }
3996
3997 // hasNIP reports whether a supported_nips list contains the given NIP number.
3998 func hasNIP(nips []int32, n int32) (ok bool) {
3999 for _, x := range nips {
4000 if x == n {
4001 return true
4002 }
4003 }
4004 return false
4005 }
4006
4007 // makeRelayBadge creates a small warning pill like "!42" or "!70".
4008 func makeRelayBadge(text string) (e dom.Element) {
4009 b := dom.CreateElement("span")
4010 dom.SetTextContent(b, text)
4011 dom.SetStyle(b, "display", "inline-block")
4012 dom.SetStyle(b, "background", "#c44")
4013 dom.SetStyle(b, "color", "#fff")
4014 dom.SetStyle(b, "padding", "1px 5px")
4015 dom.SetStyle(b, "borderRadius", "3px")
4016 dom.SetStyle(b, "fontSize", "10px")
4017 dom.SetStyle(b, "marginLeft", "4px")
4018 dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
4019 return b
4020 }
4021
4022 // updateRelayBadges rewrites the badge container for the relay at index i
4023 // based on the current relayCaps[i]. Missing NIP-42 and NIP-70 support each
4024 // produces a warning pill. Called from the NIP-11 fetch callback.
4025 func updateRelayBadges(i int32) {
4026 if i < 0 || i >= len(appSt.relayBadgeContainers) {
4027 return
4028 }
4029 container := appSt.relayBadgeContainers[i]
4030 clearChildren(container)
4031 caps := appSt.relayCaps[i]
4032 if !hasNIP(caps.supportedNIPs, 42) {
4033 dom.AppendChild(container, makeRelayBadge("!42"))
4034 }
4035 if !hasNIP(caps.supportedNIPs, 70) {
4036 dom.AppendChild(container, makeRelayBadge("!70"))
4037 }
4038 if hasNIP(caps.supportedNIPs, 50) {
4039 badge := makeRelayBadge("search")
4040 dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
4041 dom.SetStyle(badge, "color", "#0a0")
4042 dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
4043 dom.AppendChild(container, badge)
4044 }
4045 }
4046
4047 // parseNIP11Caps parses a NIP-11 Relay Information Document body into a relayCap.
4048 // Returns a zero value (fetchedOK=false) for empty input.
4049 func parseNIP11Caps(body string) (v relayCap) {
4050 var c relayCap
4051 if body == "" {
4052 return c
4053 }
4054 c.fetchedOK = true
4055 c.name = helpers.JsonGetString(body, "name")
4056 c.adminPubkey = helpers.JsonGetString(body, "pubkey")
4057 c.supportedNIPs = helpers.JsonGetIntArray(body, "supported_nips")
4058 lim := helpers.JsonGetValue(body, "limitation")
4059 if lim != "" {
4060 c.authRequired = helpers.JsonGetBool(lim, "auth_required")
4061 c.paymentReq = helpers.JsonGetBool(lim, "payment_required")
4062 c.restrictedW = helpers.JsonGetBool(lim, "restricted_writes")
4063 }
4064 return c
4065 }
4066
4067 // loadRelayPolicy reads the persisted relay policy bitfield and blocklist from
4068 // localStorage. Called once at startup before any relay logic runs.
4069 func loadRelayPolicy() {
4070 if s := localstorage.GetItem(lsKeyRelayPolicy); s != "" {
4071 appSt.relayPolicyFlags = parseIntProp(s)
4072 }
4073 if s := localstorage.GetItem(lsKeyRelayBlocklist); s != "" {
4074 appSt.relayBlocklist = splitLines(s)
4075 }
4076 }
4077
4078 // saveRelayPolicy persists relayPolicyFlags and relayBlocklist to localStorage.
4079 // Called from the settings UI whenever the user changes a toggle or the
4080 // blocklist is mutated. Does not publish kind 10006 - that is done explicitly
4081 // by the blocklist mutation helpers.
4082 func saveRelayPolicy() {
4083 localstorage.SetItem(lsKeyRelayPolicy, itoa(appSt.relayPolicyFlags))
4084 localstorage.SetItem(lsKeyRelayBlocklist, joinLines(appSt.relayBlocklist))
4085 }
4086
4087 // saveRelayList persists the current relay URLs and roles to localStorage.
4088 func saveRelayList() {
4089 localstorage.SetItem(lsKeyRelayList, joinLines(appSt.relayURLs))
4090 roles := ""
4091 for i, r := range appSt.relayRole {
4092 if i > 0 {
4093 roles = roles | "\n"
4094 }
4095 roles = roles | r
4096 }
4097 localstorage.SetItem(lsKeyRelayRoles, roles)
4098 }
4099
4100 // loadRelayList reads persisted relay URLs and roles and adds them.
4101 // Returns true if any were loaded (so defaults can be skipped).
4102 func loadRelayList() (ok bool) {
4103 s := localstorage.GetItem(lsKeyRelayList)
4104 if s == "" {
4105 return false
4106 }
4107 urls := splitLines(s)
4108 roles := splitLines(localstorage.GetItem(lsKeyRelayRoles))
4109 added := 0
4110 for i, u := range urls {
4111 if len(u) == 0 {
4112 continue
4113 }
4114 role := "both"
4115 if i < len(roles) && (roles[i] == "read" || roles[i] == "write") {
4116 role = roles[i]
4117 }
4118 addRelayWithRole(u, false, role)
4119 added++
4120 }
4121 return added > 0
4122 }
4123
4124 // isBlocked reports whether url (already normalized) is in relayBlocklist.
4125 var dnsBlacklist []string
4126
4127 func isBlocked(url string) (ok bool) {
4128 for _, b := range dnsBlacklist {
4129 if b == url {
4130 return true
4131 }
4132 }
4133 for _, b := range appSt.relayBlocklist {
4134 if b == url {
4135 return true
4136 }
4137 }
4138 return false
4139 }
4140
4141 // removeRelayByURL tears down the popover row for url and splices it out of
4142 // all parallel relay slices. No-op if url is not present.
4143 func removeRelayByURL(url string) {
4144 url = normalizeURL(url)
4145 for i, u := range appSt.relayURLs {
4146 if u != url {
4147 continue
4148 }
4149 // Detach popover row.
4150 dom.RemoveChild(appSt.popoverEl, appSt.relayRows[i])
4151 // Splice all parallel slices.
4152 appSt.relayURLs = appSt.relayURLs[:i] | appSt.relayURLs[i+1:]
4153 appSt.relayRows = appSt.relayRows[:i] | appSt.relayRows[i+1:]
4154 appSt.relayDots = appSt.relayDots[:i] | appSt.relayDots[i+1:]
4155 appSt.relayLabels = appSt.relayLabels[:i] | appSt.relayLabels[i+1:]
4156 appSt.relayBadgeContainers = appSt.relayBadgeContainers[:i] | appSt.relayBadgeContainers[i+1:]
4157 appSt.relayUserPick = appSt.relayUserPick[:i] | appSt.relayUserPick[i+1:]
4158 appSt.relayRole = appSt.relayRole[:i] | appSt.relayRole[i+1:]
4159 appSt.relayReadBtns = appSt.relayReadBtns[:i] | appSt.relayReadBtns[i+1:]
4160 appSt.relayWriteBtns = appSt.relayWriteBtns[:i] | appSt.relayWriteBtns[i+1:]
4161 appSt.relayCaps = appSt.relayCaps[:i] | appSt.relayCaps[i+1:]
4162 appSt.relayCapsTs = appSt.relayCapsTs[:i] | appSt.relayCapsTs[i+1:]
4163 saveRelayList()
4164 updateStatus()
4165 return
4166 }
4167 }
4168
4169 // addToBlocklist records url in relayBlocklist, tears down any active row for
4170 // it, persists, and publishes kind 10006. Called only from explicit UI action.
4171 func addToBlocklist(url string) {
4172 url = normalizeURL(url)
4173 if isBlocked(url) {
4174 return
4175 }
4176 appSt.relayBlocklist = mxutil.Ensure(appSt.relayBlocklist, 1)
4177 appSt.relayBlocklist = push(appSt.relayBlocklist, url)
4178 removeRelayByURL(url)
4179 saveRelayPolicy()
4180 publishKind10006()
4181 }
4182
4183 // removeFromBlocklist unblocks url, persists, and publishes kind 10006. Does
4184 // not auto-re-add the relay - user must add it back explicitly if desired.
4185 func removeFromBlocklist(url string) {
4186 url = normalizeURL(url)
4187 for i, b := range appSt.relayBlocklist {
4188 if b == url {
4189 appSt.relayBlocklist = appSt.relayBlocklist[:i] | appSt.relayBlocklist[i+1:]
4190 saveRelayPolicy()
4191 publishKind10006()
4192 return
4193 }
4194 }
4195 }
4196
4197 // publishKind10006 signs and publishes a NIP-51 "Blocked Relays" list. Called
4198 // only on explicit blocklist mutation - never from loadRelayPolicy, so merely
4199 // loading musiquay does not re-publish the list.
4200 func publishKind10006() {
4201 if !signer.HasSigner() || appSt.pubhex == "" {
4202 return
4203 }
4204 // Build tags: [["relay","wss://..."], ...]
4205 tags := "["
4206 for i, u := range appSt.relayBlocklist {
4207 if i > 0 {
4208 tags = tags | ","
4209 }
4210 tags = tags | "[\"relay\"," | jstr(u) | "]"
4211 }
4212 tags = tags | "]"
4213 ts := dom.NowSeconds()
4214 unsigned := "{\"kind\":10006,\"content\":\"\",\"tags\":" | tags |
4215 ",\"created_at\":" | i64toa(ts) |
4216 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
4217 signer.SignEvent(unsigned, func(signed string) {
4218 if signed != "" {
4219 routeMsg("[\"EVENT\"," | signed | "]")
4220 dom.ConsoleLog("[blocklist] published kind 10006 with " | itoa(len(appSt.relayBlocklist)) | " entries")
4221 }
4222 })
4223 }
4224
4225 // publishProtocol signs a musiquay protocol payload and publishes it as a bare
4226 // event. The vocabulary renders its own tag list and the envelope is the only
4227 // part built here, so this is one place for every public kind.
4228 //
4229 // The private kinds - the purchase order (3222) and the delivery receipt
4230 // (32218) - never reach this helper: they travel only inside a NIP-59 wrap and
4231 // a relay rejects them bare.
4232 func publishProtocol(kind uint16, tags [][]string, content string) {
4233 if !signer.HasSigner() || appSt.pubhex == "" {
4234 return
4235 }
4236 dom.ConsoleLog("[proto] publishing kind " | itoa(int32(kind)) | " (" | protocol.KindName(kind) | ")")
4237 ts := dom.NowSeconds()
4238 unsigned := protocolwire.UnsignedJSON(kind, protocolwire.TagsJSON(tags), content, appSt.pubhex, ts)
4239 signer.SignEvent(unsigned, func(signed string) {
4240 if signed != "" {
4241 routeMsg("[\"EVENT\"," | signed | "]")
4242 }
4243 })
4244 }
4245
4246 // The named wrappers below are what app code calls; each hands the vocabulary's
4247 // own tag list to the one publish path above.
4248
4249 func publishTrack(v *protocol.Track) {
4250 publishProtocol(protocol.KindTrack, v.Tags(), v.Content)
4251 }
4252
4253 func publishAlbum(v *protocol.Album) {
4254 publishProtocol(protocol.KindAlbum, v.Tags(), v.Content)
4255 }
4256
4257 func publishPlaylist(v *protocol.Playlist) {
4258 publishProtocol(protocol.KindPlaylist, v.Tags(), v.Content)
4259 }
4260
4261 func publishSetlist(v *protocol.Setlist) {
4262 publishProtocol(protocol.KindSetlist, v.Tags(), v.Content)
4263 }
4264
4265 func publishReleaseCredits(v *protocol.ReleaseCredits) {
4266 publishProtocol(protocol.KindReleaseCredits, v.Tags(), v.Content)
4267 }
4268
4269 func publishTrackCredits(v *protocol.TrackCredits) {
4270 publishProtocol(protocol.KindTrackCredits, v.Tags(), v.Content)
4271 }
4272
4273 func publishEdition(v *protocol.Edition) {
4274 publishProtocol(protocol.KindEdition, v.Tags(), v.Content)
4275 }
4276
4277 func publishAccessFilter(v *protocol.AccessFilter) {
4278 publishProtocol(protocol.KindAccessFilter, v.Tags(), v.Content)
4279 }
4280
4281 func publishPlayReport(v *protocol.PlayReport) {
4282 publishProtocol(protocol.KindPlayReport, v.Tags(), v.Content)
4283 }
4284
4285 func publishCalendarEvent(v *protocol.CalendarEvent) {
4286 publishProtocol(protocol.KindCalendar, v.Tags(), v.Content)
4287 }
4288
4289 func publishListing(v *protocol.Listing) {
4290 publishProtocol(protocol.KindListing, v.Tags(), v.Content)
4291 }
4292
4293 // The NIP-15 payloads are JSON in content with no tag vocabulary; the only tag
4294 // is the d that makes the event addressable, and it is the payload's id.
4295 func publishStall(v *protocol.Stall) {
4296 tags := [][]string{[]string{protocol.TagD, v.ID}}
4297 publishProtocol(protocol.KindStall, tags, v.JSON())
4298 }
4299
4300 func publishProduct(v *protocol.Product) {
4301 tags := [][]string{[]string{protocol.TagD, v.ID}}
4302 publishProtocol(protocol.KindProduct, tags, v.JSON())
4303 }
4304
4305 // addRelay adds a relay with role "both".
4306 func addRelay(url string, userPick bool) {
4307 addRelayWithRole(url, userPick, "both")
4308 }
4309
4310 // applyRelayRoleBtns sets the active/inactive styling on the r/w buttons.
4311 func applyRelayRoleBtns(rBtn, wBtn dom.Element, role string) {
4312 canRead := role == "both" || role == "read"
4313 canWrite := role == "both" || role == "write"
4314 if canRead {
4315 dom.SetStyle(rBtn, "background", "var(--bg2)")
4316 dom.SetStyle(rBtn, "color", "var(--fg)")
4317 dom.SetStyle(rBtn, "borderColor", "var(--fg)")
4318 } else {
4319 dom.SetStyle(rBtn, "background", "transparent")
4320 dom.SetStyle(rBtn, "color", "var(--muted)")
4321 dom.SetStyle(rBtn, "borderColor", "var(--border)")
4322 }
4323 if canWrite {
4324 dom.SetStyle(wBtn, "background", "var(--bg2)")
4325 dom.SetStyle(wBtn, "color", "var(--fg)")
4326 dom.SetStyle(wBtn, "borderColor", "var(--fg)")
4327 } else {
4328 dom.SetStyle(wBtn, "background", "transparent")
4329 dom.SetStyle(wBtn, "color", "var(--muted)")
4330 dom.SetStyle(wBtn, "borderColor", "var(--border)")
4331 }
4332 }
4333
4334 // addRelayWithRole adds a relay to the list and creates its popover row.
4335 // role is "both" (read+write), "read", or "write".
4336 func addRelayWithRole(url string, userPick bool, role string) {
4337 url = normalizeURL(url)
4338 // Blocklist guard - never attach a blocked relay, regardless of source.
4339 if isBlocked(url) {
4340 return
4341 }
4342 // Dedup.
4343 for i, u := range appSt.relayURLs {
4344 if u == url {
4345 if userPick && !appSt.relayUserPick[i] {
4346 appSt.relayUserPick[i] = true
4347 dom.SetStyle(appSt.relayLabels[i], "fontWeight", "bold")
4348 }
4349 return
4350 }
4351 }
4352
4353 appSt.relayURLs = mxutil.Ensure(appSt.relayURLs, 1)
4354 appSt.relayURLs = push(appSt.relayURLs, url)
4355 appSt.relayUserPick = mxutil.Ensure(appSt.relayUserPick, 1)
4356 appSt.relayUserPick = push(appSt.relayUserPick, userPick)
4357 appSt.relayRole = mxutil.Ensure(appSt.relayRole, 1)
4358 appSt.relayRole = push(appSt.relayRole, role)
4359 appSt.relayCaps = mxutil.Ensure(appSt.relayCaps, 1)
4360 appSt.relayCaps = push(appSt.relayCaps, relayCap{})
4361 appSt.relayCapsTs = mxutil.Ensure(appSt.relayCapsTs, 1)
4362 appSt.relayCapsTs = push(appSt.relayCapsTs, 0)
4363 saveRelayList()
4364
4365 // Popover row.
4366 row := dom.CreateElement("div")
4367 dom.SetStyle(row, "padding", "3px 0")
4368 dom.SetStyle(row, "display", "flex")
4369 dom.SetStyle(row, "alignItems", "center")
4370 dom.SetStyle(row, "gap", "4px")
4371 appSt.relayRows = mxutil.Ensure(appSt.relayRows, 1)
4372 appSt.relayRows = push(appSt.relayRows, row)
4373
4374 dot := dom.CreateElement("span")
4375 dom.SetTextContent(dot, "\u25CF")
4376 dom.SetStyle(dot, "color", "#5b5")
4377 dom.SetStyle(dot, "flexShrink", "0")
4378 appSt.relayDots = mxutil.Ensure(appSt.relayDots, 1)
4379 appSt.relayDots = push(appSt.relayDots, dot)
4380 dom.AppendChild(row, dot)
4381
4382 label := dom.CreateElement("span")
4383 dom.SetTextContent(label, url)
4384 dom.SetStyle(label, "flex", "1")
4385 dom.SetStyle(label, "overflow", "hidden")
4386 dom.SetStyle(label, "textOverflow", "ellipsis")
4387 dom.SetStyle(label, "whiteSpace", "nowrap")
4388 if userPick {
4389 dom.SetStyle(label, "fontWeight", "bold")
4390 }
4391 appSt.relayLabels = mxutil.Ensure(appSt.relayLabels, 1)
4392 appSt.relayLabels = push(appSt.relayLabels, label)
4393 dom.AppendChild(row, label)
4394
4395 // Separate read / write toggle buttons.
4396 makeRoleBtn := func(label string) dom.Element {
4397 b := dom.CreateElement("button")
4398 dom.SetTextContent(b, label)
4399 dom.SetStyle(b, "flexShrink", "0")
4400 dom.SetStyle(b, "fontSize", "10px")
4401 dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
4402 dom.SetStyle(b, "padding", "1px 5px")
4403 dom.SetStyle(b, "borderRadius", "3px")
4404 dom.SetStyle(b, "border", "1px solid var(--border)")
4405 dom.SetStyle(b, "cursor", "pointer")
4406 return b
4407 }
4408 rBtn := makeRoleBtn("r")
4409 wBtn := makeRoleBtn("w")
4410 appSt.relayReadBtns = mxutil.Ensure(appSt.relayReadBtns, 1)
4411 appSt.relayReadBtns = push(appSt.relayReadBtns, rBtn)
4412 appSt.relayWriteBtns = mxutil.Ensure(appSt.relayWriteBtns, 1)
4413 appSt.relayWriteBtns = push(appSt.relayWriteBtns, wBtn)
4414 applyRelayRoleBtns(rBtn, wBtn, role)
4415
4416 capturedURL := url
4417 dom.AddEventListener(rBtn, "click", dom.RegisterCallback(func() {
4418 for i, u := range appSt.relayURLs {
4419 if u != capturedURL {
4420 continue
4421 }
4422 switch appSt.relayRole[i] {
4423 case "both":
4424 appSt.relayRole[i] = "write"
4425 case "read":
4426 appSt.relayRole[i] = "write"
4427 default: // "write" - re-enable read
4428 appSt.relayRole[i] = "both"
4429 }
4430 applyRelayRoleBtns(appSt.relayReadBtns[i], appSt.relayWriteBtns[i], appSt.relayRole[i])
4431 saveRelayList()
4432 sendWriteRelays()
4433 break
4434 }
4435 }))
4436 dom.AddEventListener(wBtn, "click", dom.RegisterCallback(func() {
4437 for i, u := range appSt.relayURLs {
4438 if u != capturedURL {
4439 continue
4440 }
4441 switch appSt.relayRole[i] {
4442 case "both":
4443 appSt.relayRole[i] = "read"
4444 case "write":
4445 appSt.relayRole[i] = "read"
4446 default: // "read" - re-enable write
4447 appSt.relayRole[i] = "both"
4448 }
4449 applyRelayRoleBtns(appSt.relayReadBtns[i], appSt.relayWriteBtns[i], appSt.relayRole[i])
4450 saveRelayList()
4451 sendWriteRelays()
4452 break
4453 }
4454 }))
4455 // Empty badge container - populated when NIP-11 fetch completes.
4456 badgeBox := dom.CreateElement("span")
4457 dom.SetStyle(badgeBox, "flexShrink", "0")
4458 appSt.relayBadgeContainers = mxutil.Ensure(appSt.relayBadgeContainers, 1)
4459 appSt.relayBadgeContainers = push(appSt.relayBadgeContainers, badgeBox)
4460 dom.AppendChild(row, badgeBox)
4461
4462 // r/w buttons at far right.
4463 dom.SetStyle(rBtn, "marginLeft", "auto")
4464 dom.AppendChild(row, rBtn)
4465 dom.AppendChild(row, wBtn)
4466
4467 dom.InsertBefore(appSt.popoverEl, row, appSt.broadcastRow)
4468 updateStatus()
4469 if appSt.feedSelectEl != 0 {
4470 populateFeedSelect()
4471 }
4472
4473 // Fetch NIP-11 capability document asynchronously. The callback captures
4474 // `url` (a fresh parameter per call, not a loop variable) and looks the
4475 // index up at callback time so it still lands correctly if the list grew.
4476 httpURL := url
4477 if len(httpURL) > 6 && httpURL[:6] == "wss://" {
4478 httpURL = "https://" | httpURL[6:]
4479 } else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
4480 httpURL = "http://" | httpURL[5:]
4481 }
4482 dom.FetchRelayInfo(httpURL, func(body string) {
4483 caps := parseNIP11Caps(body)
4484 for i, u := range appSt.relayURLs {
4485 if u == url {
4486 appSt.relayCaps[i] = caps
4487 appSt.relayCapsTs[i] = dom.NowSeconds()
4488 updateRelayBadges(i)
4489 dom.ConsoleLog("[caps] " | url |
4490 " ok=" | boolStr(caps.fetchedOK) |
4491 " nip42=" | boolStr(hasNIP(caps.supportedNIPs, 42)) |
4492 " nip70=" | boolStr(hasNIP(caps.supportedNIPs, 70)) |
4493 " authReq=" | boolStr(caps.authRequired))
4494 return
4495 }
4496 }
4497 })
4498 }
4499
4500 func togglePopover() {
4501 appSt.popoverOpen = !appSt.popoverOpen
4502 if appSt.popoverOpen {
4503 if appSt.signerOpen {
4504 hideSignerPanel()
4505 }
4506 closeFeedPopover()
4507 dom.SetStyle(appSt.popoverEl, "display", "block")
4508 dom.SetStyle(appSt.statusEl, "background", "var(--accent)")
4509 dom.SetStyle(appSt.statusEl, "color", "var(--bg)")
4510 } else {
4511 dom.SetStyle(appSt.popoverEl, "display", "none")
4512 dom.SetStyle(appSt.statusEl, "background", "transparent")
4513 dom.SetStyle(appSt.statusEl, "color", "var(--muted)")
4514 }
4515 }
4516
4517 func subscribeProfile() {
4518 dom.ConsoleLog("[prof] subscribing for pubkey: " | appSt.pubhex)
4519 rr := readRelayURLs()
4520 proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(rr)}
4521 copy(proxy, discoveryRelays)
4522 for _, u := range rr {
4523 proxy = appendUnique(proxy, u)
4524 }
4525 routeMsg(buildProxyMsg("prof",
4526 "{\"authors\":[" | jstr(appSt.pubhex) | "],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}",
4527 proxy))
4528 routeMsg(buildProxyMsg("prof-settings",
4529 "{\"authors\":[" | jstr(appSt.pubhex) | "],\"kinds\":[30078],\"#d\":[\"musiquay-settings\"],\"limit\":1}",
4530 proxy))
4531 }
4532
4533
4534 func subscribeFeed() {
4535 // Throttle: collapse rapid calls into one after 2s of quiet.
4536 // Also enforce 10s cooldown - don't resubscribe if nothing changed.
4537 appSt.feedSubscribed = true
4538 if appSt.feedSubTimer != 0 {
4539 dom.ClearTimeout(appSt.feedSubTimer)
4540 }
4541 appSt.feedSubTimer = dom.SetTimeout(func() {
4542 appSt.feedSubTimer = 0
4543 now := dom.NowSeconds()
4544 if appSt.feedLastSubTs > 0 && now-appSt.feedLastSubTs < 10 {
4545 return
4546 }
4547 doSubscribeFeed()
4548 }, 2000)
4549 }
4550
4551 func doSubscribeFeed() {
4552 // Musiquay base: the kind-1 forum feed is not part of this build. The
4553 // subscription and rendering code below stays for the port.
4554 return
4555
4556 appSt.feedLastSubTs = dom.NowSeconds()
4557 dom.ConsoleLog("[sub] feed mode=" | appSt.feedMode | " follows=" | itoa(len(appSt.myFollows)))
4558 appSt.feedExhausted = false
4559 appSt.feedEmptyStreak = 0
4560 appSt.feedInitialLoad = true
4561 if appSt.feedInitialLoadTimer != 0 {
4562 dom.ClearTimeout(appSt.feedInitialLoadTimer)
4563 appSt.feedInitialLoadTimer = 0
4564 }
4565 appSt.feedBuffer = nil
4566 appSt.feedHasNew = false
4567 stopRefreshPulse()
4568 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
4569
4570 // Sync state to Feed Worker, then tell it to subscribe.
4571 // Feed Worker owns the relay subscriptions via F_SUB/F_CLOSE.
4572 feed.Send(`["F_SET_MODE",` | jstr(appSt.feedMode) | `]`)
4573 feed.Send(`["F_SET_RELAYS",` | readRelayURLsJSON() | `]`)
4574 feed.Send(`["F_SET_PUBKEY",` | jstr(appSt.pubhex) | `]`)
4575 if len(appSt.myFollows) > 0 {
4576 feed.Send(`["F_SET_FOLLOWS",` | buildJSONStrArr(appSt.myFollows) | `]`)
4577 }
4578 if len(appSt.myMutes) > 0 {
4579 feed.Send(`["F_SET_MUTES",` | buildJSONStrArr(appSt.myMutes) | `]`)
4580 }
4581 feed.Send(`["F_REFRESH"]`)
4582 }
4583
4584 func buildFeedFilter(limit int32) (s string) {
4585 if appSt.feedMode == "follows" {
4586 follows := appSt.myFollows
4587 if len(follows) > 0 {
4588 // Always include own pubkey so user's own posts appear in the feed.
4589 authors := jstr(appSt.pubhex)
4590 for _, pk := range follows {
4591 if pk != appSt.pubhex {
4592 authors = authors | "," | jstr(pk)
4593 }
4594 }
4595 return "{\"kinds\":[1,6,7,1111],\"authors\":[" | authors | "],\"limit\":" | itoa(limit) | "}"
4596 }
4597 }
4598 return "{\"kinds\":[1,6,7,1111],\"limit\":" | itoa(limit) | "}"
4599 }
4600
4601 func feedPassesFilter(ev *nostr.Event) (ok bool) {
4602 if appSt.feedMode != "follows" {
4603 return true
4604 }
4605 follows := appSt.myFollows
4606 if len(follows) == 0 {
4607 return true
4608 }
4609 for _, pk := range follows {
4610 if pk == ev.PubKey {
4611 return true
4612 }
4613 }
4614 // Also allow own events.
4615 return ev.PubKey == appSt.pubhex
4616 }
4617
4618 func feedRelays() (ss []string) {
4619 if appSt.feedMode != "" && appSt.feedMode != "follows" && appSt.feedMode != "relays" {
4620 // Single relay mode.
4621 return []string{appSt.feedMode}
4622 }
4623 return appSt.relayURLs
4624 }
4625
4626 func feedModeURL() (s string) {
4627 switch appSt.feedMode {
4628 case "", "follows":
4629 return "/feed/follows"
4630 case "relays":
4631 return "/feed/relays"
4632 }
4633 // Per-relay: strip ws:// or wss:// for the URL path.
4634 host := stripScheme(appSt.feedMode)
4635 return "/feed/relay/" | host
4636 }
4637
4638 func stripScheme(url string) (s string) {
4639 if len(url) > 6 && url[:6] == "wss://" {
4640 return url[6:]
4641 }
4642 if len(url) > 5 && url[:5] == "ws://" {
4643 return url[5:]
4644 }
4645 return url
4646 }
4647
4648
4649 func loadOlderFeed() {
4650 appSt.feedLoading = true
4651 appSt.feedMoreGot = 0
4652 feed.Send(`["F_LOAD_MORE"]`)
4653 if appSt.feedMoreTimer != 0 {
4654 dom.ClearTimeout(appSt.feedMoreTimer)
4655 }
4656 appSt.feedMoreTimer = dom.SetTimeout(func() {
4657 appSt.feedMoreTimer = 0
4658 if appSt.feedLoading {
4659 dom.ConsoleLog("[loadOlderFeed] timeout - forcing feedLoading=false, got=" | itoa(appSt.feedMoreGot))
4660 appSt.feedLoading = false
4661 if appSt.feedMoreGot == 0 {
4662 appSt.feedEmptyStreak++
4663 if appSt.feedEmptyStreak >= 3 {
4664 appSt.feedExhausted = true
4665 }
4666 } else {
4667 appSt.feedEmptyStreak = 0
4668 }
4669 if appSt.feedExhausted {
4670 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
4671 } else if appSt.oldestFeedTs > 0 {
4672 dom.SetStyle(appSt.loadMoreBtn, "display", "block")
4673 }
4674 updateStatus()
4675 }
4676 }, 10000)
4677 }
4678
4679 func populateFeedSelect() {
4680 updateFeedBtnText()
4681 if appSt.feedPopoverEl == 0 {
4682 return
4683 }
4684 clearChildren(appSt.feedPopoverEl)
4685 if len(appSt.myFollows) > 0 {
4686 addFeedOption("follows", "follows")
4687 }
4688 addFeedOption("relays", "relays")
4689 for _, u := range appSt.relayURLs {
4690 addFeedOption(u, stripScheme(u))
4691 }
4692 }
4693
4694 func addFeedOption(value, label string) {
4695 row := dom.CreateElement("div")
4696 dom.SetStyle(row, "padding", "6px 8px")
4697 dom.SetStyle(row, "cursor", "pointer")
4698 dom.SetStyle(row, "borderRadius", "4px")
4699 if value == appSt.feedMode {
4700 dom.SetStyle(row, "background", "var(--accent)")
4701 dom.SetStyle(row, "color", "var(--bg)")
4702 }
4703 dom.SetTextContent(row, label)
4704 v := value
4705 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
4706 appSt.feedMode = v
4707 updateFeedBtnText()
4708 dom.PushState(feedModeURL())
4709 closeFeedPopover()
4710 refreshFeed()
4711 }))
4712 dom.AppendChild(appSt.feedPopoverEl, row)
4713 }
4714
4715 func updateFeedBtnText() {
4716 if appSt.feedSelectEl == 0 {
4717 return
4718 }
4719 label := appSt.feedMode
4720 if label == "follows" && len(appSt.myFollows) == 0 {
4721 label = "all relays"
4722 }
4723 if label != "follows" && label != "relays" && label != "all relays" {
4724 label = stripScheme(label)
4725 }
4726 dom.SetTextContent(appSt.feedSelectEl, label | " \u25BE")
4727 }
4728
4729 func toggleFeedPopover() {
4730 if appSt.feedPopoverOpen {
4731 closeFeedPopover()
4732 return
4733 }
4734 if appSt.signerOpen {
4735 hideSignerPanel()
4736 }
4737 if appSt.popoverOpen {
4738 togglePopover()
4739 }
4740 appSt.feedPopoverOpen = true
4741 populateFeedSelect()
4742 dom.SetStyle(appSt.feedPopoverEl, "display", "block")
4743 dom.SetStyle(appSt.feedSelectEl, "background", "var(--accent)")
4744 dom.SetStyle(appSt.feedSelectEl, "color", "var(--bg)")
4745 }
4746
4747 func closeFeedPopover() {
4748 if !appSt.feedPopoverOpen {
4749 return
4750 }
4751 appSt.feedPopoverOpen = false
4752 dom.SetStyle(appSt.feedPopoverEl, "display", "none")
4753 dom.SetStyle(appSt.feedSelectEl, "background", "transparent")
4754 dom.SetStyle(appSt.feedSelectEl, "color", "var(--fg)")
4755 updateFeedBtnText()
4756 }
4757
4758 func onRefreshClick() {
4759 if appSt.refreshSpinning {
4760 return
4761 }
4762 // Flush buffered new events into the feed.
4763 if len(appSt.feedBuffer) > 0 {
4764 for _, ev := range appSt.feedBuffer {
4765 renderNote(ev)
4766 appSt.seenEvents[ev.ID] = true
4767 }
4768 appSt.feedBuffer = nil
4769 appSt.feedHasNew = false
4770 stopRefreshPulse()
4771 }
4772 // Scroll to top.
4773 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
4774 // Start spin animation.
4775 startRefreshSpin()
4776 // Cancel throttle and resubscribe immediately.
4777 if appSt.feedSubTimer != 0 {
4778 dom.ClearTimeout(appSt.feedSubTimer)
4779 appSt.feedSubTimer = 0
4780 }
4781 appSt.feedSubscribed = true
4782 doSubscribeFeed()
4783 }
4784
4785 func refreshFeed() {
4786 // Clear feed container.
4787 clearChildren(appSt.feedContainer)
4788 appSt.seenEvents = map[string]bool{}
4789 appSt.noteElements = map[string]dom.Element{}
4790 appSt.eventCount = 0
4791 appSt.oldestFeedTs = 0
4792 appSt.feedExhausted = false
4793 appSt.feedEmptyStreak = 0
4794 appSt.feedBuffer = nil
4795 appSt.feedHasNew = false
4796 stopRefreshPulse()
4797 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
4798 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
4799 // Cancel any pending throttled sub and fire immediately.
4800 if appSt.feedSubTimer != 0 {
4801 dom.ClearTimeout(appSt.feedSubTimer)
4802 appSt.feedSubTimer = 0
4803 }
4804 startRefreshSpin()
4805 appSt.feedSubscribed = true
4806 doSubscribeFeed()
4807 }
4808
4809
4810 func startRefreshPulse() {
4811 if refreshBtn == 0 || appSt.refreshPulseTimer != 0 {
4812 return
4813 }
4814 dom.SetStyle(refreshBtn, "color", "var(--accent)")
4815 appSt.refreshPulseOn = true
4816 pulseStep()
4817 }
4818
4819 func pulseStep() {
4820 if appSt.refreshPulseTimer == -1 || refreshBtn == 0 {
4821 return
4822 }
4823 if appSt.refreshPulseOn {
4824 dom.SetStyle(refreshBtn, "opacity", "0.4")
4825 } else {
4826 dom.SetStyle(refreshBtn, "opacity", "1")
4827 }
4828 appSt.refreshPulseOn = !appSt.refreshPulseOn
4829 appSt.refreshPulseTimer = dom.SetTimeout(func() {
4830 pulseStep()
4831 }, 800)
4832 }
4833
4834 func stopRefreshPulse() {
4835 if appSt.refreshPulseTimer != 0 {
4836 dom.ClearTimeout(appSt.refreshPulseTimer)
4837 }
4838 appSt.refreshPulseTimer = -1
4839 dom.SetTimeout(func() {
4840 appSt.refreshPulseTimer = 0
4841 }, 0)
4842 if refreshBtn != 0 {
4843 dom.SetStyle(refreshBtn, "color", "var(--muted)")
4844 dom.SetStyle(refreshBtn, "opacity", "1")
4845 }
4846 }
4847
4848
4849 func startRefreshSpin() {
4850 appSt.refreshSpinning = true
4851 if refreshBtn == 0 {
4852 return
4853 }
4854 appSt.refreshSpinAngle = 0
4855 spinStep()
4856 }
4857
4858 func spinStep() {
4859 if !appSt.refreshSpinning || refreshBtn == 0 {
4860 return
4861 }
4862 appSt.refreshSpinAngle += 30
4863 dom.SetStyle(refreshBtn, "transform", "rotate(" | itoa(appSt.refreshSpinAngle) | "deg)")
4864 appSt.refreshSpinTimer = dom.SetTimeout(func() {
4865 spinStep()
4866 }, 50)
4867 }
4868
4869 func stopRefreshSpin() {
4870 appSt.refreshSpinning = false
4871 if appSt.refreshSpinTimer != 0 {
4872 dom.ClearTimeout(appSt.refreshSpinTimer)
4873 appSt.refreshSpinTimer = 0
4874 }
4875 if refreshBtn != 0 {
4876 dom.SetStyle(refreshBtn, "transform", "rotate(0deg)")
4877 }
4878 }
4879
4880 func trackOldestTs(ev *nostr.Event) {
4881 if appSt.oldestFeedTs == 0 || ev.CreatedAt < appSt.oldestFeedTs {
4882 appSt.oldestFeedTs = ev.CreatedAt
4883 }
4884 }
4885
4886 func parseIntProp(s string) (n int32) {
4887 n = 0
4888 for i := 0; i < len(s); i++ {
4889 if s[i] >= '0' && s[i] <= '9' {
4890 n = n*10 + int32(s[i]-'0')
4891 } else {
4892 break
4893 }
4894 }
4895 return n
4896 }
4897
4898 func boolStr(b bool) (s string) {
4899 if b {
4900 return "true"
4901 }
4902 return "false"
4903 }
4904
4905 func i64toa(n int64) (s string) {
4906 if n == 0 {
4907 return "0"
4908 }
4909 var buf [20]byte
4910 i := len(buf)
4911 for n > 0 {
4912 i--
4913 buf[i] = byte('0' + n%10)
4914 n /= 10
4915 }
4916 return string(buf[i:])
4917 }
4918
4919 func sendWriteRelays() {
4920 msg := "[\"SET_WRITE_RELAYS\",["
4921 first := true
4922 for i, url := range appSt.relayURLs {
4923 role := "both"
4924 if i < len(appSt.relayRole) {
4925 role = appSt.relayRole[i]
4926 }
4927 if role == "read" {
4928 continue
4929 }
4930 if !first {
4931 msg = msg | ","
4932 }
4933 msg = msg | jstr(url)
4934 first = false
4935 }
4936 routeMsg(msg | "]]")
4937 }
4938
4939 // readRelayURLs returns relay URLs usable for reading (role "both" or "read").
4940 func readRelayURLs() (ss []string) {
4941 var out []string
4942 for i, url := range appSt.relayURLs {
4943 role := "both"
4944 if i < len(appSt.relayRole) {
4945 role = appSt.relayRole[i]
4946 }
4947 if role != "write" {
4948 out = mxutil.Ensure(out, 1)
4949 out = push(out, url)
4950 }
4951 }
4952 return out
4953 }
4954
4955 func buildProxyMsg(subID, filterJSON string, urls []string) (s string) {
4956 msg := "[\"PROXY\"," | jstr(subID) | "," | filterJSON | ",["
4957 for i, url := range urls {
4958 if i > 0 {
4959 msg = msg | ","
4960 }
4961 msg = msg | jstr(url)
4962 }
4963 return msg | "]]"
4964 }
4965
4966 func jstr(s string) (sv string) {
4967 return "\"" | jsonEsc(s) | "\""
4968 }
4969
4970 // scheduleTabRetry schedules a retry for any pending profile fetches after
4971 // the follows/mutes tab renders. Independent of retryRound so it works even
4972 // after the feed's retry budget is exhausted.
4973 func scheduleTabRetry() {
4974 dom.SetTimeout(func() {
4975 var missing []string
4976 for pk := range appSt.pendingNotes {
4977 if _, ok := appSt.authorNames[pk]; !ok {
4978 missing = mxutil.Ensure(missing, 1)
4979 missing = push(missing, pk)
4980 }
4981 }
4982 if len(missing) == 0 {
4983 return
4984 }
4985 for _, pk := range missing {
4986 setFetchedK0(pk, false)
4987 }
4988 for _, pk := range missing {
4989 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
4990 }
4991 }, 5000)
4992 }
4993
4994 // --- SW message handling ---
4995
4996 func onSWMessage(raw string) {
4997 if raw == "update-available" {
4998 routeMsg("activate-update")
4999 return
5000 }
5001 if raw == "reload" {
5002 dom.LocationReload()
5003 return
5004 }
5005 if !appSt.appReady {
5006 return
5007 }
5008 if len(raw) < 5 || raw[0] != '[' {
5009 return
5010 }
5011 typ, pos := nextStr(raw, 1)
5012 switch typ {
5013 case "EVENT":
5014 subID, pos2 := nextStr(raw, pos)
5015 evJSON := extractValue(raw, pos2)
5016 if evJSON == "" {
5017 return
5018 }
5019 ev := nostr.ParseEvent(evJSON)
5020 if ev == nil {
5021 return
5022 }
5023 dispatchEvent(subID, ev)
5024 case "EOSE":
5025 subID, _ := nextStr(raw, pos)
5026 dispatchEOSE(subID)
5027 case "SW_LOG":
5028 msg, _ := nextStr(raw, pos)
5029 dom.ConsoleLog("[SW] " | msg)
5030 case "BROADCAST_DONE":
5031 if appSt.broadcastBtnEl != 0 {
5032 dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile \u2713")
5033 dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--accent)")
5034 dom.SetTimeout(func() {
5035 dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile")
5036 }, 3000)
5037 }
5038 case "MLS_DM_LIST":
5039 listJSON := extractValue(raw, pos)
5040 renderConversationList(listJSON)
5041 case "MLS_DM_HISTORY":
5042 peer, pos2 := nextStr(raw, pos)
5043 msgsJSON := extractValue(raw, pos2)
5044 renderThreadMessages(peer, msgsJSON)
5045 case "MLS_DM_RECEIVED":
5046 dmJSON := extractValue(raw, pos)
5047 handleDMReceived(dmJSON)
5048 case "MLS_DM_SENT":
5049 // ok/err from MLS worker on send completion
5050 case "MLS_UNREAD_COUNT":
5051 // unread count badge (future step)
5052 case "MLS_DM_HISTORY_CLEARED":
5053 peer, _ := nextStr(raw, pos)
5054 dom.ConsoleLog("[mls] history cleared for " | peer)
5055 case "MLS_STATUS":
5056 text, _ := nextStr(raw, pos)
5057 dom.ConsoleLog("[mls] " | text)
5058 case "OK":
5059 okEvID, pos2 := nextStr(raw, pos)
5060 // Boolean follows: skip comma/space, check for "true".
5061 for pos2 < len(raw) && (raw[pos2] == ',' || raw[pos2] == ' ') {
5062 pos2++
5063 }
5064 isOK := pos2+4 <= len(raw) && raw[pos2:pos2+4] == "true"
5065 if isOK {
5066 if targetID, ok2 := appSt.pendingDeletes[okEvID]; ok2 {
5067 removeNoteFromDOM(targetID)
5068 delete(appSt.pendingDeletes, okEvID)
5069 }
5070 } else {
5071 // Reason follows after bool.
5072 for pos2 < len(raw) && raw[pos2] != '"' {
5073 pos2++
5074 }
5075 reason, _ := nextStr(raw, pos2)
5076 logActivity("relay-err", okEvID[:12] | ": " | reason, "event " | okEvID | "\nreason: " | reason)
5077 }
5078 case "SEEN_ON":
5079 evID, pos2 := nextStr(raw, pos)
5080 rURL, _ := nextStr(raw, pos2)
5081 // Strip trailing slash for dedup.
5082 if len(rURL) > 0 && rURL[len(rURL)-1] == '/' {
5083 rURL = rURL[:len(rURL)-1]
5084 }
5085 if evID != "" && rURL != "" && appSt.eventRelays != nil {
5086 existing := appSt.eventRelays[evID]
5087 found := false
5088 for _, u := range existing {
5089 if u == rURL {
5090 found = true
5091 break
5092 }
5093 }
5094 if !found {
5095 appSt.eventRelays[evID] = mxutil.Ensure(existing, 1)
5096 appSt.eventRelays[evID] = mxutil.Ensure(appSt.eventRelays[evID], 1)
5097 appSt.eventRelays[evID] = push(appSt.eventRelays[evID], rURL)
5098 logActivity("seen-on", evID[:12] | " on " | rURL, "event " | evID | "\nrelay " | rURL)
5099 if cb, ok := appSt.seenOnSubs[evID]; ok && cb != nil {
5100 cb()
5101 }
5102 }
5103 }
5104 case "RELAY_PROXY_READY":
5105 // Relay-proxy worker restarted (WASM fatal recovery). Re-send init
5106 // state and re-subscribe so the worker is functional again.
5107 if appSt.pubhex != "" {
5108 routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5109 }
5110 appSt.feedSubscribed = false
5111 resubscribe()
5112 case "NEED_IDENTITY":
5113 if appSt.pubhex != "" {
5114 routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5115 }
5116 resubscribe()
5117 case "RESUB":
5118 resubscribe()
5119
5120 case "WORKER_RESTARTED":
5121 // A domain worker crashed and restarted. Re-provision it with current state.
5122 which, _ := nextStr(raw, pos)
5123 if appSt.pubhex == "" {
5124 return
5125 }
5126 if which == "/profile-wasm-host.mjs" {
5127 profile.Send("[\"P_SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5128 profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
5129 } else if which == "/feed-wasm-host.mjs" {
5130 doSubscribeFeed()
5131 } else if which == "/notif-wasm-host.mjs" {
5132 subscribeNotifications()
5133 }
5134
5135 // ── Profile Worker responses ─────────────────────────────────────────────
5136 case "P_RESOLVED":
5137 pk3, pos2a := nextStr(raw, pos)
5138 name3, pos3a := nextStr(raw, pos2a)
5139 pic3, _ := nextStr(raw, pos3a)
5140 // Update view cache.
5141 if len(name3) > 0 {
5142 setAuthorName(pk3, name3)
5143 }
5144 if len(pic3) > 0 {
5145 setAuthorPic(pk3, pic3)
5146 }
5147 // Fill pending note headers.
5148 if len(name3) > 0 {
5149 if headers, ok2 := appSt.pendingNotes[pk3]; ok2 {
5150 for _, h := range headers {
5151 updateNoteHeader(h, name3, pic3)
5152 }
5153 delete(appSt.pendingNotes, pk3)
5154 }
5155 }
5156 if len(pic3) > 0 {
5157 for _, avaEl := range appSt.pendingNotifAvatars[pk3] {
5158 setMediaSrc(avaEl, pic3)
5159 }
5160 delete(appSt.pendingNotifAvatars, pk3)
5161 }
5162 if pk3 == appSt.pubhex {
5163 if len(name3) > 0 { appSt.profileName = name3; dom.SetTextContent(appSt.nameEl, name3) }
5164 if len(pic3) > 0 { appSt.profilePic = pic3; setMediaSrc(appSt.avatarEl, pic3); dom.SetStyle(appSt.avatarEl, "display", "block") }
5165 }
5166 updateReplyPreviewsForAuthor(pk3)
5167 updateInlineProfileLinks(pk3)
5168
5169 case "P_CONTENT":
5170 pk4, pos2b := nextStr(raw, pos)
5171 contentJSON, _ := nextStr(raw, pos2b)
5172 appSt.profileContentCache[pk4] = contentJSON
5173 lud16 := helpers.JsonGetString(contentJSON, "lud16")
5174 lud06 := helpers.JsonGetString(contentJSON, "lud06")
5175 if lud16 != "" || lud06 != "" {
5176 applyZapButtonsForAuthor(pk4)
5177 } else {
5178 delete(appSt.pendingZapRows, pk4)
5179 }
5180 // Re-render profile page if it's the viewed profile.
5181 if appSt.profileViewPK == pk4 && appSt.activePage == "profile" {
5182 renderProfilePage(pk4)
5183 }
5184 // Update own profile name if this is our own kind 0.
5185 if pk4 == appSt.pubhex {
5186 name4 := helpers.JsonGetString(contentJSON, "name")
5187 if name4 == "" { name4 = helpers.JsonGetString(contentJSON, "display_name") }
5188 if name4 != "" && appSt.profileName != name4 {
5189 appSt.profileName = name4
5190 dom.SetTextContent(appSt.nameEl, name4)
5191 }
5192 }
5193 // Persist to IDB.
5194 if len(contentJSON) > 0 {
5195 dom.IDBPut("profiles", pk4, contentJSON)
5196 }
5197
5198 case "P_FOLLOWS":
5199 pkF, pos2c := nextStr(raw, pos)
5200 followsJSON := extractValue(raw, pos2c)
5201 pksF := parseStringArrayJSON(followsJSON)
5202 followsChanged := false
5203 if pkF == appSt.pubhex {
5204 if len(pksF) != len(appSt.myFollows) {
5205 followsChanged = true
5206 }
5207 appSt.myFollows = pksF
5208 appSt.followSet = buildSet(pksF)
5209 broadcastFollows(pksF)
5210 if appSt.feedSelectEl != 0 { populateFeedSelect() }
5211 }
5212 appSt.profileFollowsCache[pkF] = pksF
5213 refreshProfileTab(pkF)
5214 if pkF == appSt.pubhex && appSt.feedSubscribed && appSt.feedMode == "follows" && followsChanged {
5215 subscribeFeed()
5216 }
5217
5218 case "P_MUTES":
5219 pkM, pos2d := nextStr(raw, pos)
5220 mutesJSON := extractValue(raw, pos2d)
5221 pksM := parseStringArrayJSON(mutesJSON)
5222 if pkM == appSt.pubhex {
5223 appSt.myMutes = pksM
5224 appSt.muteSet = buildSet(pksM)
5225 broadcastMutes(pksM)
5226 }
5227 appSt.profileMutesCache[pkM] = pksM
5228 refreshProfileTab(pkM)
5229
5230 case "P_RELAYS":
5231 pkR, pos2e := nextStr(raw, pos)
5232 relaysJSON := extractValue(raw, pos2e)
5233 appSt.profileRelaysCache[pkR] = parseStringArrayJSON(relaysJSON)
5234 if appSt.profileViewPK == pkR { refreshProfileTab(pkR) }
5235
5236 // ── Feed Worker responses ─────────────────────────────────────────────────
5237 case "F_RENDER":
5238 evJSON2 := extractValue(raw, pos)
5239 if evJSON2 == "" { return }
5240 fev := nostr.ParseEvent(evJSON2)
5241 if fev == nil { return }
5242 // pos+len(evJSON2)+... find 'now' flag
5243 nowFlag := int64(0)
5244 for i := pos + len(evJSON2); i < len(raw); i++ {
5245 c := raw[i]
5246 if c >= '0' && c <= '9' {
5247 nowFlag = int64(c - '0')
5248 break
5249 }
5250 }
5251 setEvent(fev.ID, fev)
5252 if fev.Kind == 7 {
5253 handleActionEvent(fev)
5254 return
5255 }
5256 trackOldestTs(fev)
5257 if nowFlag == 1 {
5258 if appSt.feedLoader != 0 {
5259 dom.RemoveChild(appSt.feedPage, appSt.feedLoader)
5260 appSt.feedLoader = 0
5261 }
5262 renderNote(fev)
5263 } else {
5264 if appSt.noteElements[fev.ID] != 0 || appSt.seenEvents[fev.ID] {
5265 return
5266 }
5267 appSt.feedBuffer = mxutil.Ensure(appSt.feedBuffer, 1)
5268 appSt.feedBuffer = push(appSt.feedBuffer, fev)
5269 if !appSt.feedHasNew {
5270 appSt.feedHasNew = true
5271 startRefreshPulse()
5272 }
5273 }
5274 case "PROXY_DROP":
5275 // The relay-proxy reports a relay frame it could not route to a client
5276 // subscription instead of dropping it silently.
5277 subIDd, posD := nextStr(raw, pos)
5278 whyD, _ := nextStr(raw, posD)
5279 dom.ConsoleLog("[proxy] dropped frame for " | subIDd | ": " | whyD)
5280 case "F_EOSE_DONE":
5281 subID2, _ := nextStr(raw, pos)
5282 if subID2 == "feed" {
5283 appSt.feedInitialLoad = false
5284 updateStatus()
5285 fetchDeletesForFeed()
5286 }
5287 case "F_STATUS":
5288 // ["F_STATUS", count, exhausted]
5289 cntStr := nextNum(raw, pos)
5290 var cnt int64
5291 for _, c := range cntStr {
5292 if c >= '0' && c <= '9' { cnt = cnt*10 + int64(c-'0') }
5293 }
5294 appSt.eventCount = int32(cnt)
5295 dom.ConsoleLog("[feed] events=" | itoa(appSt.eventCount))
5296 // Find exhausted flag after count.
5297 exStr := ""
5298 for p2 := pos + len(cntStr); p2 < len(raw); p2++ {
5299 if raw[p2] >= '0' && raw[p2] <= '9' {
5300 exStr = nextNum(raw, p2)
5301 break
5302 }
5303 }
5304 if exStr == "1" {
5305 appSt.feedExhausted = true
5306 }
5307 updateStatus()
5308 if appSt.feedLoading {
5309 appSt.feedLoading = false
5310 }
5311 if appSt.feedExhausted {
5312 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
5313 } else if appSt.oldestFeedTs > 0 {
5314 dom.SetStyle(appSt.loadMoreBtn, "display", "block")
5315 }
5316
5317 // ── Worker subscription bridge ────────────────────────────────────────────
5318 // The feed and notif workers own their relay subscriptions and ask the
5319 // Shell to open and close them. Without these arms the requests were
5320 // dropped, so no REQ was ever sent for the feed and it stayed empty.
5321 case "F_SUB", "N_SUB":
5322 // ["X_SUB", subID, filterJSON, urlsJSON]
5323 subIDw, posW := nextStr(raw, pos)
5324 filterW := extractValue(raw, posW)
5325 if subIDw == "" || filterW == "" { return }
5326 urlsW := extractValue(raw, posW+len(filterW))
5327 routeMsg(buildProxyMsg(subIDw, filterW, parseStringArrayJSON(urlsW)))
5328 dom.ConsoleLog("[sub] " | subIDw)
5329 case "F_CLOSE", "N_CLOSE":
5330 subIDx, _ := nextStr(raw, pos)
5331 if subIDx == "" { return }
5332 routeMsg(`["CLOSE",` | jstr(subIDx) | `]`)
5333
5334 // ── Notif Worker responses ────────────────────────────────────────────────
5335 case "N_RENDER":
5336 nEvJSON := extractValue(raw, pos)
5337 if nEvJSON == "" { return }
5338 nev := nostr.ParseEvent(nEvJSON)
5339 if nev == nil { return }
5340 appSt.notifEvents = push(appSt.notifEvents, nev)
5341 notifEventsTrim()
5342 if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(nev) {
5343 row := renderNotifRow(nev)
5344 // mode: "prepend" = first child, "append" = append
5345 modeStr := ""
5346 for i := pos + len(nEvJSON); i < len(raw); i++ {
5347 if raw[i] == '"' {
5348 j := i + 1
5349 for j < len(raw) && raw[j] != '"' { j++ }
5350 modeStr = raw[i+1:j]
5351 break
5352 }
5353 }
5354 if modeStr == "prepend" {
5355 first := dom.FirstElementChild(appSt.notifContainer)
5356 if first != 0 { dom.InsertBefore(appSt.notifContainer, row, first) } else { dom.AppendChild(appSt.notifContainer, row) }
5357 } else {
5358 dom.AppendChild(appSt.notifContainer, row)
5359 }
5360 }
5361 case "N_DOT":
5362 showStr := nextNum(raw, pos)
5363 if showStr == "1" { showNotifDot() }
5364 case "N_STATUS":
5365 // ["N_STATUS", count, exhausted]
5366 nCntStr := nextNum(raw, pos)
5367 nExStr := ""
5368 for p3 := pos + len(nCntStr); p3 < len(raw); p3++ {
5369 if raw[p3] >= '0' && raw[p3] <= '9' {
5370 nExStr = nextNum(raw, p3)
5371 break
5372 }
5373 }
5374 if nExStr == "1" {
5375 appSt.notifExhausted = true
5376 }
5377 appSt.notifLoading = false
5378 if appSt.notifExhausted && appSt.notifLoadMore != 0 {
5379 dom.SetStyle(appSt.notifLoadMore, "display", "none")
5380 } else if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 {
5381 dom.SetStyle(appSt.notifLoadMore, "display", "block")
5382 }
5383 case "N_EOSE_DONE":
5384 subIDN, _ := nextStr(raw, pos)
5385 if subIDN == "ntf" {
5386 appSt.notifInitLoad = false
5387 sortNotifEvents()
5388 if appSt.activePage == "notifications" {
5389 if appSt.notifBuilt {} else { renderNotifPage() }
5390 }
5391 if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") }
5392 }
5393 case "N_FETCH_REFS":
5394 // Forward to relay-proxy: fetch the referenced events so notif row names can fill in.
5395 idsJSON := extractValue(raw, pos)
5396 if idsJSON != "" {
5397 routeMsg(`["PROXY","ntf-ref",{"ids":` | idsJSON | `,"limit":` | itoa(len(appSt.notifMissing)) | `},` | buildURLsJSON(readRelayURLs()) | `]`)
5398 }
5399 case "N_STORE_READ_TS":
5400 tsStr2 := nextNum(raw, pos)
5401 var ts2 int64
5402 for _, c := range tsStr2 { if c >= '0' && c <= '9' { ts2 = ts2*10 + int64(c-'0') } }
5403 if ts2 > 0 { dom.IDBPut("settings", "notif-read-ts", i64toa(ts2)) }
5404
5405 // ── DM Worker responses ───────────────────────────────────────────────────
5406 case "D_LIST_RESP":
5407 listJSON2 := extractValue(raw, pos)
5408 renderConversationList(listJSON2)
5409 case "D_HISTORY_RESP":
5410 peerD, pos3 := nextStr(raw, pos)
5411 msgsD := extractValue(raw, pos3)
5412 renderThreadMessages(peerD, msgsD)
5413 case "D_RECEIVED":
5414 dmJ := extractValue(raw, pos)
5415 handleDMReceived(dmJ)
5416 case "D_SENT":
5417 _, pos4 := nextStr(raw, pos) // peer
5418 okStr, _ := nextStr(raw, pos4)
5419 if okStr == "1" || okStr == "true" {
5420 if len(appSt.pendingTsEls) > 0 {
5421 dom.SetTextContent(appSt.pendingTsEls[0], formatTime(dom.NowSeconds()))
5422 appSt.pendingTsEls = appSt.pendingTsEls[1:]
5423 }
5424 }
5425
5426 // ── Profile Worker retry request ──────────────────────────────────────────
5427 case "P_RETRY_REQ":
5428 // Profile Worker is asking for pending pubkeys that still lack a name.
5429 // Collect from pendingNotes and send back as P_RETRY_PROVIDE.
5430 var pending []string
5431 for pk := range appSt.pendingNotes {
5432 if _, ok := appSt.authorNames[pk]; !ok {
5433 pending = mxutil.Ensure(pending, 1)
5434 pending = push(pending, pk)
5435 }
5436 }
5437 if len(pending) > 0 {
5438 profile.Send(`["P_RETRY_PROVIDE",` | buildJSONStrArr(pending) | `]`)
5439 }
5440 }
5441 }
5442
5443 func resubscribe() {
5444 sendWriteRelays()
5445 subscribeProfile()
5446 subscribeFeed()
5447 subscribeNotifications()
5448 // Sync domain workers with current state.
5449 if appSt.pubhex != "" {
5450 profile.Send("[\"P_SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5451 profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
5452 }
5453 if appSt.activePage == "profile" && appSt.profileTab == "notes" && appSt.profileViewPK != "" {
5454 renderProfileNotes(appSt.profileViewPK)
5455 }
5456 }
5457
5458 func dispatchEvent(subID string, ev *nostr.Event) {
5459 if subID == "prof" || subID == "prof-settings" {
5460 handleProfileEvent(ev)
5461 } else if subID == "feed" || subID == "feed-more" {
5462 // Route to Feed Worker for dedup/filter/buffer management.
5463 feed.Send(`["F_EVENT",` | jstr(subID) | `,` | ev.ToJSON() | `]`)
5464 } else if len(subID) > 3 && subID[:3] == "ap-" {
5465 // Profile Worker owns all profile data; forward the event for processing.
5466 // P_RESOLVED/P_CONTENT/P_FOLLOWS/P_MUTES/P_RELAYS come back and Shell handles DOM.
5467 profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
5468 } else if len(subID) > 3 && subID[:3] == "pn-" {
5469 if appSt.profileNotesSeen[ev.ID] {
5470 return
5471 }
5472 appSt.profileNotesSeen[ev.ID] = true
5473 // Dedup against the broad feed sub: relays send the same event on
5474 // every matching sub on a connection, so a profile-author's new
5475 // note also arrives on "feed". Marking it here prevents it from
5476 // being double-rendered into feedContainer later in the session.
5477 appSt.seenEvents[ev.ID] = true
5478 renderProfileNote(ev)
5479 } else if len(subID) > 6 && subID[:6] == "emb-c-" {
5480 setEvent(ev.ID, ev)
5481 fillCoordEmbed(ev)
5482 } else if len(subID) > 7 && subID[:7] == "emb-cr-" {
5483 setEvent(ev.ID, ev)
5484 fillCoordEmbed(ev)
5485 } else if len(subID) > 4 && subID[:4] == "emb-" {
5486 setEvent(ev.ID, ev)
5487 fillEmbed(ev)
5488 } else if len(subID) > 3 && subID[:3] == "rp-" {
5489 handleReplyPreviewEvent(ev)
5490 } else if len(subID) > 4 && subID[:4] == "thr-" {
5491 handleThreadEvent(appSt.threadGen, ev)
5492 } else if len(subID) > 4 && subID[:4] == "act-" {
5493 handleActionEvent(ev)
5494 } else if subID == "ntf" {
5495 handleNotifEvent(ev)
5496 } else if subID == "ntf-ref" {
5497 handleNotifRefEvent(ev)
5498 } else if subID == "ntf-more" {
5499 handleNotifMoreEvent(ev)
5500 } else if subID == "search" {
5501 handleSearchEvent(ev)
5502 } else if subID == "del-scan" {
5503 if ev.Kind == 5 {
5504 for _, tag := range nostr.TagsGetAll(ev.Tags, "e") {
5505 targetID := nostr.TagValue(tag)
5506 if targetID != "" {
5507 removeNoteFromDOM(targetID)
5508 }
5509 }
5510 }
5511 }
5512 }
5513
5514 func dispatchEOSE(subID string) {
5515 // Route to domain workers first.
5516 if subID == "feed" || subID == "feed-more" {
5517 feed.Send(`["F_EOSE",` | jstr(subID) | `]`)
5518 }
5519 if subID == "ntf" || subID == "ntf-more" || subID == "ntf-ref" {
5520 notif.Send(`["N_EOSE",` | jstr(subID) | `]`)
5521 }
5522 if (len(subID) > 3 && subID[:3] == "ap-") || (len(subID) > 9 && subID[:9] == "ap-batch-") {
5523 profile.Send(`["P_EOSE",` | jstr(subID) | `]`)
5524 }
5525
5526 if subID == "feed-more" {
5527 dom.ConsoleLog("[EOSE] feed-more got=" | itoa(appSt.feedMoreGot))
5528 if appSt.feedMoreTimer != 0 {
5529 dom.ClearTimeout(appSt.feedMoreTimer)
5530 appSt.feedMoreTimer = 0
5531 }
5532 appSt.feedLoading = false
5533 if appSt.feedMoreGot == 0 {
5534 appSt.feedEmptyStreak++
5535 if appSt.feedEmptyStreak >= 3 {
5536 appSt.feedExhausted = true
5537 }
5538 } else {
5539 appSt.feedEmptyStreak = 0
5540 }
5541 if !appSt.feedExhausted && appSt.oldestFeedTs > 0 {
5542 dom.SetStyle(appSt.loadMoreBtn, "display", "block")
5543 }
5544 updateStatus()
5545 } else if subID == "feed" {
5546 // Don't flip feedInitialLoad immediately. The SW sends EOSE on the
5547 // FIRST relay's response (often the empty local relay), but slower
5548 // remote relays keep streaming events for several seconds. Holding the
5549 // initial-load window open lets those events render directly instead
5550 // of being trapped in feedBuffer until the user clicks refresh.
5551 if appSt.feedInitialLoadTimer != 0 {
5552 dom.ClearTimeout(appSt.feedInitialLoadTimer)
5553 }
5554 appSt.feedInitialLoadTimer = dom.SetTimeout(func() {
5555 appSt.feedInitialLoadTimer = 0
5556 appSt.feedInitialLoad = false
5557 }, 3000)
5558 if appSt.refreshSpinning {
5559 stopRefreshSpin()
5560 }
5561 if appSt.feedLoader != 0 {
5562 dom.RemoveChild(appSt.feedPage, appSt.feedLoader)
5563 appSt.feedLoader = 0
5564 }
5565 if appSt.oldestFeedTs > 0 {
5566 dom.SetStyle(appSt.loadMoreBtn, "display", "block")
5567 }
5568 updateStatus()
5569 retryMissingProfiles()
5570 fetchDeletesForFeed()
5571 } else if subID == "ntf" {
5572 dom.ConsoleLog("[EOSE] ntf events=" | itoa(len(appSt.notifEvents)))
5573 appSt.notifInitLoad = false
5574 sortNotifEvents()
5575 if appSt.activePage == "notifications" {
5576 if appSt.notifBuilt {
5577 // Events were rendered incrementally during load; skip the
5578 // clear+rebuild flash. notifEvents is sorted for future renders
5579 // (filter changes, load-more). The "load more" button appears below.
5580 } else {
5581 renderNotifPage()
5582 }
5583 }
5584 if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 {
5585 dom.SetStyle(appSt.notifLoadMore, "display", "block")
5586 }
5587 fetchNotifRefs()
5588 } else if subID == "ntf-ref" {
5589 if appSt.notifBuilt && appSt.activePage == "notifications" {
5590 rebuildNotifRows()
5591 }
5592 } else if subID == "ntf-more" {
5593 dom.ConsoleLog("[EOSE] ntf-more got=" | itoa(appSt.notifMoreGot) | " streak=" | itoa(appSt.notifEmptyStrk))
5594 if appSt.notifMoreTimer != 0 {
5595 dom.ClearTimeout(appSt.notifMoreTimer)
5596 appSt.notifMoreTimer = 0
5597 }
5598 appSt.notifLoading = false
5599 if appSt.notifMoreGot == 0 {
5600 appSt.notifEmptyStrk++
5601 if appSt.notifEmptyStrk >= 3 {
5602 appSt.notifExhausted = true
5603 }
5604 } else {
5605 appSt.notifEmptyStrk = 0
5606 }
5607 if !appSt.notifExhausted && appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 {
5608 dom.SetStyle(appSt.notifLoadMore, "display", "block")
5609 }
5610 fetchNotifRefs()
5611 } else if subID == "search" {
5612 dom.ConsoleLog("[EOSE] search results=" | itoa(len(appSt.searchResults)))
5613 renderSearchResults()
5614 } else if subID == "del-scan" {
5615 routeMsg("[\"CLOSE\",\"del-scan\"]")
5616 } else if len(subID) > 9 && subID[:9] == "ap-batch-" {
5617 // Delay CLOSE; Profile Worker handles retry logic via P_EOSE (sent above).
5618 closeID := subID
5619 dom.SetTimeout(func() {
5620 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5621 }, 15000)
5622 } else if len(subID) > 3 && subID[:3] == "ap-" {
5623 // Delay CLOSE; Profile Worker handles relay-hint retry via P_EOSE (sent above).
5624 closeID := subID
5625 dom.SetTimeout(func() {
5626 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5627 }, 15000)
5628 } else if len(subID) > 6 && subID[:6] == "emb-c-" {
5629 closeID := subID
5630 dom.SetTimeout(func() {
5631 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5632 embedCoordEOSECleanup(closeID)
5633 }, 5000)
5634 } else if len(subID) > 7 && subID[:7] == "emb-cr-" {
5635 closeID := subID
5636 dom.SetTimeout(func() {
5637 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5638 embedCoordEOSECleanup(closeID)
5639 }, 5000)
5640 } else if len(subID) > 4 && subID[:4] == "emb-" {
5641 closeID := subID
5642 dom.SetTimeout(func() {
5643 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5644 embedEOSECleanup(closeID)
5645 }, 5000)
5646 } else if len(subID) > 3 && subID[:3] == "rp-" {
5647 closeID := subID
5648 dom.SetTimeout(func() {
5649 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5650 }, 5000)
5651 } else if len(subID) > 4 && subID[:4] == "thr-" {
5652 closeID := subID
5653 dom.SetTimeout(func() {
5654 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5655 }, 5000)
5656 if appSt.threadOpen {
5657 handleThreadEOSE()
5658 }
5659 } else if len(subID) > 4 && subID[:4] == "act-" {
5660 closeID := subID
5661 dom.SetTimeout(func() {
5662 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5663 }, 10000)
5664 }
5665 }
5666
5667 // nextNum extracts a bare number from s starting at pos, returning it as a string.
5668 func nextNum(s string, pos int32) (sv string) {
5669 for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') {
5670 pos++
5671 }
5672 start := pos
5673 for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
5674 pos++
5675 }
5676 return s[start:pos]
5677 }
5678
5679 // nextStr extracts the next quoted string from s starting at pos.
5680 func nextStr(s string, pos int32) (s2 string, n2 int32) {
5681 for pos < len(s) && s[pos] != '"' {
5682 pos++
5683 }
5684 if pos >= len(s) {
5685 return "", pos
5686 }
5687 pos++
5688 var buf []byte
5689 hasEsc := false
5690 start := pos
5691 for pos < len(s) {
5692 if s[pos] == '\\' && pos+1 < len(s) {
5693 hasEsc = true
5694 buf = buf | s[start:pos]
5695 pos++
5696 switch s[pos] {
5697 case '"', '\\', '/':
5698 buf = mxutil.Ensure(buf, 1)
5699 buf = push(buf, s[pos])
5700 case 'n':
5701 buf = buf | "\n"
5702 case 't':
5703 buf = buf | "\t"
5704 case 'r':
5705 buf = buf | "\r"
5706 default:
5707 buf = buf | "\\" | string([]byte{s[pos]})
5708 }
5709 pos++
5710 start = pos
5711 continue
5712 }
5713 if s[pos] == '"' {
5714 break
5715 }
5716 pos++
5717 }
5718 if pos >= len(s) {
5719 return "", pos
5720 }
5721 var val string
5722 if hasEsc {
5723 buf = buf | s[start:pos]
5724 val = string(buf)
5725 } else {
5726 val = s[start:pos]
5727 }
5728 pos++
5729 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
5730 pos++
5731 }
5732 return val, pos
5733 }
5734
5735 // extractValue extracts a JSON object/array value starting at pos.
5736 func extractValue(s string, pos int32) (sv string) {
5737 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
5738 pos++
5739 }
5740 if pos >= len(s) {
5741 return ""
5742 }
5743 if s[pos] != '{' && s[pos] != '[' {
5744 return ""
5745 }
5746 start := pos
5747 depth := 0
5748 for pos < len(s) {
5749 c := s[pos]
5750 if c == '{' || c == '[' {
5751 depth++
5752 }
5753 if c == '}' || c == ']' {
5754 depth--
5755 if depth == 0 {
5756 return s[start : pos+1]
5757 }
5758 }
5759 if c == '"' {
5760 pos++
5761 for pos < len(s) && s[pos] != '"' {
5762 if s[pos] == '\\' {
5763 pos++
5764 }
5765 pos++
5766 }
5767 }
5768 pos++
5769 }
5770 return s[start:]
5771 }
5772
5773 // parseStringArrayJSON parses a JSON array of strings like ["a","b","c"].
5774 func parseStringArrayJSON(json string) (ss []string) {
5775 var out []string
5776 i := 0
5777 for i < len(json) && json[i] != '[' { i++ }
5778 if i >= len(json) { return nil }
5779 i++
5780 for {
5781 for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ }
5782 if i >= len(json) || json[i] == ']' { break }
5783 if json[i] != '"' { break }
5784 i++
5785 start := i
5786 for i < len(json) && json[i] != '"' {
5787 if json[i] == '\\' { i++ }
5788 i++
5789 }
5790 if i >= len(json) { break }
5791 out = mxutil.Ensure(out, 1)
5792 out = push(out, json[start:i])
5793 i++
5794 }
5795 return out
5796 }
5797
5798 func handleProfileEvent(ev *nostr.Event) {
5799 // Forward to Profile Worker for data storage in all cases.
5800 profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
5801 // Shell handles DOM-immediate effects for the logged-in user's own events.
5802 switch ev.Kind {
5803 case 0:
5804 if ev.CreatedAt <= appSt.profileTs {
5805 return
5806 }
5807 appSt.profileTs = ev.CreatedAt
5808 // P_CONTENT from Profile Worker will handle profile page re-render + IDB.
5809 // Update own header directly here for immediate feedback.
5810 name := helpers.JsonGetString(ev.Content, "name")
5811 if len(name) == 0 {
5812 name = helpers.JsonGetString(ev.Content, "display_name")
5813 }
5814 pic := helpers.JsonGetString(ev.Content, "picture")
5815 if len(name) > 0 {
5816 appSt.profileName = name
5817 setAuthorName(appSt.pubhex, name)
5818 dom.SetTextContent(appSt.nameEl, name)
5819 }
5820 if len(pic) > 0 {
5821 appSt.profilePic = pic
5822 setAuthorPic(appSt.pubhex, pic)
5823 setMediaSrc(appSt.avatarEl, pic)
5824 dom.SetStyle(appSt.avatarEl, "display", "block")
5825 }
5826 case 3:
5827 if ev.CreatedAt <= appSt.contactTs {
5828 return
5829 }
5830 appSt.contactTs = ev.CreatedAt
5831 var pks []string
5832 for _, tag := range nostr.TagsGetAll(ev.Tags, "p") {
5833 if v := nostr.TagValue(tag); v != "" {
5834 pks = mxutil.Ensure(pks, 1)
5835 pks = push(pks, v)
5836 }
5837 }
5838 prev := appSt.myFollows
5839 appSt.myFollows = pks
5840 appSt.followSet = buildSet(pks)
5841 broadcastFollows(pks)
5842 for _, pk := range pks {
5843 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
5844 }
5845 refreshProfileTab(appSt.pubhex)
5846 if appSt.feedSelectEl != 0 {
5847 populateFeedSelect()
5848 }
5849 if appSt.feedMode == "follows" {
5850 if appSt.feedDeferTimer != 0 {
5851 dom.ClearTimeout(appSt.feedDeferTimer)
5852 appSt.feedDeferTimer = 0
5853 }
5854 if !appSt.feedSubscribed {
5855 appSt.feedSubscribed = true
5856 logActivity("boot", "kind 3 received, subscribing feed", itoa(len(pks)) | " follows")
5857 subscribeFeed()
5858 subscribeNotifications()
5859 bootDone()
5860 } else if len(pks) != len(prev) {
5861 subscribeFeed()
5862 }
5863 }
5864 case 10000:
5865 if ev.CreatedAt <= appSt.muteTs {
5866 return
5867 }
5868 appSt.muteTs = ev.CreatedAt
5869 var pks []string
5870 for _, tag := range nostr.TagsGetAll(ev.Tags, "p") {
5871 if v := nostr.TagValue(tag); v != "" {
5872 pks = mxutil.Ensure(pks, 1)
5873 pks = push(pks, v)
5874 }
5875 }
5876 appSt.myMutes = pks
5877 appSt.muteSet = buildSet(pks)
5878 broadcastMutes(pks)
5879 refreshProfileTab(appSt.pubhex)
5880 case 10002:
5881 if ev.CreatedAt <= appSt.relayListTs {
5882 return
5883 }
5884 appSt.relayListTs = ev.CreatedAt
5885 // Profile Worker handles relay frequency tracking via P_EVENT.
5886 for _, tag := range nostr.TagsGetAll(ev.Tags, "r") {
5887 url := nostr.TagValue(tag)
5888 if url != "" {
5889 addRelay(url, true)
5890 }
5891 }
5892 sendWriteRelays()
5893 // Relay list changed - resubscribe to include new relays.
5894 // subscribeFeed has its own 2s throttle, so direct call is fine.
5895 if appSt.feedSubscribed {
5896 subscribeFeed()
5897 subscribeNotifications()
5898 }
5899 case 10050:
5900 if ev.CreatedAt <= appSt.inboxTs {
5901 return
5902 }
5903 appSt.inboxTs = ev.CreatedAt
5904 // DM inbox relay list - stored for future use.
5905 _ = nostr.TagsGetAll(ev.Tags, "relay")
5906 case 30078:
5907 dtag := nostr.TagsGetFirst(ev.Tags, "d")
5908 if dtag == nil || nostr.TagValue(dtag) != "musiquay-settings" {
5909 return
5910 }
5911 if ev.CreatedAt <= appSt.settingsTs {
5912 return
5913 }
5914 appSt.settingsTs = ev.CreatedAt
5915 applyAppSettings(ev.Content)
5916 }
5917 }
5918
5919 func applyAppSettings(content string) {
5920 theme := helpers.JsonGetString(content, "theme")
5921 if theme == "light" && appSt.isDark {
5922 toggleTheme()
5923 } else if theme == "dark" && !appSt.isDark {
5924 toggleTheme()
5925 }
5926 blossom := helpers.JsonGetString(content, "blossom")
5927 if blossom != "" {
5928 appSt.blossomServerURL = blossom
5929 localstorage.SetItem(lsKeyBlossomServer, blossom)
5930 } else {
5931 appSt.blossomServerURL = ""
5932 localstorage.RemoveItem(lsKeyBlossomServer)
5933 }
5934 clientTag := helpers.JsonGetString(content, "clientTag")
5935 if clientTag == "0" {
5936 localstorage.SetItem("musiquay-client-tag", "0")
5937 } else {
5938 localstorage.RemoveItem("musiquay-client-tag")
5939 }
5940 lang := helpers.JsonGetString(content, "lang")
5941 if lang != "" && lang != appSt.currentLang {
5942 setLang(lang)
5943 }
5944 inv := helpers.JsonGetString(content, "invidious")
5945 appSt.invidiousURL = inv
5946 if inv != "" {
5947 localstorage.SetItem(lsKeyInvidiousURL, inv)
5948 } else {
5949 localstorage.RemoveItem(lsKeyInvidiousURL)
5950 }
5951 camp := helpers.JsonGetString(content, "campion")
5952 appSt.campionURL = camp
5953 if camp != "" {
5954 localstorage.SetItem(lsKeyCampionURL, camp)
5955 } else {
5956 localstorage.RemoveItem(lsKeyCampionURL)
5957 }
5958 // Notification read watermark - sync across devices.
5959 nrts := helpers.JsonGetValue(content, "notifReadTs")
5960 if nrts != "" {
5961 ts := parseI64(nrts)
5962 if ts > appSt.notifReadTs {
5963 appSt.notifReadTs = ts
5964 dom.IDBPut("settings", "notif-read-ts", i64toa(ts))
5965 }
5966 }
5967 // Relay blocklist - merge in entries from the settings event.
5968 blocklistRaw := helpers.JsonGetValue(content, "relayBlocklist")
5969 if blocklistRaw != "" {
5970 imported := parseJSONStringArray(blocklistRaw)
5971 for _, u := range imported {
5972 u = normalizeURL(u)
5973 if u != "" && !isBlocked(u) {
5974 appSt.relayBlocklist = mxutil.Ensure(appSt.relayBlocklist, 1)
5975 appSt.relayBlocklist = push(appSt.relayBlocklist, u)
5976 }
5977 }
5978 if len(imported) > 0 {
5979 saveRelayPolicy()
5980 }
5981 }
5982 }
5983
5984 func saveAppSettings() {
5985 if appSt.pubhex == "" {
5986 return
5987 }
5988 theme := "dark"
5989 if !appSt.isDark {
5990 theme = "light"
5991 }
5992 clientTag := "1"
5993 if localstorage.GetItem("musiquay-client-tag") == "0" {
5994 clientTag = "0"
5995 }
5996 blocklistJSON := "["
5997 for i, u := range appSt.relayBlocklist {
5998 if i > 0 {
5999 blocklistJSON = blocklistJSON | ","
6000 }
6001 blocklistJSON = blocklistJSON | jstr(u)
6002 }
6003 blocklistJSON = blocklistJSON | "]"
6004 content := "{" |
6005 "\"theme\":" | jstr(theme) | "," |
6006 "\"blossom\":" | jstr(appSt.blossomServerURL) | "," |
6007 "\"clientTag\":" | jstr(clientTag) | "," |
6008 "\"lang\":" | jstr(appSt.currentLang) | "," |
6009 "\"relayBlocklist\":" | blocklistJSON | "," |
6010 "\"invidious\":" | jstr(appSt.invidiousURL) | "," |
6011 "\"campion\":" | jstr(appSt.campionURL) | "," |
6012 "\"notifReadTs\":" | i64toa(appSt.notifReadTs) |
6013 "}"
6014 ts := dom.NowSeconds()
6015 tags := "[[\"d\",\"musiquay-settings\"]]"
6016 unsigned := "{\"kind\":30078,\"content\":" | jstr(content) |
6017 ",\"tags\":" | tags |
6018 ",\"created_at\":" | i64toa(ts) |
6019 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
6020 signer.SignEvent(unsigned, func(signed string) {
6021 if signed == "" {
6022 return
6023 }
6024 appSt.settingsTs = ts
6025 routeMsg("[\"EVENT\"," | signed | "]")
6026 })
6027 }
6028
6029 func updateStatus() {
6030 dom.SetTextContent(appSt.statusEl, t("relays"))
6031 dom.SetTextContent(appSt.popoverSummary,
6032 itoa(len(appSt.relayURLs)) | " relays | " | itoa(appSt.eventCount) | " events")
6033 }
6034
6035 // --- Feed rendering ---
6036
6037 func renderNote(ev *nostr.Event) {
6038 if appSt.noteElements[ev.ID] != 0 || appSt.seenEvents[ev.ID] {
6039 return
6040 }
6041
6042 repostPK := ""
6043 repostID := ""
6044 repostTs := int64(0)
6045 if ev.Kind == 6 {
6046 inner := nostr.ParseEvent(ev.Content)
6047 if inner == nil {
6048 return
6049 }
6050 repostPK = ev.PubKey
6051 repostID = ev.ID
6052 repostTs = ev.CreatedAt
6053 setEvent(inner.ID, inner)
6054 ev = inner
6055 }
6056
6057 note := dom.CreateElement("div")
6058 if repostID != "" {
6059 appSt.noteElements[repostID] = note
6060 } else {
6061 appSt.noteElements[ev.ID] = note
6062 }
6063 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
6064 dom.SetStyle(note, "padding", "12px 0")
6065 sortTs := ev.CreatedAt
6066 if repostTs > 0 {
6067 sortTs = repostTs
6068 }
6069 dom.SetProperty(note, "musiquayTs", i64toa(sortTs))
6070
6071 if repostPK != "" {
6072 rpBanner := dom.CreateElement("div")
6073 dom.SetStyle(rpBanner, "display", "flex")
6074 dom.SetStyle(rpBanner, "alignItems", "center")
6075 dom.SetStyle(rpBanner, "gap", "6px")
6076 dom.SetStyle(rpBanner, "fontSize", "12px")
6077 dom.SetStyle(rpBanner, "color", "var(--muted)")
6078 dom.SetStyle(rpBanner, "marginBottom", "6px")
6079 dom.SetStyle(rpBanner, "cursor", "pointer")
6080 dom.SetInnerHTML(rpBanner, svgRepost)
6081 rpName := dom.CreateElement("span")
6082 if name, ok := appSt.authorNames[repostPK]; ok && name != "" {
6083 dom.SetTextContent(rpName, name | " " | t("reposted"))
6084 } else {
6085 npub := helpers.EncodeNpub(helpers.HexDecode(repostPK))
6086 short := npub
6087 if len(npub) > 20 {
6088 short = npub[:12] | "..." | npub[len(npub)-4:]
6089 }
6090 dom.SetTextContent(rpName, short | " " | t("reposted"))
6091 }
6092 dom.AppendChild(rpBanner, rpName)
6093 bannerPK := repostPK
6094 dom.AddEventListener(rpBanner, "click", dom.RegisterCallback(func() {
6095 showProfile(bannerPK)
6096 }))
6097 dom.AppendChild(note, rpBanner)
6098 }
6099
6100 // Header row: author link (left) + timestamp (right).
6101 header := dom.CreateElement("div")
6102 dom.SetStyle(header, "display", "flex")
6103 dom.SetStyle(header, "alignItems", "center")
6104 dom.SetStyle(header, "marginBottom", "4px")
6105 dom.SetStyle(header, "maxWidth", "65ch")
6106
6107 // Author link - only covers avatar + name.
6108 authorLink := dom.CreateElement("div")
6109 dom.SetStyle(authorLink, "display", "flex")
6110 dom.SetStyle(authorLink, "alignItems", "center")
6111 dom.SetStyle(authorLink, "gap", "8px")
6112 dom.SetStyle(authorLink, "cursor", "pointer")
6113 headerPK := ev.PubKey
6114 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
6115 showProfile(headerPK)
6116 }))
6117
6118 avatar := dom.CreateElement("img")
6119 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
6120 dom.SetAttribute(avatar, "width", "24")
6121 dom.SetAttribute(avatar, "height", "24")
6122 dom.SetStyle(avatar, "borderRadius", "50%")
6123 dom.SetStyle(avatar, "objectFit", "cover")
6124 dom.SetStyle(avatar, "flexShrink", "0")
6125
6126 nameSpan := dom.CreateElement("span")
6127 dom.SetStyle(nameSpan, "fontSize", "18px")
6128 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6129 dom.SetStyle(nameSpan, "fontWeight", "bold")
6130 dom.SetStyle(nameSpan, "color", "var(--fg)")
6131
6132 pk := ev.PubKey
6133 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
6134 setMediaSrc(avatar, pic)
6135 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
6136 } else {
6137 dom.SetStyle(avatar, "display", "none")
6138 }
6139 if name, ok := appSt.authorNames[pk]; ok && name != "" {
6140 dom.SetTextContent(nameSpan, name)
6141 } else {
6142 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
6143 if len(npub) > 20 {
6144 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
6145 }
6146 }
6147
6148 dom.AppendChild(authorLink, avatar)
6149 dom.AppendChild(authorLink, nameSpan)
6150 dom.AppendChild(header, authorLink)
6151
6152 appendClientTag(header, ev)
6153
6154 // Right side: delete + relay widget + timestamp - right-aligned.
6155 rightGroup := dom.CreateElement("div")
6156 dom.SetStyle(rightGroup, "display", "flex")
6157 dom.SetStyle(rightGroup, "alignItems", "center")
6158 dom.SetStyle(rightGroup, "gap", "4px")
6159 dom.SetStyle(rightGroup, "marginLeft", "auto")
6160
6161 // Delete button - own notes only.
6162 if pk == appSt.pubhex {
6163 delBtn := dom.CreateElement("span")
6164 dom.SetInnerHTML(delBtn, `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 4h10M5 4V2.5A.5.5 0 0 1 5.5 2h3a.5.5 0 0 1 .5.5V4M11 4v7.5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
6165 dom.SetStyle(delBtn, "cursor", "pointer")
6166 dom.SetStyle(delBtn, "color", "var(--muted)")
6167 dom.SetStyle(delBtn, "opacity", "0.5")
6168 dom.SetStyle(delBtn, "display", "flex")
6169 dom.SetStyle(delBtn, "alignItems", "center")
6170 delEvID := ev.ID
6171 dom.AddEventListener(delBtn, "click", dom.RegisterCallback(func() {
6172 publishDelete(delEvID)
6173 }))
6174 dom.AppendChild(rightGroup, delBtn)
6175 }
6176
6177 rw := relayWidget(ev.ID)
6178 dom.AppendChild(rightGroup, rw)
6179 if ev.CreatedAt > 0 {
6180 tsEl := dom.CreateElement("span")
6181 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
6182 dom.SetStyle(tsEl, "fontSize", "11px")
6183 dom.SetStyle(tsEl, "color", "var(--muted)")
6184 dom.SetStyle(tsEl, "cursor", "pointer")
6185 dom.SetStyle(tsEl, "flexShrink", "0")
6186 evID := ev.ID
6187 evRootID := getRootID(ev)
6188 if evRootID == "" {
6189 evRootID = evID
6190 }
6191 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
6192 showNoteThread(evRootID, evID)
6193 }))
6194 dom.AppendChild(rightGroup, tsEl)
6195 }
6196
6197 dom.AppendChild(header, rightGroup)
6198 dom.AppendChild(note, header)
6199
6200 // Track author link for update when profile arrives.
6201 if _, cached := appSt.authorNames[pk]; !cached {
6202 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6203 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6204 appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
6205 if !lookupFetchedK0(pk) {
6206 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
6207 }
6208 }
6209
6210 // Reply preview button.
6211 addReplyPreview(note, ev)
6212
6213 // Content.
6214 content := dom.CreateElement("div")
6215 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6216 dom.SetStyle(content, "fontSize", "14px")
6217 dom.SetStyle(content, "lineHeight", "1.5")
6218 dom.SetStyle(content, "wordBreak", "break-word")
6219 dom.SetStyle(content, "maxWidth", "65ch")
6220 dom.SetStyle(content, "overflow", "hidden")
6221 dom.SetStyle(content, "maxHeight", "18em")
6222 setHTMLWithMedia(content, renderMarkdown(ev.Content))
6223 dom.AppendChild(note, content)
6224
6225 more := makeShowMore(content)
6226 dom.AppendChild(note, buildActionRow(ev, note, content, more))
6227
6228 // Insert in timestamp order (newest first).
6229 insertNoteByTs(note, sortTs)
6230 attachShowMore(content, more)
6231 resolveEmbeds()
6232 }
6233
6234 func insertNoteByTs(note dom.Element, ts int64) {
6235 // Walk children to find insertion point - notes are newest-first.
6236 child := dom.FirstElementChild(appSt.feedContainer)
6237 for child != 0 {
6238 childTs := parseI64(dom.GetProperty(child, "musiquayTs"))
6239 if ts > childTs {
6240 dom.InsertBefore(appSt.feedContainer, note, child)
6241 return
6242 }
6243 child = dom.NextSibling(child)
6244 }
6245 dom.AppendChild(appSt.feedContainer, note)
6246 }
6247
6248 func parseI64(s string) (n int64) {
6249 for i := 0; i < len(s); i++ {
6250 if s[i] >= '0' && s[i] <= '9' {
6251 n = n*10 + int64(s[i]-'0')
6252 } else {
6253 break
6254 }
6255 }
6256 return n
6257 }
6258
6259 func appendNote(ev *nostr.Event) {
6260 if appSt.noteElements[ev.ID] != 0 {
6261 return
6262 }
6263 note, postInsert := buildNoteElement(ev)
6264 dom.AppendChild(appSt.feedContainer, note)
6265 postInsert()
6266 resolveEmbeds()
6267 }
6268
6269 func buildNoteElement(ev *nostr.Event) (v dom.Element, fn func()) {
6270 note := dom.CreateElement("div")
6271 appSt.noteElements[ev.ID] = note
6272 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
6273 dom.SetStyle(note, "padding", "12px 0")
6274 dom.SetProperty(note, "musiquayTs", i64toa(ev.CreatedAt))
6275 dom.SetProperty(note, "musiquayPK", ev.PubKey)
6276
6277 header := dom.CreateElement("div")
6278 dom.SetStyle(header, "display", "flex")
6279 dom.SetStyle(header, "alignItems", "center")
6280 dom.SetStyle(header, "marginBottom", "4px")
6281 dom.SetStyle(header, "maxWidth", "65ch")
6282
6283 authorLink := dom.CreateElement("div")
6284 dom.SetStyle(authorLink, "display", "flex")
6285 dom.SetStyle(authorLink, "alignItems", "center")
6286 dom.SetStyle(authorLink, "gap", "8px")
6287 dom.SetStyle(authorLink, "cursor", "pointer")
6288 headerPK := ev.PubKey
6289 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
6290 showProfile(headerPK)
6291 }))
6292
6293 avatar := dom.CreateElement("img")
6294 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
6295 dom.SetAttribute(avatar, "width", "24")
6296 dom.SetAttribute(avatar, "height", "24")
6297 dom.SetStyle(avatar, "borderRadius", "50%")
6298 dom.SetStyle(avatar, "objectFit", "cover")
6299 dom.SetStyle(avatar, "flexShrink", "0")
6300
6301 nameSpan := dom.CreateElement("span")
6302 dom.SetStyle(nameSpan, "fontSize", "18px")
6303 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6304 dom.SetStyle(nameSpan, "fontWeight", "bold")
6305 dom.SetStyle(nameSpan, "color", "var(--fg)")
6306
6307 pk := ev.PubKey
6308 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
6309 setMediaSrc(avatar, pic)
6310 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
6311 } else {
6312 dom.SetStyle(avatar, "display", "none")
6313 }
6314 if name, ok := appSt.authorNames[pk]; ok && name != "" {
6315 dom.SetTextContent(nameSpan, name)
6316 } else {
6317 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
6318 if len(npub) > 20 {
6319 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
6320 }
6321 }
6322
6323 dom.AppendChild(authorLink, avatar)
6324 dom.AppendChild(authorLink, nameSpan)
6325 attachHoverCard(authorLink, pk)
6326 dom.AppendChild(header, authorLink)
6327
6328 appendClientTag(header, ev)
6329
6330 // JSON toggle: contentEl and moreEl are set below after those elements are
6331 // created. The click handler fires long after this function returns, so the
6332 // closure sees the final values.
6333 var contentEl dom.Element
6334 var moreEl dom.Element
6335 jsonActive := false
6336 var jsonEl dom.Element
6337
6338 // Copy button - to the left of </>; marginLeft:auto pushes both to the right.
6339 cpBtn := noteCopyBtn()
6340 capturedCpEv := ev
6341 dom.AddEventListener(cpBtn, "click", dom.RegisterCallback(func() {
6342 capturedCpBtn := cpBtn
6343 text := noteClipText(capturedCpEv)
6344 dom.WritePrimarySelection(text)
6345 dom.WriteClipboard(text, func(ok bool) {
6346 if ok {
6347 noteCopyFeedback(capturedCpBtn)
6348 }
6349 })
6350 }))
6351 dom.SetStyle(cpBtn, "marginLeft", "auto")
6352 dom.AppendChild(header, cpBtn)
6353
6354 jsonBtn := dom.CreateElement("span")
6355 dom.SetTextContent(jsonBtn, "</>")
6356 dom.SetStyle(jsonBtn, "fontSize", "10px")
6357 dom.SetStyle(jsonBtn, "fontFamily", "'Fira Code', monospace")
6358 dom.SetStyle(jsonBtn, "cursor", "pointer")
6359 dom.SetStyle(jsonBtn, "opacity", "0.6")
6360 dom.SetStyle(jsonBtn, "marginRight", "8px")
6361 dom.SetStyle(jsonBtn, "userSelect", "none")
6362 dom.SetStyle(jsonBtn, "color", "var(--muted)")
6363 capturedJBtn := jsonBtn
6364 capturedEv := ev
6365 capturedNote := note
6366 dom.AddEventListener(jsonBtn, "click", dom.RegisterCallback(func() {
6367 if jsonActive {
6368 dom.SetStyle(contentEl, "display", "")
6369 dom.SetStyle(moreEl, "display", "none")
6370 dom.SetStyle(jsonEl, "display", "none")
6371 dom.SetStyle(capturedJBtn, "opacity", "0.6")
6372 dom.SetStyle(capturedJBtn, "color", "var(--muted)")
6373 jsonActive = false
6374 } else {
6375 if jsonEl == 0 {
6376 jsonEl = dom.CreateElement("pre")
6377 dom.SetStyle(jsonEl, "fontFamily", "'Fira Code', monospace")
6378 dom.SetStyle(jsonEl, "fontSize", "12px")
6379 dom.SetStyle(jsonEl, "lineHeight", "1.5")
6380 dom.SetStyle(jsonEl, "wordBreak", "break-word")
6381 dom.SetStyle(jsonEl, "whiteSpace", "pre-wrap")
6382 dom.SetStyle(jsonEl, "maxWidth", "65ch")
6383 dom.SetStyle(jsonEl, "margin", "0")
6384 dom.SetStyle(jsonEl, "color", "var(--fg)")
6385 dom.SetTextContent(jsonEl, prettyNoteJSON(capturedEv))
6386 dom.InsertBefore(capturedNote, jsonEl, moreEl)
6387 }
6388 dom.SetStyle(contentEl, "display", "none")
6389 dom.SetStyle(moreEl, "display", "none")
6390 dom.SetStyle(jsonEl, "display", "block")
6391 dom.SetStyle(capturedJBtn, "opacity", "1")
6392 dom.SetStyle(capturedJBtn, "color", "var(--accent)")
6393 jsonActive = true
6394 }
6395 }))
6396 dom.AppendChild(header, jsonBtn)
6397
6398 rw2 := relayWidget(ev.ID)
6399 dom.AppendChild(header, rw2)
6400 if ev.CreatedAt > 0 {
6401 tsEl := dom.CreateElement("span")
6402 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
6403 dom.SetStyle(tsEl, "fontSize", "11px")
6404 dom.SetStyle(tsEl, "color", "var(--muted)")
6405 dom.SetStyle(tsEl, "cursor", "pointer")
6406 dom.SetStyle(tsEl, "flexShrink", "0")
6407 evID := ev.ID
6408 evRootID := getRootID(ev)
6409 if evRootID == "" {
6410 evRootID = evID
6411 }
6412 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
6413 showNoteThread(evRootID, evID)
6414 }))
6415 dom.AppendChild(header, tsEl)
6416 }
6417
6418 dom.AppendChild(note, header)
6419
6420 if _, cached := appSt.authorNames[pk]; !cached {
6421 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6422 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6423 appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
6424 if !lookupFetchedK0(pk) {
6425 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
6426 }
6427 }
6428
6429 addReplyPreview(note, ev)
6430
6431 content := dom.CreateElement("div")
6432 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6433 dom.SetStyle(content, "fontSize", "14px")
6434 dom.SetStyle(content, "lineHeight", "1.5")
6435 dom.SetStyle(content, "wordBreak", "break-word")
6436 dom.SetStyle(content, "maxWidth", "65ch")
6437 dom.SetStyle(content, "overflow", "hidden")
6438 dom.SetStyle(content, "maxHeight", "18em")
6439 setHTMLWithMedia(content, renderMarkdown(ev.Content))
6440 dom.AppendChild(note, content)
6441 contentEl = content
6442
6443 more := makeShowMore(content)
6444 moreEl = more
6445 dom.AppendChild(note, buildActionRow(ev, note, content, more))
6446
6447 return note, func() { attachShowMore(content, more) }
6448 }
6449
6450
6451 // topRelays returns the n most frequently seen relay URLs from kind 10002 events.
6452 // topRelays moved to Profile Worker. Shell no longer has relay frequency data.
6453 // Returns nil so callers fall through to relayURLs + discoveryRelays only.
6454 func topRelays(_ int32) (ss []string) { return nil }
6455
6456 // recordRelayFreq moved to Profile Worker. Shell no longer tracks relay frequency.
6457
6458 // discoveryRelays are well-known relays that aggregate profile metadata.
6459 // Prioritized first in _proxy lists since they have the highest hit rate.
6460 var discoveryRelays []string
6461
6462 // buildProxy builds a _proxy relay list for a pubkey.
6463 // Discovery relays first, then author-specific relays if known.
6464 // buildProxy builds a relay list for querying a specific author.
6465 // authorRelays and relayFreq now live in Profile Worker; Shell uses
6466 // profileRelaysCache for the relay hints it has received via P_RELAYS.
6467 func buildProxy(pk string) (ss []string) {
6468 out := []string{:len(discoveryRelays)}
6469 copy(out, discoveryRelays)
6470 for _, u := range readRelayURLs() {
6471 out = appendUnique(out, u)
6472 }
6473 if rels, ok2 := appSt.profileRelaysCache[pk]; ok2 {
6474 for _, r := range rels {
6475 out = appendUnique(out, r)
6476 }
6477 }
6478 return out
6479 }
6480
6481 func appendUnique(list []string, val string) (ss []string) {
6482 if isBlocked(val) {
6483 return list
6484 }
6485 for _, v := range list {
6486 if v == val {
6487 return list
6488 }
6489 }
6490 // push does not grow: a caller's slice that came back at len == cap (a
6491 // returned view, or a presized list) would panic here.
6492 list = mxutil.Ensure(list, 1)
6493 return push(list, val)
6494 }
6495
6496 // --- Thread view & reply preview ---
6497
6498 // getReplyID returns the event ID this note is replying to, or "".
6499 // Checks for NIP-10 "reply" marker first, falls back to positional (last e-tag).
6500 func getReplyID(ev *nostr.Event) (s string) {
6501 id, _ := getReplyIDHint(ev)
6502 return id
6503 }
6504
6505 func getReplyIDHint(ev *nostr.Event) (s2 string, s3 string) {
6506 var etags [][]string
6507 for _, tw1 := range ev.Tags {
6508 if len(tw1) >= 2 && tw1[0] == "e" {
6509 etags = mxutil.Ensure(etags, 1)
6510 etags = push(etags, tw1)
6511 }
6512 }
6513 if len(etags) == 0 {
6514 return "", ""
6515 }
6516 // Marker-based: look for "reply".
6517 for _, tv1 := range etags {
6518 if len(tv1) >= 4 && tv1[3] == "reply" {
6519 hintw3 := ""
6520 if len(tv1) >= 3 && len(tv1[2]) > 0 {
6521 hintw3 = tv1[2]
6522 }
6523 return tv1[1], hintw3
6524 }
6525 }
6526 // Positional fallback: last e-tag is the reply target (if >1 e-tags).
6527 if len(etags) > 1 {
6528 last := etags[len(etags)-1]
6529 hintw2 := ""
6530 if len(last) >= 3 && len(last[2]) > 0 {
6531 hintw2 = last[2]
6532 }
6533 return last[1], hintw2
6534 }
6535 // Single e-tag with no marker: it's both root and reply.
6536 hintw1 := ""
6537 if len(etags[0]) >= 3 && len(etags[0][2]) > 0 {
6538 hintw1 = etags[0][2]
6539 }
6540 return etags[0][1], hintw1
6541 }
6542
6543 // getRootID returns the thread root event ID, or "".
6544 func getRootID(ev *nostr.Event) (s string) {
6545 for _, tw2 := range ev.Tags {
6546 if len(tw2) >= 4 && tw2[0] == "e" && tw2[3] == "root" {
6547 return tw2[1]
6548 }
6549 }
6550 // Positional: first e-tag.
6551 for _, tv2 := range ev.Tags {
6552 if len(tv2) >= 2 && tv2[0] == "e" {
6553 return tv2[1]
6554 }
6555 }
6556 return ""
6557 }
6558
6559 // referencesEvent returns true if ev has any e-tag pointing to the given ID.
6560 func referencesEvent(ev *nostr.Event, id string) (ok bool) {
6561 for _, tw3 := range ev.Tags {
6562 if len(tw3) >= 2 && tw3[0] == "e" && tw3[1] == id {
6563 return true
6564 }
6565 }
6566 return false
6567 }
6568
6569 func firstLine(s string) (sv string) {
6570 for i := 0; i < len(s); i++ {
6571 if s[i] == '\n' {
6572 if i > 80 {
6573 return s[:80] | "..."
6574 }
6575 return s[:i]
6576 }
6577 }
6578 if len(s) > 80 {
6579 return s[:80] | "..."
6580 }
6581 return s
6582 }
6583
6584 // appendClientTag adds "via <client>" to a note header if the event has a
6585 // "client" tag. Linkifies http(s) URLs and strips the scheme for display.
6586 func appendClientTag(header dom.Element, ev *nostr.Event) {
6587 clientTag := nostr.TagsGetFirst(ev.Tags, "client")
6588 if clientTag == nil {
6589 return
6590 }
6591 val := nostr.TagValue(clientTag)
6592 if val == "" {
6593 return
6594 }
6595
6596 wrap := dom.CreateElement("span")
6597 dom.SetStyle(wrap, "fontStyle", "italic")
6598 dom.SetStyle(wrap, "fontSize", "11px")
6599 dom.SetStyle(wrap, "color", "var(--muted)")
6600 dom.SetStyle(wrap, "marginLeft", "6px")
6601 dom.SetStyle(wrap, "flexShrink", "0")
6602
6603 via := dom.CreateElement("span")
6604 dom.SetTextContent(via, "via ")
6605 dom.AppendChild(wrap, via)
6606
6607 display := val
6608 isURL := false
6609 if len(val) >= 8 && val[:8] == "https://" {
6610 isURL = true
6611 display = val[8:]
6612 } else if len(val) >= 7 && val[:7] == "http://" {
6613 isURL = true
6614 display = val[7:]
6615 }
6616
6617 if isURL {
6618 link := dom.CreateElement("a")
6619 dom.SetAttribute(link, "href", val)
6620 dom.SetAttribute(link, "target", "_blank")
6621 dom.SetAttribute(link, "rel", "noopener noreferrer")
6622 dom.SetTextContent(link, display)
6623 dom.SetStyle(link, "color", "var(--muted)")
6624 dom.SetStyle(link, "textDecoration", "underline")
6625 dom.AppendChild(wrap, link)
6626 } else {
6627 text := dom.CreateElement("span")
6628 dom.SetTextContent(text, display)
6629 dom.AppendChild(wrap, text)
6630 }
6631
6632 dom.AppendChild(header, wrap)
6633 }
6634
6635 func addReplyPreview(note dom.Element, ev *nostr.Event) {
6636 parentID, hint := getReplyIDHint(ev)
6637 if parentID == "" {
6638 return
6639 }
6640 if hint != "" && appSt.replyHints != nil {
6641 appSt.replyHints[parentID] = hint
6642 }
6643
6644 preview := dom.CreateElement("div")
6645 dom.SetStyle(preview, "display", "flex")
6646 dom.SetStyle(preview, "alignItems", "center")
6647 dom.SetStyle(preview, "gap", "4px")
6648 dom.SetStyle(preview, "fontSize", "14px")
6649 dom.SetStyle(preview, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6650 dom.SetStyle(preview, "lineHeight", "1.5")
6651 dom.SetStyle(preview, "color", "var(--muted)")
6652 dom.SetStyle(preview, "borderLeft", "2px solid var(--accent)")
6653 dom.SetStyle(preview, "paddingLeft", "8px")
6654 dom.SetStyle(preview, "marginBottom", "4px")
6655 dom.SetStyle(preview, "cursor", "pointer")
6656 dom.SetStyle(preview, "overflow", "hidden")
6657 dom.SetStyle(preview, "maxWidth", "65ch")
6658
6659 prevAvatar := dom.CreateElement("img")
6660 dom.SetAttribute(prevAvatar, "referrerpolicy", "no-referrer")
6661 dom.SetAttribute(prevAvatar, "width", "14")
6662 dom.SetAttribute(prevAvatar, "height", "14")
6663 dom.SetStyle(prevAvatar, "borderRadius", "50%")
6664 dom.SetStyle(prevAvatar, "objectFit", "cover")
6665 dom.SetStyle(prevAvatar, "flexShrink", "0")
6666 dom.SetStyle(prevAvatar, "display", "none")
6667 dom.AppendChild(preview, prevAvatar)
6668
6669 prevText := dom.CreateElement("span")
6670 dom.SetStyle(prevText, "overflow", "hidden")
6671 dom.SetStyle(prevText, "whiteSpace", "nowrap")
6672 dom.SetStyle(prevText, "textOverflow", "ellipsis")
6673 dom.AppendChild(preview, prevText)
6674
6675 text, cached := appSt.replyCache[parentID]
6676 if !cached {
6677 text, cached = appSt.replyCacheOld[parentID]
6678 }
6679 if cached {
6680 dom.SetTextContent(prevText, text)
6681 pic, _ := appSt.replyAvatarCache[parentID]
6682 if pic == "" {
6683 pic, _ = appSt.replyAvatarCacheOld[parentID]
6684 }
6685 if pic != "" {
6686 setMediaSrc(prevAvatar, pic)
6687 dom.SetAttribute(prevAvatar, "onerror", "this.style.display='none'")
6688 dom.SetStyle(prevAvatar, "display", "block")
6689 }
6690 } else {
6691 dom.SetTextContent(prevText, t("replying_to"))
6692 appSt.replyPending[parentID] = mxutil.Ensure(appSt.replyPending[parentID], 1)
6693 appSt.replyPending[parentID] = mxutil.Ensure(appSt.replyPending[parentID], 1)
6694 appSt.replyPending[parentID] = push(appSt.replyPending[parentID], preview)
6695 queueReplyFetch(parentID)
6696 }
6697
6698 rootID := getRootID(ev)
6699 if rootID == "" {
6700 rootID = parentID
6701 }
6702 focusID := parentID
6703 dom.AddEventListener(preview, "click", dom.RegisterCallback(func() {
6704 showNoteThread(rootID, focusID)
6705 }))
6706
6707 dom.AppendChild(note, preview)
6708 }
6709
6710 func queueReplyFetch(id string) {
6711 appSt.replyQueue = mxutil.Ensure(appSt.replyQueue, 1)
6712 appSt.replyQueue = push(appSt.replyQueue, id)
6713 if appSt.replyTimer != 0 {
6714 dom.ClearTimeout(appSt.replyTimer)
6715 }
6716 appSt.replyTimer = dom.SetTimeout(func() {
6717 appSt.replyTimer = 0
6718 flushReplyQueue()
6719 }, 300)
6720 }
6721
6722 func flushReplyQueue() {
6723 if len(appSt.replyQueue) == 0 {
6724 return
6725 }
6726 filter := "{\"ids\":["
6727 var hints []string
6728 for i, id := range appSt.replyQueue {
6729 if i > 0 {
6730 filter = filter | ","
6731 }
6732 filter = filter | jstr(id)
6733 hv := appSt.replyHints[id]
6734 if hv == "" {
6735 hv = appSt.replyHintsOld[id]
6736 if hv != "" {
6737 hints = mxutil.Ensure(hints, 1)
6738 hints = push(hints, hv)
6739 delete(appSt.replyHintsOld, id)
6740 }
6741 } else {
6742 hints = mxutil.Ensure(hints, 1)
6743 hints = push(hints, hv)
6744 delete(appSt.replyHints, id)
6745 }
6746 }
6747 filter = filter | "]}"
6748 appSt.replyQueue = nil
6749
6750 // Local REQ checks IDB cache first.
6751 appSt.threadSubCount++
6752 reqID := "rp-" | itoa(appSt.threadSubCount)
6753 routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
6754
6755 // PROXY fetches from remote relays - use hints + discovery + feed + top relays.
6756 var urls []string
6757 seen := map[string]bool{}
6758 for _, hw1 := range hints {
6759 if !seen[hw1] {
6760 seen[hw1] = true
6761 urls = mxutil.Ensure(urls, 1)
6762 urls = push(urls, hw1)
6763 }
6764 }
6765 for _, u := range discoveryRelays {
6766 if !seen[u] {
6767 seen[u] = true
6768 urls = mxutil.Ensure(urls, 1)
6769 urls = push(urls, u)
6770 }
6771 }
6772 for _, u := range appSt.relayURLs {
6773 if !seen[u] {
6774 seen[u] = true
6775 urls = mxutil.Ensure(urls, 1)
6776 urls = push(urls, u)
6777 }
6778 }
6779 for _, u := range topRelays(8) {
6780 if !seen[u] {
6781 seen[u] = true
6782 urls = mxutil.Ensure(urls, 1)
6783 urls = push(urls, u)
6784 }
6785 }
6786 appSt.threadSubCount++
6787 proxyID := "rp-" | itoa(appSt.threadSubCount)
6788 routeMsg(buildProxyMsg(proxyID, filter, urls))
6789
6790 // Reset sweep timer: 20s after last batch sent, mark remaining as not found.
6791 if appSt.replySweepTimer != 0 {
6792 dom.ClearTimeout(appSt.replySweepTimer)
6793 }
6794 appSt.replySweepTimer = dom.SetTimeout(func() {
6795 appSt.replySweepTimer = 0
6796 sweepUnfoundReplies()
6797 }, 20000)
6798 }
6799
6800 func handleReplyPreviewEvent(ev *nostr.Event) {
6801 line := firstLine(ev.Content)
6802 appSt.replyLineCache[ev.ID] = line
6803 appSt.replyAuthorMap[ev.ID] = ev.PubKey
6804
6805 name := ev.PubKey[:8] | "..."
6806 nameResolved := false
6807 if n, ok := appSt.authorNames[ev.PubKey]; ok && n != "" {
6808 name = n
6809 nameResolved = true
6810 }
6811 text := name | ": " | line
6812 appSt.replyCache[ev.ID] = text
6813 appSt.replyCount++
6814 if appSt.replyCount > rotateReplyMax {
6815 rotateReplyCaches()
6816 }
6817
6818 pic := ""
6819 if p, ok := appSt.authorPics[ev.PubKey]; ok && p != "" {
6820 pic = p
6821 }
6822 appSt.replyAvatarCache[ev.ID] = pic
6823
6824 // Trigger profile fetch if not cached.
6825 if !nameResolved {
6826 if !lookupFetchedK0(ev.PubKey) {
6827 profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
6828 }
6829 }
6830
6831 if divs, ok2 := appSt.replyPending[ev.ID]; ok2 {
6832 for _, d := range divs {
6833 fillReplyPreviewDiv(d, text, pic)
6834 }
6835 // Track for late name update if unresolved.
6836 if !nameResolved {
6837 appSt.replyNeedName[ev.ID] = appSt.replyNeedName[ev.ID] | divs
6838 }
6839 delete(appSt.replyPending, ev.ID)
6840 }
6841 }
6842
6843 func fillReplyPreviewDiv(d dom.Element, text, pic string) {
6844 img := dom.FirstChild(d)
6845 if img == 0 {
6846 return
6847 }
6848 span := dom.NextSibling(img)
6849 if span != 0 {
6850 dom.SetTextContent(span, text)
6851 }
6852 if pic != "" {
6853 setMediaSrc(img, pic)
6854 dom.SetAttribute(img, "onerror", "this.style.display='none'")
6855 dom.SetStyle(img, "display", "block")
6856 }
6857 }
6858
6859 func sweepUnfoundReplies() {
6860 if len(appSt.replyPending) == 0 {
6861 return
6862 }
6863 notFound := t("reply_not_found")
6864 for id, divs := range appSt.replyPending {
6865 for _, d := range divs {
6866 span := dom.NextSibling(dom.FirstChild(d))
6867 if span != 0 {
6868 dom.SetTextContent(span, notFound)
6869 }
6870 }
6871 delete(appSt.replyPending, id)
6872 }
6873 }
6874
6875 // updateReplyPreviewsForAuthor is called when a kind 0 profile arrives.
6876 // It updates any reply preview divs that were rendered with a hex pubkey stub.
6877 func updateReplyPreviewsForAuthor(pk string) {
6878 name, _ := appSt.authorNames[pk]
6879 pic, _ := appSt.authorPics[pk]
6880 if name == "" {
6881 return
6882 }
6883
6884 for eid, apk := range appSt.replyAuthorMap {
6885 if apk != pk {
6886 continue
6887 }
6888 line := appSt.replyLineCache[eid]
6889 text := name | ": " | line
6890 appSt.replyCache[eid] = text
6891 appSt.replyAvatarCache[eid] = pic
6892
6893 if divs, ok2 := appSt.replyNeedName[eid]; ok2 {
6894 for _, d := range divs {
6895 fillReplyPreviewDiv(d, text, pic)
6896 }
6897 delete(appSt.replyNeedName, eid)
6898 }
6899 }
6900 }
6901
6902 func showNoteThread(rootID, focusID string) {
6903 // Close old thread subs immediately to prevent stale events leaking in.
6904 for _, sid := range appSt.threadActiveSubs {
6905 routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
6906 }
6907 appSt.threadActiveSubs = nil
6908 appSt.threadGen++
6909
6910 appSt.threadRootID = rootID
6911 appSt.threadFocusID = focusID
6912 appSt.threadEvents = map[string]*nostr.Event{}
6913 appSt.threadLastRendered = 0
6914 appSt.threadOpen = true
6915 appSt.threadFollowUp = 0
6916 appSt.threadFetchedIDs = map[string]bool{rootID: true}
6917 appSt.threadReturnPage = appSt.activePage
6918
6919 // Save scroll position.
6920 appSt.savedScrollTop = dom.GetProperty(appSt.contentArea, "scrollTop")
6921
6922 // Deactivate search bar so top bar returns to normal while in thread.
6923 if appSt.searchBarActive {
6924 deactivateSearchBar(appSt.searchBtnGlobal)
6925 }
6926 // Stop any inline media (Invidious iframes) playing in the feed.
6927 stopInlineMedia(appSt.feedContainer)
6928
6929 // Ensure feedPage is visible - threadPage lives inside it.
6930 if appSt.activePage != "feed" {
6931 dom.SetStyle(appSt.profilePage, "display", "none")
6932 dom.SetStyle(appSt.msgPage, "display", "none")
6933 dom.SetStyle(appSt.settingsPage, "display", "none")
6934 dom.SetStyle(appSt.notifPage, "display", "none")
6935 dom.SetStyle(appSt.aboutPage, "display", "none")
6936 dom.SetStyle(appSt.composePage, "display", "none")
6937 dom.SetStyle(appSt.searchPage, "display", "none")
6938 dom.SetStyle(appSt.feedPage, "display", "block")
6939 }
6940
6941 // Defocus all sidebar buttons - thread view is its own context.
6942 dom.SetStyle(appSt.sidebarFeed, "background", "transparent")
6943 dom.SetStyle(appSt.sidebarFeed, "color", "var(--fg)")
6944 dom.SetStyle(appSt.sidebarCompose, "background", "transparent")
6945 dom.SetStyle(appSt.sidebarCompose, "color", "var(--fg)")
6946 dom.SetStyle(appSt.sidebarMsg, "background", "transparent")
6947 dom.SetStyle(appSt.sidebarMsg, "color", "var(--fg)")
6948 dom.SetStyle(appSt.sidebarNotif, "background", "transparent")
6949 dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)")
6950 dom.SetStyle(appSt.sidebarSettings, "background", "transparent")
6951 dom.SetStyle(appSt.sidebarSettings, "color", "var(--fg)")
6952
6953 // Switch UI.
6954 dom.SetStyle(appSt.feedContainer, "display", "none")
6955 dom.SetStyle(appSt.loadMoreBtn, "display", "none")
6956 dom.SetStyle(appSt.threadPage, "display", "block")
6957 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
6958
6959 // Show back button in top bar, hide everything else.
6960 closeFeedPopover()
6961 dom.SetStyle(appSt.topBackBtn, "display", "inline")
6962 dom.SetStyle(appSt.pageTitleEl, "display", "none")
6963 dom.SetStyle(appSt.feedSelectEl, "display", "none")
6964 dom.SetStyle(appSt.notifBarGroup, "display", "none")
6965
6966 if !appSt.navPop {
6967 url := "/t/" | rootID
6968 if focusID != "" && focusID != rootID {
6969 url = url | "#" | focusID
6970 }
6971 dom.PushState(url)
6972 appSt.threadPushedState = true
6973 } else {
6974 appSt.threadPushedState = false
6975 }
6976
6977 clearChildren(appSt.threadContainer)
6978
6979 // Loading indicator.
6980 loading := dom.CreateElement("div")
6981 dom.SetTextContent(loading, t("loading_thread"))
6982 dom.SetStyle(loading, "color", "var(--muted)")
6983 dom.SetStyle(loading, "fontSize", "14px")
6984 dom.SetStyle(loading, "padding", "16px 0")
6985 dom.AppendChild(appSt.threadContainer, loading)
6986
6987 // Build wide relay set for thread fetch.
6988 var thrRelays []string
6989 thrSeen := map[string]bool{}
6990 for _, u := range appSt.relayURLs {
6991 if !thrSeen[u] {
6992 thrSeen[u] = true
6993 thrRelays = mxutil.Ensure(thrRelays, 1)
6994 thrRelays = push(thrRelays, u)
6995 }
6996 }
6997 for _, u := range topRelays(8) {
6998 if !thrSeen[u] {
6999 thrSeen[u] = true
7000 thrRelays = mxutil.Ensure(thrRelays, 1)
7001 thrRelays = push(thrRelays, u)
7002 }
7003 }
7004
7005 appSt.threadRelays = thrRelays
7006
7007 // Local REQ for cached events.
7008 appSt.threadSubCount++
7009 s1 := "thr-" | itoa(appSt.threadSubCount)
7010 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7011 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s1)
7012 routeMsg("[\"REQ\"," | jstr(s1) | ",{\"ids\":[" | jstr(rootID) | "]}]")
7013
7014 appSt.threadSubCount++
7015 s2 := "thr-" | itoa(appSt.threadSubCount)
7016 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7017 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s2)
7018 routeMsg("[\"REQ\"," | jstr(s2) | ",{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}]")
7019
7020 // PROXY for remote relays.
7021 appSt.threadSubCount++
7022 s3 := "thr-" | itoa(appSt.threadSubCount)
7023 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7024 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s3)
7025 routeMsg(buildProxyMsg(s3, "{\"ids\":[" | jstr(rootID) | "]}", thrRelays))
7026
7027 appSt.threadSubCount++
7028 s4 := "thr-" | itoa(appSt.threadSubCount)
7029 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7030 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s4)
7031 routeMsg(buildProxyMsg(s4, "{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}", thrRelays))
7032
7033 // Also fetch the focus event directly if different from root.
7034 if focusID != rootID {
7035 appSt.threadSubCount++
7036 s5 := "thr-" | itoa(appSt.threadSubCount)
7037 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7038 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s5)
7039 routeMsg(buildProxyMsg(s5, "{\"ids\":[" | jstr(focusID) | "]}", thrRelays))
7040 }
7041 }
7042
7043 func closeNoteThread() {
7044 // Close active subs.
7045 for _, sid := range appSt.threadActiveSubs {
7046 routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
7047 }
7048 appSt.threadActiveSubs = nil
7049
7050 appSt.threadOpen = false
7051 appSt.threadRootID = ""
7052 appSt.threadFocusID = ""
7053 appSt.threadRelays = nil
7054 appSt.threadFetchedIDs = nil
7055 stopInlineMedia(appSt.threadContainer)
7056 dom.SetStyle(appSt.threadPage, "display", "none")
7057
7058 // Always restore feedContainer - showNoteThread hides it.
7059 dom.SetStyle(appSt.feedContainer, "display", "block")
7060 dom.SetStyle(appSt.topBackBtn, "display", "none")
7061
7062 // Return to the page we came from.
7063 ret := appSt.threadReturnPage
7064 appSt.threadReturnPage = ""
7065 if ret != "" && ret != "feed" {
7066 // Force activePage to "" so switchPage doesn't early-return.
7067 appSt.activePage = ""
7068 switchPage(ret)
7069 // If returning to search, re-activate the bar with the current query
7070 // so the user sees the input and can go back or search again.
7071 if ret == "search" && appSt.searchBtnGlobal != 0 && !appSt.searchBarActive {
7072 activateSearchBar(appSt.searchBtnGlobal)
7073 if appSt.searchCurrentQ != "" {
7074 dom.SetProperty(appSt.searchInputEl, "value", appSt.searchCurrentQ)
7075 }
7076 }
7077 } else {
7078 dom.SetStyle(appSt.feedSelectEl, "display", "inline")
7079 if !appSt.feedExhausted && appSt.oldestFeedTs > 0 {
7080 dom.SetStyle(appSt.loadMoreBtn, "display", "block")
7081 }
7082 }
7083
7084 // Restore scroll position.
7085 if appSt.savedScrollTop != "" {
7086 dom.SetProperty(appSt.contentArea, "scrollTop", appSt.savedScrollTop)
7087 appSt.savedScrollTop = ""
7088 }
7089 }
7090
7091 func handleThreadEvent(gen int32, ev *nostr.Event) {
7092 if gen != appSt.threadGen {
7093 return // stale event from a previous thread
7094 }
7095 appSt.threadEvents[ev.ID] = ev
7096 // Debounced render - 200ms after last event, show what we have.
7097 if appSt.threadRenderTimer != 0 {
7098 dom.ClearTimeout(appSt.threadRenderTimer)
7099 }
7100 appSt.threadRenderTimer = dom.SetTimeout(func() {
7101 appSt.threadRenderTimer = 0
7102 if appSt.threadOpen {
7103 renderThreadTree()
7104 }
7105 }, 200)
7106 }
7107
7108 func handleThreadEOSE() {
7109 // Final render pass.
7110 if appSt.threadRenderTimer != 0 {
7111 dom.ClearTimeout(appSt.threadRenderTimer)
7112 appSt.threadRenderTimer = 0
7113 }
7114 renderThreadTree()
7115 threadFetchReplies()
7116 }
7117
7118 func threadFetchReplies() {
7119 if appSt.threadFollowUp >= 3 || !appSt.threadOpen {
7120 return
7121 }
7122 // Collect event IDs not yet queried for children.
7123 var ids []string
7124 for id := range appSt.threadEvents {
7125 if !appSt.threadFetchedIDs[id] {
7126 ids = mxutil.Ensure(ids, 1)
7127 ids = push(ids, id)
7128 appSt.threadFetchedIDs[id] = true
7129 }
7130 }
7131 if len(ids) == 0 {
7132 return
7133 }
7134 appSt.threadFollowUp++
7135
7136 // Build JSON array of IDs for the #e filter.
7137 eArr := ""
7138 for i, id := range ids {
7139 if i > 0 {
7140 eArr = eArr | ","
7141 }
7142 eArr = eArr | jstr(id)
7143 }
7144 filter := "{\"#e\":[" | eArr | "],\"kinds\":[1]}"
7145
7146 // Local REQ.
7147 appSt.threadSubCount++
7148 s1 := "thr-" | itoa(appSt.threadSubCount)
7149 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7150 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s1)
7151 routeMsg("[\"REQ\"," | jstr(s1) | "," | filter | "]")
7152
7153 // PROXY to remote relays.
7154 if len(appSt.threadRelays) > 0 {
7155 appSt.threadSubCount++
7156 s2 := "thr-" | itoa(appSt.threadSubCount)
7157 appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7158 appSt.threadActiveSubs = push(appSt.threadActiveSubs, s2)
7159 routeMsg(buildProxyMsg(s2, filter, appSt.threadRelays))
7160 }
7161 }
7162
7163 func renderThreadTree() {
7164 n := len(appSt.threadEvents)
7165 if n == appSt.threadLastRendered {
7166 return // no new events since last render
7167 }
7168 appSt.threadLastRendered = n
7169
7170 clearChildren(appSt.threadContainer)
7171
7172 if n == 0 {
7173 empty := dom.CreateElement("div")
7174 dom.SetTextContent(empty, t("thread_empty"))
7175 dom.SetStyle(empty, "color", "var(--muted)")
7176 dom.SetStyle(empty, "padding", "16px 0")
7177 dom.AppendChild(appSt.threadContainer, empty)
7178 return
7179 }
7180
7181 // Build parent->children map. If direct parent isn't in the thread,
7182 // re-parent under root (the event is in-thread if it references root).
7183 children := map[string][]string{}
7184 for id, ev := range appSt.threadEvents {
7185 parentID := getReplyID(ev)
7186 if parentID == "" || parentID == id {
7187 continue
7188 }
7189 if appSt.threadEvents[parentID] == nil && parentID != appSt.threadRootID {
7190 // Direct parent not fetched - attach to root if event belongs to thread.
7191 if referencesEvent(ev, appSt.threadRootID) {
7192 parentID = appSt.threadRootID
7193 }
7194 }
7195 children[parentID] = mxutil.Ensure(children[parentID], 1)
7196 children[parentID] = push(children[parentID], id)
7197 }
7198
7199 // Sort children chronologically.
7200 for pid := range children {
7201 ids := children[pid]
7202 sortByCreatedAt(ids)
7203 children[pid] = ids
7204 }
7205
7206 // Find root - event with no parent in this thread, or threadRootID.
7207 rootID := appSt.threadRootID
7208 if _, ok := appSt.threadEvents[rootID]; !ok {
7209 // Root not fetched; find event with no in-thread parent.
7210 for id, ev := range appSt.threadEvents {
7211 parentID := getReplyID(ev)
7212 if parentID == "" || appSt.threadEvents[parentID] == nil {
7213 rootID = id
7214 break
7215 }
7216 }
7217 }
7218
7219 // Render tree via DFS, tracking what was rendered.
7220 rendered := map[string]bool{}
7221 var renderAt func(id string, depth int32)
7222 renderAt = func(id string, depth int32) {
7223 ev, ok := appSt.threadEvents[id]
7224 if !ok {
7225 return
7226 }
7227 rendered[id] = true
7228 if isMuted(ev.PubKey) || repliesToMuted(ev) {
7229 return // skip muted user and entire sub-branch
7230 }
7231 if looksLikeJSONSpam(ev.Content) {
7232 return
7233 }
7234 renderThreadNote(ev, depth, id == appSt.threadFocusID)
7235 for _, childID := range children[id] {
7236 renderAt(childID, depth+1)
7237 }
7238 }
7239
7240 // If root is present, start there. Otherwise render all as flat.
7241 if _, ok2 := appSt.threadEvents[rootID]; ok2 {
7242 renderAt(rootID, 0)
7243 // Render any remaining orphans (events not reached by DFS).
7244 for id := range appSt.threadEvents {
7245 if !rendered[id] {
7246 renderAt(id, 1)
7247 }
7248 }
7249 } else {
7250 for id := range appSt.threadEvents {
7251 renderAt(id, 0)
7252 }
7253 }
7254
7255 // Scroll focused note into view (centered vertically).
7256 if appSt.threadFocusID != "" {
7257 dom.SetTimeout(func() {
7258 el := dom.GetElementById("thread-focus")
7259 if el != 0 {
7260 top := parseIntProp(dom.GetProperty(el, "offsetTop"))
7261 vh := parseIntProp(dom.GetProperty(appSt.contentArea, "clientHeight"))
7262 scroll := top - vh/2
7263 if scroll < 0 {
7264 scroll = 0
7265 }
7266 dom.SetProperty(appSt.contentArea, "scrollTop", itoa(scroll))
7267 }
7268 }, 50)
7269 }
7270 }
7271
7272 func sortByCreatedAt(ids []string) {
7273 for i := 0; i < len(ids); i++ {
7274 for j := i + 1; j < len(ids); j++ {
7275 ei := appSt.threadEvents[ids[i]]
7276 ej := appSt.threadEvents[ids[j]]
7277 if ei != nil && ej != nil && ei.CreatedAt > ej.CreatedAt {
7278 ids[i], ids[j] = ids[j], ids[i]
7279 }
7280 }
7281 }
7282 }
7283
7284 func renderThreadNote(ev *nostr.Event, depth int32, focused bool) {
7285 note := dom.CreateElement("div")
7286 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
7287 dom.SetStyle(note, "padding", "8px 0")
7288 dom.SetStyle(note, "marginLeft", itoa(depth*8) | "px")
7289
7290 if focused {
7291 dom.SetStyle(note, "borderLeft", "3px solid var(--accent)")
7292 dom.SetStyle(note, "paddingLeft", "8px")
7293 dom.SetStyle(note, "background", "var(--bg2)")
7294 dom.SetStyle(note, "borderRadius", "4px")
7295 dom.SetAttribute(note, "id", "thread-focus")
7296 }
7297
7298 // Header.
7299 header := dom.CreateElement("div")
7300 dom.SetStyle(header, "display", "flex")
7301 dom.SetStyle(header, "alignItems", "center")
7302 dom.SetStyle(header, "marginBottom", "4px")
7303 dom.SetStyle(header, "maxWidth", "65ch")
7304
7305 authorLink := dom.CreateElement("div")
7306 dom.SetStyle(authorLink, "display", "flex")
7307 dom.SetStyle(authorLink, "alignItems", "center")
7308 dom.SetStyle(authorLink, "gap", "6px")
7309 dom.SetStyle(authorLink, "cursor", "pointer")
7310 headerPK := ev.PubKey
7311 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
7312 showProfile(headerPK)
7313 }))
7314
7315 avatar := dom.CreateElement("img")
7316 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
7317 dom.SetAttribute(avatar, "width", "20")
7318 dom.SetAttribute(avatar, "height", "20")
7319 dom.SetStyle(avatar, "borderRadius", "50%")
7320 dom.SetStyle(avatar, "objectFit", "cover")
7321 dom.SetStyle(avatar, "flexShrink", "0")
7322
7323 nameSpan := dom.CreateElement("span")
7324 dom.SetStyle(nameSpan, "fontSize", "14px")
7325 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7326 dom.SetStyle(nameSpan, "fontWeight", "bold")
7327 dom.SetStyle(nameSpan, "color", "var(--fg)")
7328
7329 pk := ev.PubKey
7330 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
7331 setMediaSrc(avatar, pic)
7332 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
7333 } else {
7334 dom.SetStyle(avatar, "display", "none")
7335 }
7336 if name, ok := appSt.authorNames[pk]; ok && name != "" {
7337 dom.SetTextContent(nameSpan, name)
7338 } else {
7339 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7340 if len(npub) > 20 {
7341 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
7342 }
7343 }
7344
7345 dom.AppendChild(authorLink, avatar)
7346 dom.AppendChild(authorLink, nameSpan)
7347 attachHoverCard(authorLink, pk)
7348 dom.AppendChild(header, authorLink)
7349
7350 appendClientTag(header, ev)
7351
7352 // Copy button - copies this note and all replies beneath it.
7353 tCpBtn := noteCopyBtn()
7354 capturedTCpEv := ev
7355 capturedTCpBtn := tCpBtn
7356 dom.AddEventListener(tCpBtn, "click", dom.RegisterCallback(func() {
7357 evs := collectBranch(capturedTCpEv.ID)
7358 text := ""
7359 for _, e := range evs {
7360 text = text | noteClipText(e)
7361 }
7362 dom.WritePrimarySelection(text)
7363 dom.WriteClipboard(text, func(ok bool) {
7364 if ok {
7365 noteCopyFeedback(capturedTCpBtn)
7366 }
7367 })
7368 }))
7369 dom.SetStyle(tCpBtn, "marginLeft", "auto")
7370 dom.AppendChild(header, tCpBtn)
7371
7372 // JSON toggle </>
7373 var tContentEl dom.Element
7374 var tMoreEl dom.Element
7375 tJsonActive := false
7376 var tJsonEl dom.Element
7377 tJsonBtn := dom.CreateElement("span")
7378 dom.SetTextContent(tJsonBtn, "</>")
7379 dom.SetStyle(tJsonBtn, "fontSize", "10px")
7380 dom.SetStyle(tJsonBtn, "fontFamily", "'Fira Code', monospace")
7381 dom.SetStyle(tJsonBtn, "cursor", "pointer")
7382 dom.SetStyle(tJsonBtn, "opacity", "0.6")
7383 dom.SetStyle(tJsonBtn, "marginRight", "8px")
7384 dom.SetStyle(tJsonBtn, "userSelect", "none")
7385 dom.SetStyle(tJsonBtn, "color", "var(--muted)")
7386 capturedTJBtn := tJsonBtn
7387 capturedTEv := ev
7388 capturedTNote := note
7389 dom.AddEventListener(tJsonBtn, "click", dom.RegisterCallback(func() {
7390 if tJsonActive {
7391 dom.SetStyle(tContentEl, "display", "")
7392 dom.SetStyle(tMoreEl, "display", "none")
7393 dom.SetStyle(tJsonEl, "display", "none")
7394 dom.SetStyle(capturedTJBtn, "opacity", "0.6")
7395 dom.SetStyle(capturedTJBtn, "color", "var(--muted)")
7396 tJsonActive = false
7397 } else {
7398 if tJsonEl == 0 {
7399 tJsonEl = dom.CreateElement("pre")
7400 dom.SetStyle(tJsonEl, "fontFamily", "'Fira Code', monospace")
7401 dom.SetStyle(tJsonEl, "fontSize", "12px")
7402 dom.SetStyle(tJsonEl, "lineHeight", "1.5")
7403 dom.SetStyle(tJsonEl, "wordBreak", "break-word")
7404 dom.SetStyle(tJsonEl, "whiteSpace", "pre-wrap")
7405 dom.SetStyle(tJsonEl, "maxWidth", "65ch")
7406 dom.SetStyle(tJsonEl, "margin", "0")
7407 dom.SetStyle(tJsonEl, "color", "var(--fg)")
7408 dom.SetTextContent(tJsonEl, prettyNoteJSON(capturedTEv))
7409 dom.InsertBefore(capturedTNote, tJsonEl, tMoreEl)
7410 }
7411 dom.SetStyle(tContentEl, "display", "none")
7412 dom.SetStyle(tMoreEl, "display", "none")
7413 dom.SetStyle(tJsonEl, "display", "block")
7414 dom.SetStyle(capturedTJBtn, "opacity", "1")
7415 dom.SetStyle(capturedTJBtn, "color", "var(--accent)")
7416 tJsonActive = true
7417 }
7418 }))
7419 dom.AppendChild(header, tJsonBtn)
7420
7421 // Relay widget + timestamp.
7422 rw3 := relayWidget(ev.ID)
7423 dom.AppendChild(header, rw3)
7424 if ev.CreatedAt > 0 {
7425 tsEl := dom.CreateElement("span")
7426 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
7427 dom.SetStyle(tsEl, "fontSize", "11px")
7428 dom.SetStyle(tsEl, "color", "var(--muted)")
7429 dom.AppendChild(header, tsEl)
7430 }
7431
7432 dom.AppendChild(note, header)
7433
7434 if _, cached := appSt.authorNames[pk]; !cached {
7435 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
7436 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
7437 appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
7438 if !lookupFetchedK0(pk) {
7439 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
7440 }
7441 }
7442
7443 // Content.
7444 content := dom.CreateElement("div")
7445 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7446 dom.SetStyle(content, "fontSize", "13px")
7447 dom.SetStyle(content, "lineHeight", "1.5")
7448 dom.SetStyle(content, "wordBreak", "break-word")
7449 dom.SetStyle(content, "maxWidth", "65ch")
7450 setHTMLWithMedia(content, renderMarkdown(ev.Content))
7451 dom.AppendChild(note, content)
7452 tContentEl = content
7453
7454 more := makeShowMore(content)
7455 tMoreEl = more
7456 dom.AppendChild(note, buildActionRow(ev, note, content, more))
7457
7458 dom.AppendChild(appSt.threadContainer, note)
7459 resolveEmbeds()
7460 }
7461
7462 // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY.
7463 // Still used internally; call sites use profile.Send(P_RESOLVE_FORCE) instead.
7464 func fetchAuthorProfile(pk string) {
7465 if lookupFetchedK0(pk) {
7466 return
7467 }
7468 setFetchedK0(pk, true)
7469
7470 appSt.profileSubCounter++
7471 subID := "ap-" | itoa(appSt.profileSubCounter)
7472 appSt.authorSubPK[subID] = pk
7473
7474 proxyRelays := buildProxy(pk)
7475 routeMsg(buildProxyMsg(subID,
7476 "{\"authors\":[" | jstr(pk) | "],\"kinds\":[0,3,10002,10000],\"limit\":6}",
7477 proxyRelays))
7478 }
7479
7480 // retryMissingProfiles batches pubkeys that still lack a name into chunked
7481 // PROXY requests through the orly relay. Fetches all metadata kinds so
7482 // relay lists from kind 10002 enable second-hop discovery.
7483 func retryMissingProfiles() {
7484 var missing []string
7485 for pk := range appSt.pendingNotes {
7486 if _, ok := appSt.authorNames[pk]; !ok {
7487 missing = mxutil.Ensure(missing, 1)
7488 missing = push(missing, pk)
7489 }
7490 }
7491 if len(missing) == 0 {
7492 return
7493 }
7494
7495 // Reset fetchedK0 for still-missing profiles so individual re-fetches
7496 // can fire if new relay info appears from other profiles' kind 10002.
7497 for _, pk := range missing {
7498 setFetchedK0(pk, false)
7499 }
7500
7501 // Discovery relays first, then user relays + discovered relays.
7502 proxy := []string{:len(discoveryRelays)}
7503 copy(proxy, discoveryRelays)
7504 for _, u := range appSt.relayURLs {
7505 proxy = appendUnique(proxy, u)
7506 }
7507 top := topRelays(8)
7508 for _, u := range top {
7509 proxy = appendUnique(proxy, u)
7510 }
7511
7512 const batchSize = 100
7513 batchNum := 0
7514 for i := 0; i < len(missing); i += batchSize {
7515 end := i + batchSize
7516 if end > len(missing) {
7517 end = len(missing)
7518 }
7519 chunk := missing[i:end]
7520 authors := "["
7521 for j, pk := range chunk {
7522 if j > 0 {
7523 authors = authors | ","
7524 }
7525 authors = authors | jstr(pk)
7526 }
7527 authors = authors | "]"
7528 subID := "ap-batch-" | itoa(appSt.retryRound) | "-" | itoa(batchNum)
7529 batchNum++
7530 routeMsg(buildProxyMsg(subID,
7531 "{\"authors\":" | authors | ",\"kinds\":[0,10002],\"limit\":" | itoa(len(chunk)*2) | "}",
7532 proxy))
7533 // Direct query to feed relays.
7534 appSt.profileSubCounter++
7535 routeMsg(buildProxyMsg("ap-d-" | itoa(appSt.profileSubCounter),
7536 "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
7537 appSt.relayURLs))
7538 }
7539 appSt.retryRound++
7540 }
7541
7542 // applyAuthorProfile is no longer called by Shell - Profile Worker owns event
7543 // processing. P_RESOLVED/P_CONTENT handlers in onSWMessage do the DOM updates.
7544
7545 // updateNoteHeader fills in avatar+name on a note's author header div.
7546 func updateNoteHeader(header dom.Element, name, pic string) {
7547 // First child is <img>, second is <span>.
7548 img := dom.FirstChild(header)
7549 if img == 0 {
7550 return
7551 }
7552 span := dom.NextSibling(img)
7553 if len(pic) > 0 {
7554 setMediaSrc(img, pic)
7555 dom.SetAttribute(img, "onerror", "this.style.display='none'")
7556 dom.SetStyle(img, "display", "")
7557 }
7558 if len(name) > 0 {
7559 dom.SetTextContent(span, name)
7560 }
7561 }
7562
7563 // --- Profile page ---
7564
7565 func showEditProfileModal() {
7566 content := appSt.profileContentCache[appSt.pubhex]
7567 curName := helpers.JsonGetString(content, "name")
7568 curAbout := helpers.JsonGetString(content, "about")
7569 curPic := helpers.JsonGetString(content, "picture")
7570 curBanner := helpers.JsonGetString(content, "banner")
7571 curNip05 := helpers.JsonGetString(content, "nip05")
7572 curWebsite := helpers.JsonGetString(content, "website")
7573 curLud16 := helpers.JsonGetString(content, "lud16")
7574
7575 overlay := dom.CreateElement("div")
7576 dom.SetStyle(overlay, "position", "fixed")
7577 dom.SetStyle(overlay, "top", "0")
7578 dom.SetStyle(overlay, "left", "0")
7579 dom.SetStyle(overlay, "right", "0")
7580 dom.SetStyle(overlay, "bottom", "0")
7581 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.6)")
7582 dom.SetStyle(overlay, "display", "flex")
7583 dom.SetStyle(overlay, "alignItems", "center")
7584 dom.SetStyle(overlay, "justifyContent", "center")
7585 dom.SetStyle(overlay, "zIndex", "2000")
7586
7587 modal := dom.CreateElement("div")
7588 dom.SetStyle(modal, "background", "var(--bg)")
7589 dom.SetStyle(modal, "border", "1px solid var(--border)")
7590 dom.SetStyle(modal, "borderRadius", "8px")
7591 dom.SetStyle(modal, "padding", "20px")
7592 dom.SetStyle(modal, "width", "400px")
7593 dom.SetStyle(modal, "maxWidth", "calc(100vw - 32px)")
7594 dom.SetStyle(modal, "maxHeight", "calc(100vh - 64px)")
7595 dom.SetStyle(modal, "overflowY", "auto")
7596
7597 makeField := func(label, val string, multiline bool) dom.Element {
7598 wrap := dom.CreateElement("div")
7599 dom.SetStyle(wrap, "marginBottom", "12px")
7600 lbl := dom.CreateElement("div")
7601 dom.SetTextContent(lbl, label)
7602 dom.SetStyle(lbl, "fontSize", "11px")
7603 dom.SetStyle(lbl, "color", "var(--muted)")
7604 dom.SetStyle(lbl, "marginBottom", "4px")
7605 dom.AppendChild(wrap, lbl)
7606 var input dom.Element
7607 if multiline {
7608 input = dom.CreateElement("textarea")
7609 dom.SetAttribute(input, "rows", "3")
7610 } else {
7611 input = dom.CreateElement("input")
7612 dom.SetAttribute(input, "type", "text")
7613 }
7614 dom.SetStyle(input, "width", "100%")
7615 dom.SetStyle(input, "padding", "6px 8px")
7616 dom.SetStyle(input, "background", "var(--bg2)")
7617 dom.SetStyle(input, "color", "var(--fg)")
7618 dom.SetStyle(input, "border", "1px solid var(--border)")
7619 dom.SetStyle(input, "borderRadius", "4px")
7620 dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
7621 dom.SetStyle(input, "fontSize", "13px")
7622 dom.SetStyle(input, "boxSizing", "border-box")
7623 dom.SetAttribute(input, "value", val)
7624 if multiline {
7625 dom.SetTextContent(input, val)
7626 }
7627 dom.AppendChild(wrap, input)
7628 return wrap
7629 }
7630
7631 nameField := makeField(t("profile_name"), curName, false)
7632 aboutField := makeField(t("profile_about"), curAbout, true)
7633 picField := makeField(t("profile_picture"), curPic, false)
7634 bannerField := makeField(t("profile_banner"), curBanner, false)
7635 nip05Field := makeField(t("profile_nip05"), curNip05, false)
7636 websiteField := makeField(t("profile_website"), curWebsite, false)
7637 lud16Field := makeField(t("profile_lud16"), curLud16, false)
7638
7639 addUploadRow := func(field dom.Element) {
7640 input := dom.NextSibling(dom.FirstChild(field))
7641 btn := dom.CreateElement("button")
7642 dom.SetInnerHTML(btn, `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2v8M5 5l3-3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 10v3a1 1 0 001 1h10a1 1 0 001-1v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
7643 dom.SetStyle(btn, "padding", "6px 8px")
7644 dom.SetStyle(btn, "border", "1px solid var(--border)")
7645 dom.SetStyle(btn, "borderRadius", "4px")
7646 dom.SetStyle(btn, "background", "transparent")
7647 dom.SetStyle(btn, "color", "var(--fg)")
7648 dom.SetStyle(btn, "cursor", "pointer")
7649 dom.SetStyle(btn, "display", "flex")
7650 dom.SetStyle(btn, "alignItems", "center")
7651 dom.SetStyle(btn, "flexShrink", "0")
7652 dom.AddEventListener(btn, "click", dom.RegisterCallback(func() {
7653 dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
7654 if b64 != "" {
7655 blossomUploadToInput(b64, mime, input)
7656 }
7657 })
7658 }))
7659 row := dom.CreateElement("div")
7660 dom.SetStyle(row, "display", "flex")
7661 dom.SetStyle(row, "gap", "4px")
7662 dom.SetStyle(input, "flex", "1")
7663 dom.SetStyle(input, "minWidth", "0")
7664 dom.InsertBefore(field, row, input)
7665 dom.AppendChild(row, input)
7666 dom.AppendChild(row, btn)
7667 }
7668
7669 addUploadRow(picField)
7670 addUploadRow(bannerField)
7671
7672 dom.AppendChild(modal, nameField)
7673 dom.AppendChild(modal, aboutField)
7674 dom.AppendChild(modal, picField)
7675 dom.AppendChild(modal, bannerField)
7676 dom.AppendChild(modal, nip05Field)
7677 dom.AppendChild(modal, websiteField)
7678 dom.AppendChild(modal, lud16Field)
7679
7680 // Buttons row.
7681 btnRow := dom.CreateElement("div")
7682 dom.SetStyle(btnRow, "display", "flex")
7683 dom.SetStyle(btnRow, "gap", "8px")
7684 dom.SetStyle(btnRow, "marginTop", "8px")
7685
7686 getInput := func(field dom.Element) dom.Element {
7687 el := dom.NextSibling(dom.FirstChild(field))
7688 tag := dom.GetProperty(el, "tagName")
7689 if tag == "DIV" {
7690 return dom.FirstChild(el)
7691 }
7692 return el
7693 }
7694
7695 saveBtn := dom.CreateElement("button")
7696 dom.SetTextContent(saveBtn, t("save"))
7697 dom.SetStyle(saveBtn, "padding", "6px 16px")
7698 dom.SetStyle(saveBtn, "fontFamily", "'Fira Code', monospace")
7699 dom.SetStyle(saveBtn, "fontSize", "12px")
7700 dom.SetStyle(saveBtn, "background", "var(--accent)")
7701 dom.SetStyle(saveBtn, "color", "#fff")
7702 dom.SetStyle(saveBtn, "border", "none")
7703 dom.SetStyle(saveBtn, "borderRadius", "4px")
7704 dom.SetStyle(saveBtn, "cursor", "pointer")
7705 dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
7706 newName := dom.GetProperty(getInput(nameField), "value")
7707 newAbout := dom.GetProperty(getInput(aboutField), "value")
7708 newPic := dom.GetProperty(getInput(picField), "value")
7709 newBanner := dom.GetProperty(getInput(bannerField), "value")
7710 newNip05 := dom.GetProperty(getInput(nip05Field), "value")
7711 newWebsite := dom.GetProperty(getInput(websiteField), "value")
7712 newLud16 := dom.GetProperty(getInput(lud16Field), "value")
7713
7714 k0content := "{" |
7715 "\"name\":\"" | jsonEsc(newName) | "\"" |
7716 ",\"about\":\"" | jsonEsc(newAbout) | "\"" |
7717 ",\"picture\":\"" | jsonEsc(newPic) | "\"" |
7718 ",\"banner\":\"" | jsonEsc(newBanner) | "\"" |
7719 ",\"nip05\":\"" | jsonEsc(newNip05) | "\"" |
7720 ",\"website\":\"" | jsonEsc(newWebsite) | "\"" |
7721 ",\"lud16\":\"" | jsonEsc(newLud16) | "\"" |
7722 "}"
7723
7724 ts := dom.NowSeconds()
7725 unsigned := "{\"kind\":0,\"content\":" | jstr(k0content) |
7726 ",\"tags\":[]" |
7727 ",\"created_at\":" | i64toa(ts) |
7728 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
7729
7730 hasSigner := "no"
7731 if signer.HasSigner() {
7732 hasSigner = "yes"
7733 }
7734 dom.ConsoleLog("[profile] signing kind 0, hasSigner=" | hasSigner)
7735 signer.SignEvent(unsigned, func(signed string) {
7736 if len(signed) == 0 {
7737 dom.ConsoleLog("[profile] sign returned empty - check [signer] and [ext.dispatch] logs above")
7738 dom.Confirm("sign failed - open signer panel, unlock vault, and try again")
7739 return
7740 }
7741 dom.ConsoleLog("[profile] sign OK, posting to SW")
7742 selected := composeSelectedRelays()
7743 for _, d := range discoveryRelays {
7744 selected = appendUnique(selected, d)
7745 }
7746 dom.ConsoleLog("[profile] PUBLISH_TO " | itoa(len(selected)) | " relays")
7747 msg := "[\"PUBLISH_TO\"," | signed | ",["
7748 for i, u := range selected {
7749 if i > 0 {
7750 msg = msg | ","
7751 }
7752 msg = msg | jstr(u)
7753 }
7754 routeMsg(msg | "]]")
7755 dom.RemoveChild(dom.Body(), overlay)
7756 // Update local cache immediately.
7757 appSt.profileContentCache[appSt.pubhex] = k0content
7758 if len(newName) > 0 {
7759 appSt.profileName = newName
7760 setAuthorName(appSt.pubhex, newName)
7761 dom.SetTextContent(appSt.nameEl, newName)
7762 }
7763 if len(newPic) > 0 {
7764 appSt.profilePic = newPic
7765 appSt.authorPics[appSt.pubhex] = newPic
7766 setMediaSrc(appSt.avatarEl, newPic)
7767 dom.SetStyle(appSt.avatarEl, "display", "block")
7768 }
7769 appSt.profileTs = ts
7770 renderProfilePage(appSt.pubhex)
7771 })
7772 }))
7773 dom.AppendChild(btnRow, saveBtn)
7774
7775 cancelBtn := dom.CreateElement("button")
7776 dom.SetTextContent(cancelBtn, t("cancel"))
7777 dom.SetStyle(cancelBtn, "padding", "6px 16px")
7778 dom.SetStyle(cancelBtn, "fontFamily", "'Fira Code', monospace")
7779 dom.SetStyle(cancelBtn, "fontSize", "12px")
7780 dom.SetStyle(cancelBtn, "background", "var(--bg2)")
7781 dom.SetStyle(cancelBtn, "color", "var(--fg)")
7782 dom.SetStyle(cancelBtn, "border", "1px solid var(--border)")
7783 dom.SetStyle(cancelBtn, "borderRadius", "4px")
7784 dom.SetStyle(cancelBtn, "cursor", "pointer")
7785 dom.AddEventListener(cancelBtn, "click", dom.RegisterCallback(func() {
7786 dom.RemoveChild(dom.Body(), overlay)
7787 }))
7788 dom.AppendChild(btnRow, cancelBtn)
7789
7790 dom.AppendChild(modal, btnRow)
7791 dom.AppendChild(overlay, modal)
7792 dom.AppendChild(dom.Body(), overlay)
7793 }
7794
7795 func showProfile(pk string) {
7796 if appSt.fetchedK0 == nil {
7797 return
7798 }
7799 // Save current profile's tab state before switching.
7800 if appSt.profileViewPK != "" && appSt.profileWrappers[appSt.profileViewPK] != 0 {
7801 appSt.profileWrapTabSel[appSt.profileViewPK] = appSt.profileTab
7802 appSt.profileWrapTabCont[appSt.profileViewPK] = appSt.profileTabContent
7803 appSt.profileWrapTabs[appSt.profileViewPK] = appSt.profileTabBtns
7804 }
7805
7806 appSt.profileViewPK = pk
7807
7808 // Always fetch fresh metadata in background.
7809 setFetchedK0(pk, false)
7810 profile.Send(`["P_RESOLVE_FORCE",` | jstr(pk) | `]`)
7811
7812 if !appSt.navPop {
7813 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7814 dom.PushState("/p/" | npub)
7815 }
7816
7817 // Hide all cached profile wrappers and remove relay info.
7818 for _, w := range appSt.profileWrappers {
7819 dom.SetStyle(w, "display", "none")
7820 }
7821 if appSt.relayInfoEl != 0 {
7822 dom.RemoveChild(appSt.profilePage, appSt.relayInfoEl)
7823 appSt.relayInfoEl = 0
7824 }
7825
7826 if wrapper, ok2 := appSt.profileWrappers[pk]; ok2 {
7827 // Cache hit - restore.
7828 dom.SetStyle(wrapper, "display", "block")
7829 appSt.profileTabContent = appSt.profileWrapTabCont[pk]
7830 appSt.profileTabBtns = appSt.profileWrapTabs[pk]
7831 appSt.profileTab = appSt.profileWrapTabSel[pk]
7832 profileTouchLRU(pk)
7833 } else {
7834 renderProfilePage(pk)
7835 }
7836
7837 appSt.activePage = "" // force switchPage to run
7838 switchPage("profile")
7839 }
7840
7841 const profileCacheMax = 8
7842
7843 func profileTouchLRU(pk string) {
7844 // Move pk to end (most recent).
7845 out := []string{:0:len(appSt.profileWrapOrder)}
7846 for _, p := range appSt.profileWrapOrder {
7847 if p != pk {
7848 out = mxutil.Ensure(out, 1)
7849 out = push(out, p)
7850 }
7851 }
7852 appSt.profileWrapOrder = push(out, pk)
7853 }
7854
7855 func profileEvictOldest() {
7856 for len(appSt.profileWrapOrder) > profileCacheMax {
7857 old := appSt.profileWrapOrder[0]
7858 appSt.profileWrapOrder = appSt.profileWrapOrder[1:]
7859 if w, ok2 := appSt.profileWrappers[old]; ok2 {
7860 dom.RemoveChild(appSt.profilePage, w)
7861 dom.ReleaseChildren(w)
7862 dom.ReleaseElement(w)
7863 }
7864 delete(appSt.profileWrappers, old)
7865 delete(appSt.profileWrapTabCont, old)
7866 delete(appSt.profileWrapTabs, old)
7867 delete(appSt.profileWrapTabSel, old)
7868 delete(appSt.profileContentCache, old)
7869 delete(appSt.profileFollowsCache, old)
7870 delete(appSt.profileMutesCache, old)
7871 delete(appSt.profileRelaysCache, old)
7872 }
7873 }
7874
7875 func verifyNip05(nip05, pubkeyHex string, badge dom.Element) {
7876 at := -1
7877 for i := 0; i < len(nip05); i++ {
7878 if nip05[i] == '@' {
7879 at = i
7880 break
7881 }
7882 }
7883 if at < 1 || at >= len(nip05)-1 {
7884 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
7885 return
7886 }
7887 local := nip05[:at]
7888 domain := nip05[at+1:]
7889 url := "https://" | domain | "/.well-known/nostr.json?name=" | local
7890 dom.FetchText(url, func(body string) {
7891 if body == "" {
7892 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
7893 return
7894 }
7895 namesObj := helpers.JsonGetString(body, "names")
7896 if namesObj == "" {
7897 // names might be an object not a string - extract manually
7898 namesStart := -1
7899 key := "\"names\""
7900 for i := 0; i < len(body)-len(key); i++ {
7901 if body[i:i+len(key)] == key {
7902 namesStart = i + len(key)
7903 break
7904 }
7905 }
7906 if namesStart < 0 {
7907 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
7908 return
7909 }
7910 // Skip colon and whitespace to find the object
7911 for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') {
7912 namesStart++
7913 }
7914 if namesStart >= len(body) || body[namesStart] != '{' {
7915 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
7916 return
7917 }
7918 // Find matching brace
7919 depth := 0
7920 end := namesStart
7921 for end < len(body) {
7922 if body[end] == '{' {
7923 depth++
7924 } else if body[end] == '}' {
7925 depth--
7926 if depth == 0 {
7927 end++
7928 break
7929 }
7930 }
7931 end++
7932 }
7933 namesObj = body[namesStart:end]
7934 }
7935 got := helpers.JsonGetString(namesObj, local)
7936 if got == pubkeyHex {
7937 dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅
7938 } else {
7939 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
7940 }
7941 })
7942 }
7943
7944 func renderProfilePage(pk string) {
7945 savedTab := appSt.profileTab
7946 closeProfileNoteSub()
7947 appSt.profileNotesSeen = map[string]bool{}
7948
7949 // Remove previous wrapper for this pubkey if it exists.
7950 if old, ok2 := appSt.profileWrappers[pk]; ok2 {
7951 dom.RemoveChild(appSt.profilePage, old)
7952 }
7953
7954 wrapper := dom.CreateElement("div")
7955 appSt.profileWrappers[pk] = wrapper
7956 profileTouchLRU(pk)
7957 profileEvictOldest()
7958 dom.AppendChild(appSt.profilePage, wrapper)
7959
7960 content := appSt.profileContentCache[pk]
7961 name := appSt.authorNames[pk]
7962 pic := appSt.authorPics[pk]
7963 about := helpers.JsonGetString(content, "about")
7964 website := helpers.JsonGetString(content, "website")
7965 nip05 := helpers.JsonGetString(content, "nip05")
7966 lud16 := helpers.JsonGetString(content, "lud16")
7967 banner := helpers.JsonGetString(content, "banner")
7968
7969 // Banner - full width, 200px, cover.
7970 if banner != "" {
7971 bannerEl := dom.CreateElement("img")
7972 dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
7973 setMediaSrc(bannerEl, banner)
7974 dom.SetStyle(bannerEl, "width", "100%")
7975 dom.SetStyle(bannerEl, "height", "240px")
7976 dom.SetStyle(bannerEl, "objectFit", "cover")
7977 dom.SetStyle(bannerEl, "objectPosition", "center 30%")
7978 dom.SetStyle(bannerEl, "display", "block")
7979 dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
7980 dom.AppendChild(wrapper, bannerEl)
7981 }
7982
7983 // User info card - glass effect, overlapping banner.
7984 card := dom.CreateElement("div")
7985 dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 42%, transparent)")
7986 dom.SetStyle(card, "backdropFilter", "blur(8px)")
7987 dom.SetStyle(card, "borderRadius", "8px")
7988 dom.SetStyle(card, "padding", "8px")
7989 if banner != "" {
7990 dom.SetStyle(card, "margin", "-24px 16px 0")
7991 } else {
7992 dom.SetStyle(card, "margin", "16px")
7993 }
7994 dom.SetStyle(card, "position", "relative")
7995 dom.SetStyle(card, "width", "fit-content")
7996 dom.SetStyle(card, "maxWidth", "calc(100% - 32px)")
7997
7998 // Top row: avatar + info. Wraps on narrow screens so avatar goes above.
7999 topRow := dom.CreateElement("div")
8000 dom.SetStyle(topRow, "display", "flex")
8001 dom.SetStyle(topRow, "gap", "16px")
8002 dom.SetStyle(topRow, "alignItems", "flex-start")
8003 dom.SetStyle(topRow, "flexWrap", "wrap")
8004
8005 // Compute npub early - needed for avatar QR click and npub row.
8006 npubBytes := helpers.HexDecode(pk)
8007 npubStr := helpers.EncodeNpub(npubBytes)
8008
8009 if pic != "" {
8010 av := dom.CreateElement("img")
8011 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
8012 setMediaSrc(av, pic)
8013 dom.SetAttribute(av, "width", "64")
8014 dom.SetAttribute(av, "height", "64")
8015 dom.SetStyle(av, "borderRadius", "50%")
8016 dom.SetStyle(av, "objectFit", "cover")
8017 dom.SetStyle(av, "flexShrink", "0")
8018 dom.SetStyle(av, "border", "3px solid var(--bg)")
8019 dom.SetStyle(av, "cursor", "pointer")
8020 dom.SetAttribute(av, "onerror", "this.style.display='none'")
8021 avNpub := npubStr
8022 dom.AddEventListener(av, "click", dom.RegisterCallback(func() {
8023 showQRModal(avNpub)
8024 }))
8025 dom.AppendChild(topRow, av)
8026 }
8027
8028 info := dom.CreateElement("div")
8029 dom.SetStyle(info, "minWidth", "200px")
8030 dom.SetStyle(info, "flex", "1")
8031 dom.SetStyle(info, "overflow", "hidden")
8032
8033 if name != "" {
8034 nameRow := dom.CreateElement("div")
8035 dom.SetStyle(nameRow, "display", "flex")
8036 dom.SetStyle(nameRow, "alignItems", "baseline")
8037 dom.SetStyle(nameRow, "gap", "8px")
8038 nameSpan := dom.CreateElement("span")
8039 dom.SetTextContent(nameSpan, name)
8040 dom.SetStyle(nameSpan, "fontSize", "20px")
8041 dom.SetStyle(nameSpan, "fontWeight", "bold")
8042 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8043 dom.SetStyle(nameSpan, "cursor", "pointer")
8044 nameNpub := npubStr
8045 dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() {
8046 showQRModal(nameNpub)
8047 }))
8048 dom.AppendChild(nameRow, nameSpan)
8049 // "follows you" badge.
8050 if pk != appSt.pubhex {
8051 if theirFollows, ok2 := appSt.profileFollowsCache[pk]; ok2 {
8052 for _, fpk := range theirFollows {
8053 if fpk == appSt.pubhex {
8054 badge := dom.CreateElement("span")
8055 dom.SetTextContent(badge, "follows you")
8056 dom.SetStyle(badge, "fontSize", "11px")
8057 dom.SetStyle(badge, "color", "var(--muted)")
8058 dom.SetStyle(badge, "background", "var(--bg2)")
8059 dom.SetStyle(badge, "padding", "2px 6px")
8060 dom.SetStyle(badge, "borderRadius", "4px")
8061 dom.AppendChild(nameRow, badge)
8062 break
8063 }
8064 }
8065 }
8066 }
8067 dom.AppendChild(info, nameRow)
8068 }
8069
8070 if nip05 != "" {
8071 nip05Row := dom.CreateElement("div")
8072 dom.SetStyle(nip05Row, "display", "flex")
8073 dom.SetStyle(nip05Row, "alignItems", "center")
8074 dom.SetStyle(nip05Row, "gap", "4px")
8075 nip05Text := dom.CreateElement("span")
8076 dom.SetTextContent(nip05Text, nip05)
8077 dom.SetStyle(nip05Text, "color", "var(--muted)")
8078 dom.SetStyle(nip05Text, "fontSize", "13px")
8079 dom.AppendChild(nip05Row, nip05Text)
8080 nip05Badge := dom.CreateElement("span")
8081 dom.SetStyle(nip05Badge, "fontSize", "14px")
8082 dom.AppendChild(nip05Row, nip05Badge)
8083 dom.AppendChild(info, nip05Row)
8084
8085 // Async NIP-05 validation.
8086 verifyNip05(nip05, pk, nip05Badge)
8087 }
8088
8089 // npub row: clickable npub text (shows QR) + copy icon.
8090 npubRow := dom.CreateElement("div")
8091 dom.SetStyle(npubRow, "display", "flex")
8092 dom.SetStyle(npubRow, "alignItems", "center")
8093 dom.SetStyle(npubRow, "gap", "6px")
8094 dom.SetStyle(npubRow, "marginTop", "2px")
8095 npubEl := dom.CreateElement("span")
8096 dom.SetStyle(npubEl, "color", "var(--muted)")
8097 dom.SetStyle(npubEl, "fontSize", "12px")
8098 dom.SetStyle(npubEl, "wordBreak", "break-all")
8099 dom.SetStyle(npubEl, "cursor", "pointer")
8100 dom.SetTextContent(npubEl, npubStr)
8101 npubQR := npubStr
8102 dom.AddEventListener(npubEl, "click", dom.RegisterCallback(func() {
8103 showQRModal(npubQR)
8104 }))
8105 dom.AppendChild(npubRow, npubEl)
8106 copyBtn := actionBtn(`<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><rect x="6" y="6" width="9" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M12 6V4.5A1.5 1.5 0 0010.5 3H4.5A1.5 1.5 0 003 4.5v8A1.5 1.5 0 004.5 14H6" stroke="currentColor" stroke-width="1.5"/></svg>`)
8107 dom.SetAttribute(copyBtn, "data-npub", npubStr)
8108 dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText(this.dataset.npub)")
8109 dom.AppendChild(npubRow, copyBtn)
8110 dom.AppendChild(info, npubRow)
8111
8112 // Website + lightning inline.
8113 if website != "" || lud16 != "" {
8114 metaRow := dom.CreateElement("div")
8115 dom.SetStyle(metaRow, "display", "flex")
8116 dom.SetStyle(metaRow, "flexWrap", "wrap")
8117 dom.SetStyle(metaRow, "gap", "4px 12px")
8118 dom.SetStyle(metaRow, "marginTop", "6px")
8119 dom.SetStyle(metaRow, "fontSize", "12px")
8120 if website != "" {
8121 wEl := dom.CreateElement("span")
8122 dom.SetStyle(wEl, "color", "var(--accent)")
8123 dom.SetStyle(wEl, "wordBreak", "break-all")
8124 dom.SetTextContent(wEl, website)
8125 dom.AppendChild(metaRow, wEl)
8126 }
8127 if lud16 != "" {
8128 lEl := dom.CreateElement("span")
8129 dom.SetStyle(lEl, "color", "var(--muted)")
8130 dom.SetStyle(lEl, "wordBreak", "break-all")
8131 dom.SetStyle(lEl, "cursor", "pointer")
8132 dom.SetTextContent(lEl, "\xE2\x9A\xA1 " | lud16)
8133 capLud := lud16
8134 dom.AddEventListener(lEl, "click", dom.RegisterCallback(func() {
8135 showQRModal(capLud)
8136 }))
8137 dom.AppendChild(metaRow, lEl)
8138 }
8139 dom.AppendChild(info, metaRow)
8140 }
8141
8142 dom.AppendChild(topRow, info)
8143 dom.AppendChild(card, topRow)
8144
8145 // Action buttons - only for other users.
8146 if pk != appSt.pubhex {
8147 btnRow := dom.CreateElement("div")
8148 dom.SetStyle(btnRow, "display", "flex")
8149 dom.SetStyle(btnRow, "gap", "8px")
8150 dom.SetStyle(btnRow, "marginTop", "12px")
8151
8152 // Follow/unfollow button.
8153 followBtn := dom.CreateElement("button")
8154 dom.SetStyle(followBtn, "padding", "6px 16px")
8155 dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
8156 dom.SetStyle(followBtn, "fontSize", "12px")
8157 dom.SetStyle(followBtn, "borderRadius", "4px")
8158 dom.SetStyle(followBtn, "cursor", "pointer")
8159 isFollowingw1 := false
8160 if len(appSt.myFollows) > 0 {
8161 for _, fpk := range appSt.myFollows {
8162 if fpk == pk {
8163 isFollowingw1 = true
8164 break
8165 }
8166 }
8167 }
8168 if isFollowingw1 {
8169 dom.SetTextContent(followBtn, "followed")
8170 dom.SetStyle(followBtn, "background", "var(--bg2)")
8171 dom.SetStyle(followBtn, "color", "var(--fg)")
8172 dom.SetStyle(followBtn, "border", "1px solid var(--border)")
8173 } else {
8174 dom.SetTextContent(followBtn, "follow")
8175 dom.SetStyle(followBtn, "background", "var(--accent)")
8176 dom.SetStyle(followBtn, "color", "#fff")
8177 dom.SetStyle(followBtn, "border", "none")
8178 }
8179 dom.AppendChild(btnRow, followBtn)
8180
8181
8182 // Mute button.
8183 muteBtn := dom.CreateElement("button")
8184 dom.SetStyle(muteBtn, "padding", "6px 16px")
8185 dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
8186 dom.SetStyle(muteBtn, "fontSize", "12px")
8187 dom.SetStyle(muteBtn, "borderRadius", "4px")
8188 dom.SetStyle(muteBtn, "cursor", "pointer")
8189 if isMuted(pk) {
8190 dom.SetTextContent(muteBtn, "muted")
8191 dom.SetStyle(muteBtn, "background", "#c33")
8192 dom.SetStyle(muteBtn, "color", "#fff")
8193 dom.SetStyle(muteBtn, "border", "none")
8194 } else {
8195 dom.SetTextContent(muteBtn, "mute")
8196 dom.SetStyle(muteBtn, "background", "var(--bg2)")
8197 dom.SetStyle(muteBtn, "color", "var(--fg)")
8198 dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
8199 }
8200
8201 // Wire up click handlers with cross-references.
8202 btnPK := pk
8203 cFollow := followBtn
8204 cMute := muteBtn
8205 dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
8206 toggleFollow(btnPK, cFollow, cMute)
8207 }))
8208 dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
8209 toggleMute(btnPK, cMute, cFollow)
8210 }))
8211 dom.AppendChild(btnRow, muteBtn)
8212
8213 dom.AppendChild(card, btnRow)
8214 } else {
8215 // Own profile - edit button.
8216 editRow := dom.CreateElement("div")
8217 dom.SetStyle(editRow, "marginTop", "12px")
8218
8219 editBtn := dom.CreateElement("button")
8220 dom.SetTextContent(editBtn, t("edit_profile"))
8221 dom.SetStyle(editBtn, "padding", "6px 16px")
8222 dom.SetStyle(editBtn, "fontFamily", "'Fira Code', monospace")
8223 dom.SetStyle(editBtn, "fontSize", "12px")
8224 dom.SetStyle(editBtn, "background", "var(--bg2)")
8225 dom.SetStyle(editBtn, "color", "var(--fg)")
8226 dom.SetStyle(editBtn, "border", "1px solid var(--border)")
8227 dom.SetStyle(editBtn, "borderRadius", "4px")
8228 dom.SetStyle(editBtn, "cursor", "pointer")
8229 dom.AddEventListener(editBtn, "click", dom.RegisterCallback(func() {
8230 showEditProfileModal()
8231 }))
8232 dom.AppendChild(editRow, editBtn)
8233 dom.AppendChild(card, editRow)
8234 }
8235
8236 dom.AppendChild(wrapper, card)
8237
8238 // About/bio - same height-cap + show-more pattern as feed notes.
8239 if about != "" {
8240 aboutEl := dom.CreateElement("div")
8241 dom.SetStyle(aboutEl, "padding", "12px 16px")
8242 dom.SetStyle(aboutEl, "fontSize", "14px")
8243 dom.SetStyle(aboutEl, "lineHeight", "1.5")
8244 dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8245 dom.SetStyle(aboutEl, "wordBreak", "break-word")
8246 dom.SetStyle(aboutEl, "overflow", "hidden")
8247 dom.SetStyle(aboutEl, "maxHeight", "18em")
8248 dom.AppendChild(wrapper, aboutEl)
8249 setHTMLWithMedia(aboutEl, renderMarkdown(about))
8250 more := makeShowMore(aboutEl)
8251 dom.SetStyle(more, "padding", "0 16px 8px")
8252 dom.AppendChild(wrapper, more)
8253 attachShowMore(aboutEl, more)
8254 }
8255
8256 // Tab bar.
8257 tabBar := dom.CreateElement("div")
8258 dom.SetStyle(tabBar, "display", "flex")
8259 dom.SetStyle(tabBar, "gap", "0")
8260 dom.SetStyle(tabBar, "margin", "8px 16px 0")
8261 dom.SetStyle(tabBar, "border", "1px solid var(--border)")
8262 dom.SetStyle(tabBar, "borderRadius", "6px")
8263 dom.SetStyle(tabBar, "overflow", "hidden")
8264 dom.SetStyle(tabBar, "width", "fit-content")
8265
8266 appSt.profileTabBtns = map[string]dom.Element{}
8267
8268 // Unrolled - tinyjs range/loop closure aliasing.
8269 tabNotes := makeProtoBtn(t("notes"))
8270 dom.SetStyle(tabNotes, "cursor", "pointer")
8271 appSt.profileTabBtns["notes"] = tabNotes
8272 tabNotesPK := pk
8273 dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() {
8274 selectProfileTab("notes", tabNotesPK)
8275 }))
8276 dom.AppendChild(tabBar, tabNotes)
8277
8278 tabFollows := makeProtoBtn(t("follows"))
8279 dom.SetStyle(tabFollows, "cursor", "pointer")
8280 appSt.profileTabBtns["follows"] = tabFollows
8281 tabFollowsPK := pk
8282 dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() {
8283 selectProfileTab("follows", tabFollowsPK)
8284 }))
8285 dom.AppendChild(tabBar, tabFollows)
8286
8287 tabRelays := makeProtoBtn(t("relays"))
8288 dom.SetStyle(tabRelays, "cursor", "pointer")
8289 appSt.profileTabBtns["relays"] = tabRelays
8290 tabRelaysPK := pk
8291 dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() {
8292 selectProfileTab("relays", tabRelaysPK)
8293 }))
8294 dom.AppendChild(tabBar, tabRelays)
8295
8296 tabMutes := makeProtoBtn(t("mutes"))
8297 dom.SetStyle(tabMutes, "cursor", "pointer")
8298 appSt.profileTabBtns["mutes"] = tabMutes
8299 tabMutesPK := pk
8300 dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() {
8301 selectProfileTab("mutes", tabMutesPK)
8302 }))
8303 dom.AppendChild(tabBar, tabMutes)
8304
8305 dom.AppendChild(wrapper, tabBar)
8306
8307 // Tab content container.
8308 appSt.profileTabContent = dom.CreateElement("div")
8309 dom.SetStyle(appSt.profileTabContent, "padding", "8px 0")
8310 dom.AppendChild(wrapper, appSt.profileTabContent)
8311
8312 // Restore or default tab.
8313 appSt.profileTab = ""
8314 if savedTab != "" {
8315 selectProfileTab(savedTab, pk)
8316 } else {
8317 selectProfileTab("notes", pk)
8318 }
8319
8320 // Save cache refs for this profile.
8321 appSt.profileWrapTabCont[pk] = appSt.profileTabContent
8322 appSt.profileWrapTabs[pk] = appSt.profileTabBtns
8323 appSt.profileWrapTabSel[pk] = appSt.profileTab
8324
8325 _ = name // title shown via topBackBtn, not pageTitleEl
8326 }
8327
8328 func toggleFollow(pk string, btn dom.Element, muteBtn dom.Element) {
8329 if !signer.HasSigner() || appSt.pubhex == "" {
8330 return
8331 }
8332 found := false
8333 var newFollows []string
8334 for _, fpk := range appSt.myFollows {
8335 if fpk == pk {
8336 found = true
8337 continue
8338 }
8339 newFollows = mxutil.Ensure(newFollows, 1)
8340 newFollows = push(newFollows, fpk)
8341 }
8342 if !found {
8343 newFollows = mxutil.Ensure(newFollows, 1)
8344 newFollows = push(newFollows, pk)
8345 }
8346 appSt.myFollows = newFollows
8347 appSt.followSet = buildSet(newFollows)
8348
8349 if found {
8350 dom.SetTextContent(btn, "follow")
8351 dom.SetStyle(btn, "background", "var(--accent)")
8352 dom.SetStyle(btn, "color", "#fff")
8353 dom.SetStyle(btn, "border", "none")
8354 } else {
8355 dom.SetTextContent(btn, "followed")
8356 dom.SetStyle(btn, "background", "var(--bg2)")
8357 dom.SetStyle(btn, "color", "var(--fg)")
8358 dom.SetStyle(btn, "border", "1px solid var(--border)")
8359 // Following unmutes.
8360 if isMuted(pk) {
8361 unmute(pk, muteBtn)
8362 }
8363 }
8364
8365 // Bump contactTs so stale kind 3 events from slow relays are rejected.
8366 appSt.contactTs = dom.NowSeconds()
8367 publishFollowList(newFollows)
8368 if appSt.feedSelectEl != 0 {
8369 populateFeedSelect()
8370 }
8371 if appSt.feedMode == "follows" {
8372 refreshFeed()
8373 }
8374 }
8375
8376 func publishFollowList(follows []string) {
8377 tags := "["
8378 for i, pk := range follows {
8379 if i > 0 {
8380 tags = tags | ","
8381 }
8382 tags = tags | "[\"p\"," | jstr(pk) | "]"
8383 }
8384 tags = tags | "]"
8385 ts := dom.NowSeconds()
8386 unsigned := "{\"kind\":3,\"content\":\"\"" |
8387 ",\"tags\":" | tags |
8388 ",\"created_at\":" | i64toa(ts) |
8389 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
8390 logActivity("follow", "publishing kind 3, " | itoa(len(follows)) | " follows", "calling signer.SignEvent")
8391 signer.SignEvent(unsigned, func(signed string) {
8392 if signed == "" {
8393 logActivity("follow", "FAILED: signer returned empty", "vault locked or signer crashed")
8394 return
8395 }
8396 targets := []string{:len(appSt.relayURLs)}
8397 copy(targets, appSt.relayURLs)
8398 for _, d := range discoveryRelays {
8399 targets = appendUnique(targets, d)
8400 }
8401 msg := "[\"PUBLISH_TO\"," | signed | ",["
8402 relayList := ""
8403 for i, u := range targets {
8404 if i > 0 {
8405 msg = msg | ","
8406 relayList = relayList | "\n"
8407 }
8408 msg = msg | jstr(u)
8409 relayList = relayList | u
8410 }
8411 routeMsg(msg | "]]")
8412 logActivity("follow", "kind 3 sent to " | itoa(len(targets)) | " relays", "relays:\n" | relayList | "\n\nevent:\n" | signed)
8413 })
8414 }
8415
8416 func isMuted(pk string) (ok bool) { return appSt.muteSet[pk] }
8417
8418 func buildSet(pks []string) (m map[string]bool) {
8419 s := map[string]bool{}
8420 for _, pk := range pks {
8421 s[pk] = true
8422 }
8423 return s
8424 }
8425
8426 // broadcastFollows pushes the current follow list to Feed and Notif workers.
8427 // P_FOLLOWS is outbound from Profile Worker to Shell only; never send it inbound.
8428 func broadcastFollows(pks []string) {
8429 j := buildJSONStrArr(pks)
8430 feed.Send(`["F_SET_FOLLOWS",` | j | `]`)
8431 notif.Send(`["N_SET_FOLLOWS",` | j | `]`)
8432 }
8433
8434 // broadcastMutes pushes the current mute list to Feed and Notif workers.
8435 func broadcastMutes(pks []string) {
8436 j := buildJSONStrArr(pks)
8437 feed.Send(`["F_SET_MUTES",` | j | `]`)
8438 notif.Send(`["N_SET_MUTES",` | j | `]`)
8439 }
8440
8441 // buildJSONStrArr builds a JSON array of quoted strings.
8442 func buildJSONStrArr(pks []string) (s string) {
8443 s = "["
8444 for i, pk := range pks {
8445 if i > 0 { s = s | "," }
8446 s = s | jstr(pk)
8447 }
8448 return s | "]"
8449 }
8450
8451 // buildURLsJSON builds a JSON array of quoted URL strings.
8452 func buildURLsJSON(urls []string) (s string) {
8453 return buildJSONStrArr(urls)
8454 }
8455
8456 // repliesToMuted returns true if ev is a reply whose parent "p" tag is a muted user.
8457 func repliesToMuted(ev *nostr.Event) (ok bool) {
8458 if ev.Kind != 1 {
8459 return false
8460 }
8461 hasE := false
8462 for _, tag := range ev.Tags {
8463 if len(tag) >= 2 && tag[0] == "e" {
8464 hasE = true
8465 break
8466 }
8467 }
8468 if !hasE {
8469 return false // not a reply
8470 }
8471 for _, tag := range ev.Tags {
8472 if len(tag) >= 2 && tag[0] == "p" && isMuted(tag[1]) {
8473 return true
8474 }
8475 }
8476 return false
8477 }
8478
8479 func toggleMute(pk string, btn dom.Element, followBtn dom.Element) {
8480 if !signer.HasSigner() || appSt.pubhex == "" {
8481 return
8482 }
8483 found := false
8484 var newMutes []string
8485 for _, m := range appSt.myMutes {
8486 if m == pk {
8487 found = true
8488 continue
8489 }
8490 newMutes = mxutil.Ensure(newMutes, 1)
8491 newMutes = push(newMutes, m)
8492 }
8493 if !found {
8494 newMutes = mxutil.Ensure(newMutes, 1)
8495 newMutes = push(newMutes, pk)
8496 }
8497 appSt.myMutes = newMutes
8498 appSt.muteSet = buildSet(newMutes)
8499
8500 if found {
8501 dom.SetTextContent(btn, "mute")
8502 dom.SetStyle(btn, "background", "var(--bg2)")
8503 dom.SetStyle(btn, "color", "var(--fg)")
8504 dom.SetStyle(btn, "border", "1px solid var(--border)")
8505 // Re-fetch profile notes now that user is unmuted.
8506 if appSt.profileViewPK == pk && appSt.profileTab == "notes" && appSt.profileTabContent != 0 {
8507 clearChildren(appSt.profileTabContent)
8508 renderProfileNotes(pk)
8509 }
8510 } else {
8511 dom.SetTextContent(btn, "muted")
8512 dom.SetStyle(btn, "background", "#c33")
8513 dom.SetStyle(btn, "color", "#fff")
8514 dom.SetStyle(btn, "border", "none")
8515 // Muting unfollows.
8516 if isFollowing(pk) {
8517 unfollow(pk, followBtn)
8518 }
8519 removeMutedNotes(pk)
8520 }
8521
8522 appSt.muteTs = dom.NowSeconds()
8523 publishMuteList(newMutes)
8524 }
8525
8526 func unmute(pk string, btn dom.Element) {
8527 var newMutes []string
8528 for _, m := range appSt.myMutes {
8529 if m != pk {
8530 newMutes = mxutil.Ensure(newMutes, 1)
8531 newMutes = push(newMutes, m)
8532 }
8533 }
8534 appSt.myMutes = newMutes
8535 appSt.muteSet = buildSet(newMutes)
8536 dom.SetTextContent(btn, "mute")
8537 dom.SetStyle(btn, "background", "var(--bg2)")
8538 dom.SetStyle(btn, "color", "var(--fg)")
8539 dom.SetStyle(btn, "border", "1px solid var(--border)")
8540 appSt.muteTs = dom.NowSeconds()
8541 publishMuteList(newMutes)
8542 }
8543
8544 func isFollowing(pk string) (ok bool) { return appSt.followSet[pk] }
8545
8546 func unfollow(pk string, btn dom.Element) {
8547 var newFollows []string
8548 for _, fpk := range appSt.myFollows {
8549 if fpk != pk {
8550 newFollows = mxutil.Ensure(newFollows, 1)
8551 newFollows = push(newFollows, fpk)
8552 }
8553 }
8554 appSt.myFollows = newFollows
8555 appSt.followSet = buildSet(newFollows)
8556 dom.SetTextContent(btn, "follow")
8557 dom.SetStyle(btn, "background", "var(--accent)")
8558 dom.SetStyle(btn, "color", "#fff")
8559 dom.SetStyle(btn, "border", "none")
8560 appSt.contactTs = dom.NowSeconds()
8561 publishFollowList(newFollows)
8562 }
8563
8564 func removeMutedNotes(pk string) {
8565 // Remove from main feed DOM.
8566 removeChildrenByPK(appSt.feedContainer, pk)
8567 // Clear profile notes tab if viewing the muted user.
8568 if appSt.profileViewPK == pk && appSt.profileTab == "notes" && appSt.profileTabContent != 0 {
8569 closeProfileNoteSub()
8570 clearChildren(appSt.profileTabContent)
8571 }
8572 // Purge from feed buffer so muted notes don't reappear on refresh.
8573 if len(appSt.feedBuffer) > 0 {
8574 var kept []*nostr.Event
8575 for _, ev := range appSt.feedBuffer {
8576 if ev.PubKey != pk {
8577 kept = mxutil.Ensure(kept, 1)
8578 kept = push(kept, ev)
8579 }
8580 }
8581 appSt.feedBuffer = kept
8582 }
8583 // Purge from thread cache if a thread is open.
8584 if appSt.threadOpen {
8585 for id, ev := range appSt.threadEvents {
8586 if ev.PubKey == pk {
8587 delete(appSt.threadEvents, id)
8588 }
8589 }
8590 appSt.threadLastRendered = 0
8591 renderThreadTree()
8592 }
8593 }
8594
8595 func publishMuteList(mutes []string) {
8596 tags := "["
8597 for i, pk := range mutes {
8598 if i > 0 {
8599 tags = tags | ","
8600 }
8601 tags = tags | "[\"p\"," | jstr(pk) | "]"
8602 }
8603 tags = tags | "]"
8604 ts := dom.NowSeconds()
8605 unsigned := "{\"kind\":10000,\"content\":\"\"" |
8606 ",\"tags\":" | tags |
8607 ",\"created_at\":" | i64toa(ts) |
8608 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
8609 dom.ConsoleLog("[mute] signing kind 10000 with " | itoa(len(mutes)) | " entries")
8610 signer.SignEvent(unsigned, func(signed string) {
8611 if signed == "" {
8612 dom.ConsoleLog("[mute] sign failed - signer returned empty")
8613 return
8614 }
8615 dom.ConsoleLog("[mute] publishing kind 10000")
8616 selected := composeSelectedRelays()
8617 if len(selected) > 0 {
8618 msg := "[\"PUBLISH_TO\"," | signed | ",["
8619 for i, u := range selected {
8620 if i > 0 {
8621 msg = msg | ","
8622 }
8623 msg = msg | jstr(u)
8624 }
8625 routeMsg(msg | "]]")
8626 } else {
8627 routeMsg("[\"EVENT\"," | signed | "]")
8628 }
8629 })
8630 }
8631
8632 func profileMetaRow(icon, text, link string) (e dom.Element) {
8633 row := dom.CreateElement("div")
8634 dom.SetStyle(row, "padding", "4px 0")
8635 dom.SetStyle(row, "display", "flex")
8636 dom.SetStyle(row, "alignItems", "center")
8637 dom.SetStyle(row, "gap", "8px")
8638
8639 iconEl := dom.CreateElement("span")
8640 dom.SetTextContent(iconEl, icon)
8641 dom.AppendChild(row, iconEl)
8642
8643 if link != "" {
8644 href := link
8645 if strIndex(href, "://") < 0 {
8646 href = "https://" | href
8647 }
8648 a := dom.CreateElement("a")
8649 dom.SetAttribute(a, "href", href)
8650 dom.SetAttribute(a, "target", "_blank")
8651 dom.SetAttribute(a, "rel", "noopener")
8652 dom.SetStyle(a, "color", "var(--accent)")
8653 dom.SetStyle(a, "wordBreak", "break-all")
8654 dom.SetTextContent(a, text)
8655 dom.AppendChild(row, a)
8656 } else {
8657 span := dom.CreateElement("span")
8658 dom.SetStyle(span, "color", "var(--fg)")
8659 dom.SetTextContent(span, text)
8660 dom.AppendChild(row, span)
8661 }
8662 return row
8663 }
8664
8665 // --- Profile tab functions ---
8666
8667 func closeProfileNoteSub() {
8668 if appSt.activeProfileNoteSub != "" {
8669 routeMsg("[\"CLOSE\"," | jstr(appSt.activeProfileNoteSub) | "]")
8670 appSt.activeProfileNoteSub = ""
8671 }
8672 }
8673
8674 // refreshProfileTab re-renders the active tab if we're viewing this author's profile.
8675 func refreshProfileTab(pk string) {
8676 if appSt.profileViewPK != pk || appSt.profileTab == "" {
8677 return
8678 }
8679 // Force re-render by clearing current tab and re-selecting.
8680 saved := appSt.profileTab
8681 appSt.profileTab = ""
8682 selectProfileTab(saved, pk)
8683 }
8684
8685 func selectProfileTab(tab, pk string) {
8686 if tab == appSt.profileTab {
8687 return
8688 }
8689 closeProfileNoteSub()
8690 appSt.profileTab = tab
8691 clearChildren(appSt.profileTabContent)
8692
8693 for id, btn := range appSt.profileTabBtns {
8694 if id == tab {
8695 dom.SetStyle(btn, "background", "var(--accent)")
8696 dom.SetStyle(btn, "color", "#fff")
8697 } else {
8698 dom.SetStyle(btn, "background", "transparent")
8699 dom.SetStyle(btn, "color", "var(--fg)")
8700 }
8701 }
8702
8703 // Update URL hash to reflect active tab.
8704 if !appSt.navPop && appSt.profileViewPK != "" {
8705 npub := helpers.EncodeNpub(helpers.HexDecode(appSt.profileViewPK))
8706 dom.ReplaceState("/p/" | npub | "#" | tab)
8707 }
8708
8709 switch tab {
8710 case "notes":
8711 renderProfileNotes(pk)
8712 case "follows":
8713 renderProfileFollows(pk)
8714 case "relays":
8715 renderProfileRelays(pk)
8716 case "mutes":
8717 renderProfileMutes(pk)
8718 }
8719 }
8720
8721 func renderProfileNotes(pk string) {
8722 appSt.profileNotesSeen = map[string]bool{}
8723 appSt.profileSubCounter++
8724 subID := "pn-" | itoa(appSt.profileSubCounter)
8725 appSt.activeProfileNoteSub = subID
8726 proxyRelays := buildProxy(pk)
8727 routeMsg(buildProxyMsg(subID,
8728 "{\"authors\":[" | jstr(pk) | "],\"kinds\":[1],\"limit\":20}",
8729 proxyRelays))
8730 }
8731
8732 func renderProfileNote(ev *nostr.Event) {
8733 if appSt.profileTabContent == 0 || appSt.profileTab != "notes" {
8734 return
8735 }
8736 if isMuted(ev.PubKey) {
8737 return
8738 }
8739 note := dom.CreateElement("div")
8740 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
8741 dom.SetStyle(note, "padding", "12px 16px")
8742
8743 // Header row: author link (left) + relay widget + timestamp (right).
8744 header := dom.CreateElement("div")
8745 dom.SetStyle(header, "display", "flex")
8746 dom.SetStyle(header, "alignItems", "center")
8747 dom.SetStyle(header, "marginBottom", "4px")
8748 dom.SetStyle(header, "maxWidth", "65ch")
8749
8750 authorLink := dom.CreateElement("div")
8751 dom.SetStyle(authorLink, "display", "flex")
8752 dom.SetStyle(authorLink, "alignItems", "center")
8753 dom.SetStyle(authorLink, "gap", "8px")
8754 dom.SetStyle(authorLink, "cursor", "pointer")
8755 headerPK := ev.PubKey
8756 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
8757 showProfile(headerPK)
8758 }))
8759
8760 avatar := dom.CreateElement("img")
8761 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
8762 dom.SetAttribute(avatar, "width", "24")
8763 dom.SetAttribute(avatar, "height", "24")
8764 dom.SetStyle(avatar, "borderRadius", "50%")
8765 dom.SetStyle(avatar, "objectFit", "cover")
8766 dom.SetStyle(avatar, "flexShrink", "0")
8767
8768 nameSpan := dom.CreateElement("span")
8769 dom.SetStyle(nameSpan, "fontSize", "18px")
8770 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8771 dom.SetStyle(nameSpan, "fontWeight", "bold")
8772 dom.SetStyle(nameSpan, "color", "var(--fg)")
8773
8774 pk := ev.PubKey
8775 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
8776 setMediaSrc(avatar, pic)
8777 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
8778 } else {
8779 dom.SetStyle(avatar, "display", "none")
8780 }
8781 if name, ok := appSt.authorNames[pk]; ok && name != "" {
8782 dom.SetTextContent(nameSpan, name)
8783 } else {
8784 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
8785 if len(npub) > 20 {
8786 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
8787 }
8788 }
8789
8790 dom.AppendChild(authorLink, avatar)
8791 dom.AppendChild(authorLink, nameSpan)
8792 dom.AppendChild(header, authorLink)
8793
8794 appendClientTag(header, ev)
8795
8796 rw := relayWidget(ev.ID)
8797 dom.SetStyle(rw, "marginLeft", "auto")
8798 dom.AppendChild(header, rw)
8799 if ev.CreatedAt > 0 {
8800 tsEl := dom.CreateElement("span")
8801 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
8802 dom.SetStyle(tsEl, "fontSize", "11px")
8803 dom.SetStyle(tsEl, "color", "var(--muted)")
8804 dom.SetStyle(tsEl, "cursor", "pointer")
8805 dom.SetStyle(tsEl, "flexShrink", "0")
8806 evID := ev.ID
8807 evRootID := getRootID(ev)
8808 if evRootID == "" {
8809 evRootID = evID
8810 }
8811 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
8812 showNoteThread(evRootID, evID)
8813 }))
8814 dom.AppendChild(header, tsEl)
8815 }
8816 dom.AppendChild(note, header)
8817
8818 // Track author link for update when profile arrives.
8819 if _, cached := appSt.authorNames[pk]; !cached {
8820 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
8821 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
8822 appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
8823 if !lookupFetchedK0(pk) {
8824 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
8825 }
8826 }
8827
8828 // Reply preview.
8829 addReplyPreview(note, ev)
8830
8831 content := dom.CreateElement("div")
8832 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8833 dom.SetStyle(content, "fontSize", "14px")
8834 dom.SetStyle(content, "lineHeight", "1.5")
8835 dom.SetStyle(content, "wordBreak", "break-word")
8836 dom.SetStyle(content, "maxWidth", "65ch")
8837 dom.SetStyle(content, "overflow", "hidden")
8838 dom.SetStyle(content, "maxHeight", "18em")
8839 setHTMLWithMedia(content, renderMarkdown(ev.Content))
8840 dom.AppendChild(note, content)
8841
8842 more := makeShowMore(content)
8843 dom.AppendChild(note, buildActionRow(ev, note, content, more))
8844
8845 dom.AppendChild(appSt.profileTabContent, note)
8846 attachShowMore(content, more)
8847 resolveEmbeds()
8848 }
8849
8850 func renderProfileFollows(pk string) {
8851 var follows []string
8852 if pk == appSt.pubhex {
8853 follows = appSt.myFollows
8854 } else {
8855 follows = appSt.profileFollowsCache[pk]
8856 }
8857 if len(follows) == 0 {
8858 empty := dom.CreateElement("div")
8859 dom.SetTextContent(empty, t("no_follows"))
8860 dom.SetStyle(empty, "padding", "16px")
8861 dom.SetStyle(empty, "color", "var(--muted)")
8862 dom.SetStyle(empty, "fontSize", "13px")
8863 dom.AppendChild(appSt.profileTabContent, empty)
8864 return
8865 }
8866
8867 countEl := dom.CreateElement("div")
8868 dom.SetTextContent(countEl, itoa(len(follows)) | " " | t("following"))
8869 dom.SetStyle(countEl, "padding", "8px 16px")
8870 dom.SetStyle(countEl, "color", "var(--muted)")
8871 dom.SetStyle(countEl, "fontSize", "12px")
8872 dom.AppendChild(appSt.profileTabContent, countEl)
8873
8874 for i := 0; i < len(follows); i++ {
8875 fpk := follows[i]
8876 row := makeProfileRow(fpk)
8877 dom.AppendChild(appSt.profileTabContent, row)
8878 }
8879 scheduleTabRetry()
8880 }
8881
8882 func renderProfileRelays(pk string) {
8883 relays := appSt.profileRelaysCache[pk]
8884 if len(relays) == 0 {
8885 empty := dom.CreateElement("div")
8886 dom.SetTextContent(empty, t("no_relays"))
8887 dom.SetStyle(empty, "padding", "16px")
8888 dom.SetStyle(empty, "color", "var(--muted)")
8889 dom.SetStyle(empty, "fontSize", "13px")
8890 dom.AppendChild(appSt.profileTabContent, empty)
8891 return
8892 }
8893
8894 for i := 0; i < len(relays); i++ {
8895 rURL := relays[i]
8896 row := dom.CreateElement("div")
8897 dom.SetStyle(row, "padding", "10px 16px")
8898 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
8899 dom.SetStyle(row, "cursor", "pointer")
8900 dom.SetStyle(row, "fontSize", "13px")
8901
8902 urlEl := dom.CreateElement("span")
8903 dom.SetTextContent(urlEl, rURL)
8904 dom.SetStyle(urlEl, "color", "var(--accent)")
8905 dom.AppendChild(row, urlEl)
8906
8907 clickURL := rURL
8908 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
8909 showRelayInfo(clickURL)
8910 }))
8911 dom.AppendChild(appSt.profileTabContent, row)
8912 }
8913 }
8914
8915 func renderProfileMutes(pk string) {
8916 var mutes []string
8917 if pk == appSt.pubhex {
8918 mutes = appSt.myMutes
8919 } else {
8920 mutes = appSt.profileMutesCache[pk]
8921 }
8922 if len(mutes) == 0 {
8923 empty := dom.CreateElement("div")
8924 dom.SetTextContent(empty, t("no_mutes"))
8925 dom.SetStyle(empty, "padding", "16px")
8926 dom.SetStyle(empty, "color", "var(--muted)")
8927 dom.SetStyle(empty, "fontSize", "13px")
8928 dom.AppendChild(appSt.profileTabContent, empty)
8929 return
8930 }
8931
8932 countEl := dom.CreateElement("div")
8933 dom.SetTextContent(countEl, itoa(len(mutes)) | " " | t("muted"))
8934 dom.SetStyle(countEl, "padding", "8px 16px")
8935 dom.SetStyle(countEl, "color", "var(--muted)")
8936 dom.SetStyle(countEl, "fontSize", "12px")
8937 dom.AppendChild(appSt.profileTabContent, countEl)
8938
8939 for i := 0; i < len(mutes); i++ {
8940 mpk := mutes[i]
8941 row := makeProfileRow(mpk)
8942 dom.AppendChild(appSt.profileTabContent, row)
8943 }
8944 scheduleTabRetry()
8945 }
8946
8947 func makeProfileRow(pk string) (e dom.Element) {
8948 row := dom.CreateElement("div")
8949 dom.SetStyle(row, "display", "flex")
8950 dom.SetStyle(row, "alignItems", "center")
8951 dom.SetStyle(row, "gap", "10px")
8952 dom.SetStyle(row, "padding", "10px 16px")
8953 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
8954 dom.SetStyle(row, "cursor", "pointer")
8955
8956 av := dom.CreateElement("img")
8957 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
8958 dom.SetAttribute(av, "width", "32")
8959 dom.SetAttribute(av, "height", "32")
8960 dom.SetStyle(av, "borderRadius", "50%")
8961 dom.SetStyle(av, "objectFit", "cover")
8962 dom.SetStyle(av, "flexShrink", "0")
8963 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
8964 setMediaSrc(av, pic)
8965 } else {
8966 dom.SetStyle(av, "display", "none")
8967 }
8968 dom.SetAttribute(av, "onerror", "this.style.display='none'")
8969 dom.AppendChild(row, av)
8970
8971 nameSpan := dom.CreateElement("span")
8972 dom.SetStyle(nameSpan, "fontSize", "14px")
8973 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8974 if name, ok := appSt.authorNames[pk]; ok && name != "" {
8975 dom.SetTextContent(nameSpan, name)
8976 } else {
8977 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
8978 if len(npub) > 20 {
8979 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
8980 }
8981 }
8982 dom.AppendChild(row, nameSpan)
8983
8984 rowPK := pk
8985 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
8986 showProfile(rowPK)
8987 }))
8988
8989 if _, cached := appSt.authorNames[pk]; !cached {
8990 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
8991 appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], row)
8992 if !lookupFetchedK0(pk) {
8993 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
8994 }
8995 }
8996
8997 return row
8998 }
8999
9000 // --- Relay info page ---
9001
9002 func showRelayInfo(url string) {
9003 appSt.profileViewPK = ""
9004 closeProfileNoteSub()
9005 // Hide cached profile wrappers but keep them.
9006 for _, w := range appSt.profileWrappers {
9007 dom.SetStyle(w, "display", "none")
9008 }
9009 // Remove old relay info page if any.
9010 if appSt.relayInfoEl != 0 {
9011 dom.RemoveChild(appSt.profilePage, appSt.relayInfoEl)
9012 appSt.relayInfoEl = 0
9013 }
9014 appSt.relayInfoEl = dom.CreateElement("div")
9015 dom.AppendChild(appSt.profilePage, appSt.relayInfoEl)
9016
9017 hdr := dom.CreateElement("div")
9018 dom.SetStyle(hdr, "display", "flex")
9019 dom.SetStyle(hdr, "alignItems", "center")
9020 dom.SetStyle(hdr, "gap", "10px")
9021 dom.SetStyle(hdr, "padding", "16px")
9022 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
9023
9024 backBtn := dom.CreateElement("button")
9025 dom.SetInnerHTML(backBtn, "←")
9026 dom.SetStyle(backBtn, "background", "none")
9027 dom.SetStyle(backBtn, "border", "none")
9028 dom.SetStyle(backBtn, "fontSize", "20px")
9029 dom.SetStyle(backBtn, "cursor", "pointer")
9030 dom.SetStyle(backBtn, "color", "var(--fg)")
9031 dom.SetStyle(backBtn, "padding", "0")
9032 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
9033 switchPage("feed")
9034 }))
9035 dom.AppendChild(hdr, backBtn)
9036
9037 urlEl := dom.CreateElement("span")
9038 dom.SetTextContent(urlEl, url)
9039 dom.SetStyle(urlEl, "fontWeight", "bold")
9040 dom.SetStyle(urlEl, "fontSize", "14px")
9041 dom.SetStyle(urlEl, "wordBreak", "break-all")
9042 dom.AppendChild(hdr, urlEl)
9043 dom.AppendChild(appSt.relayInfoEl, hdr)
9044
9045 loading := dom.CreateElement("div")
9046 dom.SetTextContent(loading, t("loading"))
9047 dom.SetStyle(loading, "padding", "16px")
9048 dom.SetStyle(loading, "color", "var(--muted)")
9049 dom.AppendChild(appSt.relayInfoEl, loading)
9050
9051 appSt.activePage = ""
9052 switchPage("profile")
9053 dom.SetTextContent(appSt.pageTitleEl, t("relay_info"))
9054
9055 // Convert wss→https for NIP-11 HTTP fetch.
9056 httpURL := url
9057 if len(httpURL) > 6 && httpURL[:6] == "wss://" {
9058 httpURL = "https://" | httpURL[6:]
9059 } else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
9060 httpURL = "http://" | httpURL[5:]
9061 }
9062
9063 dom.FetchRelayInfo(httpURL, func(body string) {
9064 dom.RemoveChild(appSt.profilePage, loading)
9065 if body == "" {
9066 errEl := dom.CreateElement("div")
9067 dom.SetTextContent(errEl, t("relay_fail"))
9068 dom.SetStyle(errEl, "padding", "16px")
9069 dom.SetStyle(errEl, "color", "#e55")
9070 dom.AppendChild(appSt.relayInfoEl, errEl)
9071 return
9072 }
9073 renderRelayInfoBody(body)
9074 })
9075 }
9076
9077 func renderRelayInfoBody(body string) {
9078 container := dom.CreateElement("div")
9079 dom.SetStyle(container, "padding", "16px")
9080
9081 name := helpers.JsonGetString(body, "name")
9082 desc := helpers.JsonGetString(body, "description")
9083 pk := helpers.JsonGetString(body, "pubkey")
9084 contact := helpers.JsonGetString(body, "contact")
9085 software := helpers.JsonGetString(body, "software")
9086 ver := helpers.JsonGetString(body, "version")
9087
9088 if name != "" {
9089 el := dom.CreateElement("div")
9090 dom.SetTextContent(el, name)
9091 dom.SetStyle(el, "fontSize", "20px")
9092 dom.SetStyle(el, "fontWeight", "bold")
9093 dom.SetStyle(el, "marginBottom", "8px")
9094 dom.SetStyle(el, "fontFamily", "system-ui, sans-serif")
9095 dom.AppendChild(container, el)
9096 }
9097
9098 if desc != "" {
9099 el := dom.CreateElement("div")
9100 dom.SetStyle(el, "fontSize", "14px")
9101 dom.SetStyle(el, "lineHeight", "1.5")
9102 dom.SetStyle(el, "marginBottom", "12px")
9103 dom.SetStyle(el, "wordBreak", "break-word")
9104 dom.AppendChild(container, el)
9105 setHTMLWithMedia(el, renderMarkdown(desc))
9106 }
9107
9108 if contact != "" {
9109 dom.AppendChild(container, profileMetaRow("@", contact, ""))
9110 }
9111 if pk != "" {
9112 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
9113 short := npub
9114 if len(short) > 20 {
9115 short = short[:16] | "..." | short[len(short)-8:]
9116 }
9117 dom.AppendChild(container, profileMetaRow("pk", short, ""))
9118 }
9119 if software != "" {
9120 label := software
9121 if ver != "" {
9122 label = label | " " | ver
9123 }
9124 dom.AppendChild(container, profileMetaRow("sw", label, ""))
9125 }
9126
9127 dom.AppendChild(appSt.relayInfoEl, container)
9128 }
9129
9130 // --- Messaging ---
9131
9132 func relayURLsJSON() (s string) {
9133 msg := "["
9134 for i, url := range appSt.relayURLs {
9135 if i > 0 {
9136 msg = msg | ","
9137 }
9138 msg = msg | jstr(url)
9139 }
9140 return msg | "]"
9141 }
9142
9143 func readRelayURLsJSON() (s string) {
9144 msg := "["
9145 first := true
9146 for i, url := range appSt.relayURLs {
9147 role := "both"
9148 if i < len(appSt.relayRole) {
9149 role = appSt.relayRole[i]
9150 }
9151 if role == "write" {
9152 continue
9153 }
9154 if !first {
9155 msg = msg | ","
9156 }
9157 msg = msg | jstr(url)
9158 first = false
9159 }
9160 return msg | "]"
9161 }
9162
9163 func formatTime(ts int64) (s string) {
9164 if ts == 0 {
9165 return ""
9166 }
9167 d := dom.NowSeconds() - ts
9168 if d < 0 {
9169 d = 0
9170 }
9171 if d < 60 {
9172 return "now"
9173 }
9174 if d < 3600 {
9175 return itoa(int32(d/60)) | "m ago"
9176 }
9177 if d < 86400 {
9178 return itoa(int32(d/3600)) | "h ago"
9179 }
9180 if d < 2592000 {
9181 return itoa(int32(d/86400)) | "d ago"
9182 }
9183 if d < 31536000 {
9184 return itoa(int32(d/2592000)) | "mo ago"
9185 }
9186 return itoa(int32(d/31536000)) | "y ago"
9187 }
9188
9189 func dismissHoverCard() {
9190 if appSt.hoverCardEl != 0 {
9191 dom.Remove(appSt.hoverCardEl)
9192 appSt.hoverCardEl = 0
9193 appSt.hoverCardParent = 0
9194 appSt.hoverCardPK = ""
9195 appSt.hoverCardHover = false
9196 }
9197 if appSt.hoverCardTimer != 0 {
9198 dom.ClearTimeout(appSt.hoverCardTimer)
9199 appSt.hoverCardTimer = 0
9200 }
9201 if appSt.hoverCardDismiss != 0 {
9202 dom.ClearTimeout(appSt.hoverCardDismiss)
9203 appSt.hoverCardDismiss = 0
9204 }
9205 }
9206
9207 func scheduleDismissHoverCard() {
9208 if appSt.hoverCardDismiss != 0 {
9209 dom.ClearTimeout(appSt.hoverCardDismiss)
9210 }
9211 appSt.hoverCardDismiss = dom.SetTimeout(func() {
9212 appSt.hoverCardDismiss = 0
9213 if !appSt.hoverCardHover {
9214 dismissHoverCard()
9215 }
9216 }, 300)
9217 }
9218
9219 func showHoverCard(pk string, anchor dom.Element) {
9220 if pk == appSt.pubhex {
9221 return // don't show hover card for self
9222 }
9223 dismissHoverCard()
9224 appSt.hoverCardPK = pk
9225
9226 card := dom.CreateElement("div")
9227 dom.SetStyle(card, "position", "fixed")
9228 dom.SetStyle(card, "zIndex", "2000")
9229 dom.SetStyle(card, "width", "280px")
9230 dom.SetStyle(card, "maxWidth", "calc(100vw - 16px)")
9231 dom.SetStyle(card, "background", "var(--bg)")
9232 dom.SetStyle(card, "border", "1px solid var(--border)")
9233 dom.SetStyle(card, "borderRadius", "8px")
9234 dom.SetStyle(card, "boxShadow", "0 4px 16px rgba(0,0,0,0.3)")
9235 dom.SetStyle(card, "overflow", "hidden")
9236 dom.SetStyle(card, "fontFamily", "'Fira Code', monospace")
9237
9238 // Banner.
9239 content := appSt.profileContentCache[pk]
9240 if content != "" {
9241 banner := helpers.JsonGetString(content, "banner")
9242 if banner != "" {
9243 bannerEl := dom.CreateElement("img")
9244 dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
9245 setMediaSrc(bannerEl, banner)
9246 dom.SetStyle(bannerEl, "width", "100%")
9247 dom.SetStyle(bannerEl, "height", "80px")
9248 dom.SetStyle(bannerEl, "objectFit", "cover")
9249 dom.SetStyle(bannerEl, "display", "block")
9250 dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
9251 dom.AppendChild(card, bannerEl)
9252 }
9253 }
9254
9255 // Avatar + name row.
9256 infoRow := dom.CreateElement("div")
9257 dom.SetStyle(infoRow, "display", "flex")
9258 dom.SetStyle(infoRow, "alignItems", "center")
9259 dom.SetStyle(infoRow, "gap", "8px")
9260 dom.SetStyle(infoRow, "padding", "8px 10px")
9261
9262 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
9263 av := dom.CreateElement("img")
9264 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
9265 setMediaSrc(av, pic)
9266 dom.SetAttribute(av, "width", "36")
9267 dom.SetAttribute(av, "height", "36")
9268 dom.SetStyle(av, "borderRadius", "50%")
9269 dom.SetStyle(av, "objectFit", "cover")
9270 dom.SetStyle(av, "flexShrink", "0")
9271 dom.SetAttribute(av, "onerror", "this.style.display='none'")
9272 dom.AppendChild(infoRow, av)
9273 }
9274
9275 nameElw1 := dom.CreateElement("span")
9276 dom.SetStyle(nameElw1, "fontWeight", "bold")
9277 dom.SetStyle(nameElw1, "fontSize", "13px")
9278 dom.SetStyle(nameElw1, "color", "var(--fg)")
9279 if name, ok := appSt.authorNames[pk]; ok && name != "" {
9280 dom.SetTextContent(nameElw1, name)
9281 } else {
9282 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
9283 if len(npub) > 20 {
9284 dom.SetTextContent(nameElw1, npub[:12] | "..." | npub[len(npub)-4:])
9285 }
9286 }
9287 dom.AppendChild(infoRow, nameElw1)
9288 dom.AppendChild(card, infoRow)
9289
9290 // Button row: follow + mute.
9291 if signer.HasSigner() && appSt.pubhex != "" {
9292 btnRow := dom.CreateElement("div")
9293 dom.SetStyle(btnRow, "display", "flex")
9294 dom.SetStyle(btnRow, "gap", "6px")
9295 dom.SetStyle(btnRow, "padding", "0 10px 8px")
9296
9297 followBtn := dom.CreateElement("button")
9298 dom.SetStyle(followBtn, "padding", "4px 10px")
9299 dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
9300 dom.SetStyle(followBtn, "fontSize", "11px")
9301 dom.SetStyle(followBtn, "borderRadius", "4px")
9302 dom.SetStyle(followBtn, "cursor", "pointer")
9303 isFollowingw2 := false
9304 if len(appSt.myFollows) > 0 {
9305 for _, fpk := range appSt.myFollows {
9306 if fpk == pk {
9307 isFollowingw2 = true
9308 break
9309 }
9310 }
9311 }
9312 if isFollowingw2 {
9313 dom.SetTextContent(followBtn, "followed")
9314 dom.SetStyle(followBtn, "background", "var(--bg2)")
9315 dom.SetStyle(followBtn, "color", "var(--fg)")
9316 dom.SetStyle(followBtn, "border", "1px solid var(--border)")
9317 } else {
9318 dom.SetTextContent(followBtn, "follow")
9319 dom.SetStyle(followBtn, "background", "var(--accent)")
9320 dom.SetStyle(followBtn, "color", "#fff")
9321 dom.SetStyle(followBtn, "border", "none")
9322 }
9323 dom.AppendChild(btnRow, followBtn)
9324
9325 muteBtn := dom.CreateElement("button")
9326 dom.SetStyle(muteBtn, "padding", "4px 10px")
9327 dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
9328 dom.SetStyle(muteBtn, "fontSize", "11px")
9329 dom.SetStyle(muteBtn, "borderRadius", "4px")
9330 dom.SetStyle(muteBtn, "cursor", "pointer")
9331 if isMuted(pk) {
9332 dom.SetTextContent(muteBtn, "muted")
9333 dom.SetStyle(muteBtn, "background", "#c33")
9334 dom.SetStyle(muteBtn, "color", "#fff")
9335 dom.SetStyle(muteBtn, "border", "none")
9336 } else {
9337 dom.SetTextContent(muteBtn, "mute")
9338 dom.SetStyle(muteBtn, "background", "var(--bg2)")
9339 dom.SetStyle(muteBtn, "color", "var(--fg)")
9340 dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
9341 }
9342
9343 hcPK := pk
9344 hcFollow := followBtn
9345 hcMute := muteBtn
9346 dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
9347 toggleFollow(hcPK, hcFollow, hcMute)
9348 }))
9349 dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
9350 toggleMute(hcPK, hcMute, hcFollow)
9351 }))
9352 dom.AppendChild(btnRow, muteBtn)
9353
9354 dom.AppendChild(card, btnRow)
9355 }
9356
9357 appSt.hoverCardHover = false
9358 dom.AddEventListener(card, "mouseenter", dom.RegisterCallback(func() {
9359 appSt.hoverCardHover = true
9360 if appSt.hoverCardDismiss != 0 {
9361 dom.ClearTimeout(appSt.hoverCardDismiss)
9362 appSt.hoverCardDismiss = 0
9363 }
9364 }))
9365 dom.AddEventListener(card, "mouseleave", dom.RegisterCallback(func() {
9366 appSt.hoverCardHover = false
9367 scheduleDismissHoverCard()
9368 }))
9369
9370 appSt.hoverCardEl = card
9371 appSt.hoverCardParent = dom.Body()
9372 dom.AppendChild(dom.Body(), card)
9373 dom.GetBoundingRect(anchor, func(left, top, right, bottom, width, height int32) {
9374 vw := dom.GetViewportWidth()
9375 cardW := 280
9376 if cardW > vw-16 {
9377 cardW = vw - 16
9378 }
9379 x := left
9380 if x+cardW > vw-8 {
9381 x = vw - 8 - cardW
9382 }
9383 if x < 8 {
9384 x = 8
9385 }
9386 dom.SetStyle(card, "left", itoa(x) | "px")
9387 dom.SetStyle(card, "top", itoa(bottom) | "px")
9388 })
9389 }
9390
9391 func attachHoverCard(authorLink dom.Element, pk string) {
9392 dom.SetStyle(authorLink, "position", "relative")
9393 hcPK := pk
9394 dom.AddEventListener(authorLink, "mouseenter", dom.RegisterCallback(func() {
9395 if appSt.hoverCardTimer != 0 {
9396 dom.ClearTimeout(appSt.hoverCardTimer)
9397 }
9398 if appSt.hoverCardDismiss != 0 {
9399 dom.ClearTimeout(appSt.hoverCardDismiss)
9400 appSt.hoverCardDismiss = 0
9401 }
9402 capPK := hcPK
9403 capAnchor := authorLink
9404 appSt.hoverCardTimer = dom.SetTimeout(func() {
9405 appSt.hoverCardTimer = 0
9406 showHoverCard(capPK, capAnchor)
9407 }, 400)
9408 }))
9409 dom.AddEventListener(authorLink, "mouseleave", dom.RegisterCallback(func() {
9410 if appSt.hoverCardTimer != 0 {
9411 dom.ClearTimeout(appSt.hoverCardTimer)
9412 appSt.hoverCardTimer = 0
9413 }
9414 scheduleDismissHoverCard()
9415 }))
9416 }
9417
9418 // relayWidget creates a hover tooltip showing which relays delivered a note.
9419 func relayWidget(evID string) (e dom.Element) {
9420 wrap := dom.CreateElement("span")
9421 dom.SetStyle(wrap, "position", "relative")
9422 dom.SetStyle(wrap, "flexShrink", "0")
9423 dom.SetStyle(wrap, "marginRight", "6px")
9424
9425 btn := dom.CreateElement("span")
9426 dom.SetTextContent(btn, "\xf0\x9f\x93\xa1") // 📡
9427 dom.SetStyle(btn, "fontSize", "10px")
9428 dom.SetStyle(btn, "cursor", "pointer")
9429 dom.SetStyle(btn, "opacity", "0.6")
9430 dom.AppendChild(wrap, btn)
9431
9432 var tip dom.Element
9433 capturedID := evID
9434
9435 dom.AddEventListener(wrap, "click", dom.RegisterCallback(func() {
9436 showRelayModal(capturedID)
9437 }))
9438
9439 dom.AddEventListener(wrap, "mouseenter", dom.RegisterCallback(func() {
9440 if tip != 0 {
9441 return
9442 }
9443 urls := appSt.eventRelays[capturedID]
9444
9445 tip = dom.CreateElement("div")
9446 dom.SetStyle(tip, "position", "absolute")
9447 dom.SetStyle(tip, "top", "22px")
9448 dom.SetStyle(tip, "right", "0")
9449 dom.SetStyle(tip, "zIndex", "1000")
9450 dom.SetStyle(tip, "background", "var(--bg)")
9451 dom.SetStyle(tip, "border", "1px solid var(--border)")
9452 dom.SetStyle(tip, "borderRadius", "6px")
9453 dom.SetStyle(tip, "padding", "6px 10px")
9454 dom.SetStyle(tip, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
9455 dom.SetStyle(tip, "fontSize", "11px")
9456 dom.SetStyle(tip, "whiteSpace", "nowrap")
9457 dom.SetStyle(tip, "fontFamily", "'Fira Code', monospace")
9458 dom.SetStyle(tip, "pointerEvents", "none")
9459
9460 if len(urls) == 0 {
9461 line := dom.CreateElement("div")
9462 dom.SetStyle(line, "color", "var(--muted)")
9463 dom.SetTextContent(line, "(no relay data)")
9464 dom.AppendChild(tip, line)
9465 } else {
9466 for _, u := range urls {
9467 line := dom.CreateElement("div")
9468 dom.SetStyle(line, "padding", "1px 0")
9469 dom.SetStyle(line, "color", "var(--fg)")
9470 dom.SetTextContent(line, u)
9471 dom.AppendChild(tip, line)
9472 }
9473 }
9474 dom.AppendChild(wrap, tip)
9475 }))
9476
9477 dom.AddEventListener(wrap, "mouseleave", dom.RegisterCallback(func() {
9478 if tip != 0 {
9479 dom.RemoveChild(wrap, tip)
9480 tip = 0
9481 }
9482 }))
9483
9484 return wrap
9485 }
9486
9487 func showRelayModal(evID string) {
9488 overlay := dom.CreateElement("div")
9489 dom.SetStyle(overlay, "position", "fixed")
9490 dom.SetStyle(overlay, "top", "0")
9491 dom.SetStyle(overlay, "left", "0")
9492 dom.SetStyle(overlay, "width", "100%")
9493 dom.SetStyle(overlay, "height", "100%")
9494 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
9495 dom.SetStyle(overlay, "zIndex", "500")
9496 dom.SetStyle(overlay, "display", "flex")
9497 dom.SetStyle(overlay, "alignItems", "center")
9498 dom.SetStyle(overlay, "justifyContent", "center")
9499
9500 modal := dom.CreateElement("div")
9501 dom.SetStyle(modal, "background", "var(--bg)")
9502 dom.SetStyle(modal, "border", "1px solid var(--border)")
9503 dom.SetStyle(modal, "borderRadius", "8px")
9504 dom.SetStyle(modal, "maxWidth", "90vw")
9505 dom.SetStyle(modal, "maxHeight", "70vh")
9506 dom.SetStyle(modal, "display", "flex")
9507 dom.SetStyle(modal, "flexDirection", "column")
9508 dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.3)")
9509
9510 // Header.
9511 hdr := dom.CreateElement("div")
9512 dom.SetStyle(hdr, "display", "flex")
9513 dom.SetStyle(hdr, "alignItems", "center")
9514 dom.SetStyle(hdr, "justifyContent", "space-between")
9515 dom.SetStyle(hdr, "padding", "12px 16px")
9516 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
9517
9518 title := dom.CreateElement("span")
9519 dom.SetTextContent(title, "note seen on:")
9520 dom.SetStyle(title, "fontWeight", "bold")
9521 dom.SetStyle(title, "fontSize", "14px")
9522 dom.AppendChild(hdr, title)
9523
9524 closeBtn := dom.CreateElement("button")
9525 dom.SetTextContent(closeBtn, "\xc3\x97") // ×
9526 dom.SetStyle(closeBtn, "background", "transparent")
9527 dom.SetStyle(closeBtn, "border", "none")
9528 dom.SetStyle(closeBtn, "fontSize", "20px")
9529 dom.SetStyle(closeBtn, "cursor", "pointer")
9530 dom.SetStyle(closeBtn, "color", "var(--muted)")
9531 dom.SetStyle(closeBtn, "padding", "0 4px")
9532 dom.AppendChild(hdr, closeBtn)
9533 dom.AppendChild(modal, hdr)
9534
9535 // Scrollable list - rebuilt by renderList() on open and on SEEN_ON updates.
9536 list := dom.CreateElement("div")
9537 dom.SetStyle(list, "overflowY", "auto")
9538 dom.SetStyle(list, "padding", "12px 16px")
9539 dom.AppendChild(modal, list)
9540
9541 renderList := func() {
9542 dom.ReleaseChildren(list)
9543 dom.SetInnerHTML(list, "")
9544 urls := appSt.eventRelays[evID]
9545 if len(urls) == 0 {
9546 line := dom.CreateElement("div")
9547 dom.SetStyle(line, "color", "var(--muted)")
9548 dom.SetStyle(line, "fontSize", "13px")
9549 dom.SetTextContent(line, "(no relay data yet - waiting for OK)")
9550 dom.AppendChild(list, line)
9551 return
9552 }
9553 for _, u := range urls {
9554 row := dom.CreateElement("div")
9555 dom.SetStyle(row, "padding", "6px 0")
9556 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
9557 dom.SetStyle(row, "fontSize", "13px")
9558 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
9559 dom.SetStyle(row, "wordBreak", "break-all")
9560 dom.SetTextContent(row, u)
9561 dom.AppendChild(list, row)
9562 }
9563 }
9564 renderList()
9565 appSt.seenOnSubs[evID] = renderList
9566
9567 // Rebroadcast button - publish this event to all write relays.
9568 if appSt.pubhex != "" {
9569 ev, _ := lookupEvent(evID)
9570 if ev != nil {
9571 footer := dom.CreateElement("div")
9572 dom.SetStyle(footer, "padding", "8px 16px 12px")
9573 dom.SetStyle(footer, "borderTop", "1px solid var(--border)")
9574 rbtn := dom.CreateElement("span")
9575 dom.SetTextContent(rbtn, "rebroadcast to all relays")
9576 dom.SetStyle(rbtn, "cursor", "pointer")
9577 dom.SetStyle(rbtn, "color", "var(--accent)")
9578 dom.SetStyle(rbtn, "fontSize", "13px")
9579 dom.SetStyle(rbtn, "fontFamily", "'Fira Code', monospace")
9580 evJSON := ev.ToJSON()
9581 capturedEvID := ev.ID
9582 dom.AddEventListener(rbtn, "click", dom.RegisterCallback(func() {
9583 dom.SetTextContent(rbtn, "sending...")
9584 dom.SetStyle(rbtn, "color", "var(--muted)")
9585 dom.SetStyle(rbtn, "cursor", "default")
9586 msg := "[\"PUBLISH_TO\"," | evJSON | ",["
9587 relayList := ""
9588 for i, url := range appSt.relayURLs {
9589 if i > 0 {
9590 msg = msg | ","
9591 relayList = relayList | "\n"
9592 }
9593 msg = msg | jstr(url)
9594 relayList = relayList | url
9595 }
9596 routeMsg(msg | "]]")
9597 logActivity("rebroadcast", capturedEvID[:12] | " to " | itoa(len(appSt.relayURLs)) | " relays", "event " | capturedEvID | "\nrelays:\n" | relayList)
9598 dom.SetTimeout(func() {
9599 dom.SetTextContent(rbtn, "sent \u2713")
9600 }, 500)
9601 }))
9602 dom.AppendChild(footer, rbtn)
9603 dom.AppendChild(modal, footer)
9604 }
9605 }
9606
9607 dom.AppendChild(overlay, modal)
9608
9609 closeFn := dom.RegisterCallback(func() {
9610 delete(appSt.seenOnSubs, evID)
9611 dom.RemoveChild(dom.Body(), overlay)
9612 })
9613 dom.AddEventListener(closeBtn, "click", closeFn)
9614 dom.AddSelfEventListener(overlay, "click", closeFn)
9615
9616 dom.AppendChild(dom.Body(), overlay)
9617 }
9618
9619 // --- Emoji data ---
9620 // emojiList is defined in emoji_data.mx (1761 entries from Unicode 16.0).
9621
9622 type emojiEntry struct {
9623 emoji string
9624 name string
9625 }
9626
9627
9628 func initEmoji() {
9629 appSt.emojiByName = map[string]string{}
9630 for _, e := range emojiList {
9631 appSt.emojiByName[":" | e.name | ":"] = e.emoji
9632 }
9633 }
9634
9635 // replaceEmojiCodes replaces :name: shortcodes in text with emoji characters.
9636 func replaceEmojiCodes(s string) (sv string) {
9637 out := ""
9638 start := 0
9639 i := 0
9640 for i < len(s) {
9641 if s[i] == ':' {
9642 end := -1
9643 for j := i + 1; j < len(s) && j < i+30; j++ {
9644 if s[j] == ':' {
9645 end = j
9646 break
9647 }
9648 if s[j] == ' ' || s[j] == '\n' {
9649 break
9650 }
9651 }
9652 if end > 0 {
9653 code := s[i : end+1]
9654 if emoji, ok2 := appSt.emojiByName[code]; ok2 {
9655 out = out | s[start:i] | emoji
9656 i = end + 1
9657 start = i
9658 continue
9659 }
9660 }
9661 }
9662 i++
9663 }
9664 return out | s[start:]
9665 }
9666
9667 // --- Action button row ---
9668
9669 type zapRowPending struct {
9670 row dom.Element
9671 ev *nostr.Event
9672 }
9673
9674 // attachZapButton appends the zap button + total span to the end of an
9675 // action button row, and registers the total element for 9735 updates.
9676 func attachZapButton(row dom.Element, ev *nostr.Event) {
9677 zapWrap := dom.CreateElement("span")
9678 dom.SetStyle(zapWrap, "display", "inline-flex")
9679 dom.SetStyle(zapWrap, "alignItems", "center")
9680 dom.SetStyle(zapWrap, "gap", "4px")
9681 zapBtn := actionBtn(svgZap)
9682 capturedZapEv := ev
9683 dom.AddEventListener(zapBtn, "click", dom.RegisterCallback(func() {
9684 showZapModal(capturedZapEv)
9685 }))
9686 dom.AppendChild(zapWrap, zapBtn)
9687 zapTotalEl := dom.CreateElement("span")
9688 dom.SetStyle(zapTotalEl, "fontSize", "12px")
9689 dom.SetStyle(zapTotalEl, "color", "var(--muted)")
9690 dom.SetStyle(zapTotalEl, "whiteSpace", "nowrap")
9691 if msats, ok := appSt.noteZapTotals[ev.ID]; ok && msats > 0 {
9692 dom.SetTextContent(zapTotalEl, "\xe2\x9a\xa1 " | i64toa(msats/1000) | " sats")
9693 }
9694 dom.AppendChild(zapWrap, zapTotalEl)
9695 appSt.noteZapEls[ev.ID] = zapTotalEl
9696 moreEl := dom.QuerySelectorFrom(row, "[data-more]")
9697 if moreEl != 0 {
9698 dom.InsertBefore(row, zapWrap, moreEl)
9699 } else {
9700 dom.AppendChild(row, zapWrap)
9701 }
9702 }
9703
9704 // applyZapButtonsForAuthor injects zap buttons into any action rows that
9705 // were built before this author's lud16 was known.
9706 func applyZapButtonsForAuthor(pk string) {
9707 pending, ok := appSt.pendingZapRows[pk]
9708 if !ok {
9709 return
9710 }
9711 delete(appSt.pendingZapRows, pk)
9712 for _, p := range pending {
9713 attachZapButton(p.row, p.ev)
9714 }
9715 }
9716
9717 func buildActionRow(ev *nostr.Event, noteEl dom.Element, contentEl dom.Element, moreEl dom.Element) (e dom.Element) {
9718 wrap := dom.CreateElement("div")
9719 dom.SetStyle(wrap, "marginTop", "8px")
9720 dom.SetStyle(wrap, "maxWidth", "65ch")
9721
9722 // Cache the event for repost/quote content.
9723 setEvent(ev.ID, ev)
9724 fillEmbed(ev)
9725
9726 // Reaction display - own line above buttons.
9727 reactDisplay := dom.CreateElement("div")
9728 dom.SetStyle(reactDisplay, "display", "flex")
9729 dom.SetStyle(reactDisplay, "flexWrap", "wrap")
9730 dom.SetStyle(reactDisplay, "alignItems", "center")
9731 dom.SetStyle(reactDisplay, "gap", "4px")
9732 dom.SetStyle(reactDisplay, "fontSize", "12px")
9733 dom.SetStyle(reactDisplay, "marginBottom", "4px")
9734 if _, ok2 := appSt.noteReactionMap[ev.ID]; ok2 {
9735 fillReactionDisplay(reactDisplay, ev.ID)
9736 }
9737 appSt.noteReactionEls[ev.ID] = reactDisplay
9738 dom.AppendChild(wrap, reactDisplay)
9739
9740 // Button row.
9741 row := dom.CreateElement("div")
9742 dom.SetStyle(row, "display", "flex")
9743 dom.SetStyle(row, "alignItems", "center")
9744 dom.SetStyle(row, "gap", "16px")
9745
9746 // Reply button.
9747 replyBtn := actionBtn(svgReply)
9748 capturedEv1 := ev
9749 capturedNote1 := noteEl
9750 dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
9751 showEditor("reply", capturedEv1, capturedNote1)
9752 }))
9753 dom.AppendChild(row, replyBtn)
9754
9755 // Quote button.
9756 quoteBtn := actionBtn(svgQuote)
9757 capturedEv2 := ev
9758 capturedNote2 := noteEl
9759 dom.AddEventListener(quoteBtn, "click", dom.RegisterCallback(func() {
9760 showEditor("quote", capturedEv2, capturedNote2)
9761 }))
9762 dom.AppendChild(row, quoteBtn)
9763
9764 // Repost button + counter.
9765 repostWrap := dom.CreateElement("span")
9766 dom.SetStyle(repostWrap, "display", "flex")
9767 dom.SetStyle(repostWrap, "alignItems", "center")
9768 dom.SetStyle(repostWrap, "gap", "4px")
9769 repostBtn := actionBtn(svgRepost)
9770 capturedEv3 := ev
9771 dom.AddEventListener(repostBtn, "click", dom.RegisterCallback(func() {
9772 onRepostClick(capturedEv3)
9773 }))
9774 dom.AppendChild(repostWrap, repostBtn)
9775 repostCount := dom.CreateElement("span")
9776 dom.SetStyle(repostCount, "fontSize", "12px")
9777 dom.SetStyle(repostCount, "color", "var(--muted)")
9778 if pks, ok := appSt.noteRepostCounts[ev.ID]; ok && len(pks) > 0 {
9779 dom.SetTextContent(repostCount, itoa(len(pks)))
9780 }
9781 dom.AppendChild(repostWrap, repostCount)
9782 appSt.noteRepostEls[ev.ID] = repostCount
9783 dom.AppendChild(row, repostWrap)
9784
9785 // React button.
9786 reactWrap := dom.CreateElement("span")
9787 dom.SetStyle(reactWrap, "display", "inline-flex")
9788 dom.SetStyle(reactWrap, "alignItems", "center")
9789 reactBtn := actionBtn(svgReact)
9790 capturedEv4 := ev
9791 capturedWrap := reactWrap
9792 dom.AddEventListener(reactBtn, "click", dom.RegisterCallback(func() {
9793 showEmojiPicker(capturedEv4, capturedWrap)
9794 }))
9795 dom.AppendChild(reactWrap, reactBtn)
9796 dom.AppendChild(row, reactWrap)
9797
9798 // Zap button - right of react. Only rendered if the author has a
9799 // lightning address. If the profile isn't cached yet, queue this row so
9800 // applyAuthorProfile can inject the button when it arrives.
9801 authorHasLN := false
9802 if content, ok2 := appSt.profileContentCache[ev.PubKey]; ok2 {
9803 if helpers.JsonGetString(content, "lud16") != "" || helpers.JsonGetString(content, "lud06") != "" {
9804 authorHasLN = true
9805 }
9806 }
9807 if authorHasLN {
9808 attachZapButton(row, ev)
9809 } else {
9810 appSt.pendingZapRows[ev.PubKey] = mxutil.Ensure(appSt.pendingZapRows[ev.PubKey], 1)
9811 appSt.pendingZapRows[ev.PubKey] = mxutil.Ensure(appSt.pendingZapRows[ev.PubKey], 1)
9812 appSt.pendingZapRows[ev.PubKey] = push(appSt.pendingZapRows[ev.PubKey], zapRowPending{row: row, ev: ev})
9813 }
9814
9815 if moreEl != 0 {
9816 dom.SetStyle(moreEl, "marginLeft", "auto")
9817 dom.SetAttribute(moreEl, "data-more", "1")
9818 dom.AppendChild(row, moreEl)
9819 }
9820 dom.AppendChild(wrap, row)
9821
9822 // Queue fetch for kind 6/7 if not already fetched.
9823 queueActionFetch(ev.ID)
9824
9825 return wrap
9826 }
9827
9828 func actionBtn(svgHTML string) (e dom.Element) {
9829 btn := dom.CreateElement("span")
9830 dom.SetInnerHTML(btn, svgHTML)
9831 dom.SetStyle(btn, "cursor", "pointer")
9832 dom.SetStyle(btn, "display", "inline-flex")
9833 dom.SetStyle(btn, "alignItems", "center")
9834 dom.SetStyle(btn, "color", "var(--muted)")
9835 dom.SetStyle(btn, "opacity", "0.6")
9836 dom.SetStyle(btn, "userSelect", "none")
9837 capturedBtn := btn
9838 dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
9839 dom.SetStyle(capturedBtn, "opacity", "1")
9840 }))
9841 dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
9842 dom.SetStyle(capturedBtn, "opacity", "0.5")
9843 }))
9844 return btn
9845 }
9846
9847 // --- Reaction/repost display ---
9848
9849 func fillReactionDisplay(el dom.Element, evID string) {
9850 rm, ok := appSt.noteReactionMap[evID]
9851 if !ok {
9852 return
9853 }
9854 // Sort by count descending (simple selection sort).
9855 type ec struct {
9856 emoji string
9857 count int32
9858 }
9859 var sorted []ec
9860 for e, pks := range rm {
9861 sorted = mxutil.Ensure(sorted, 1)
9862 sorted = push(sorted, ec{e, len(pks)})
9863 }
9864 for i := 0; i < len(sorted); i++ {
9865 for j := i + 1; j < len(sorted); j++ {
9866 if sorted[j].count > sorted[i].count {
9867 sorted[i], sorted[j] = sorted[j], sorted[i]
9868 }
9869 }
9870 }
9871 clearChildren(el)
9872 for i, e := range sorted {
9873 if i >= 8 {
9874 more := dom.CreateElement("span")
9875 dom.SetTextContent(more, "...")
9876 dom.SetStyle(more, "color", "var(--muted)")
9877 dom.AppendChild(el, more)
9878 break
9879 }
9880 chip := dom.CreateElement("span")
9881 dom.SetTextContent(chip, e.emoji | " " | itoa(e.count))
9882 dom.SetStyle(chip, "whiteSpace", "nowrap")
9883 dom.AppendChild(el, chip)
9884 }
9885 }
9886
9887 // --- Reaction/repost fetching ---
9888
9889 func queueActionFetch(evID string) {
9890 if appSt.actionFetchedIDs[evID] {
9891 return
9892 }
9893 appSt.actionFetchedIDs[evID] = true
9894 appSt.actionFetchQueue = mxutil.Ensure(appSt.actionFetchQueue, 1)
9895 appSt.actionFetchQueue = push(appSt.actionFetchQueue, evID)
9896 if appSt.actionFetchTimer != 0 {
9897 dom.ClearTimeout(appSt.actionFetchTimer)
9898 }
9899 appSt.actionFetchTimer = dom.SetTimeout(func() {
9900 appSt.actionFetchTimer = 0
9901 flushActionFetch()
9902 }, 400)
9903 }
9904
9905 func flushActionFetch() {
9906 if len(appSt.actionFetchQueue) == 0 {
9907 return
9908 }
9909 queue := appSt.actionFetchQueue
9910 appSt.actionFetchQueue = nil
9911
9912 const batchSize = 20
9913 for i := 0; i < len(queue); i += batchSize {
9914 end := i + batchSize
9915 if end > len(queue) {
9916 end = len(queue)
9917 }
9918 chunk := queue[i:end]
9919 ids := "["
9920 for j, id := range chunk {
9921 if j > 0 {
9922 ids = ids | ","
9923 }
9924 ids = ids | jstr(id)
9925 }
9926 ids = ids | "]"
9927 appSt.actionSubCounter++
9928 subID := "act-" | itoa(appSt.actionSubCounter)
9929
9930 var urls []string
9931 seen := map[string]bool{}
9932 for _, u := range appSt.relayURLs {
9933 if !seen[u] {
9934 seen[u] = true
9935 urls = mxutil.Ensure(urls, 1)
9936 urls = push(urls, u)
9937 }
9938 }
9939 for _, u := range topRelays(4) {
9940 if !seen[u] {
9941 seen[u] = true
9942 urls = mxutil.Ensure(urls, 1)
9943 urls = push(urls, u)
9944 }
9945 }
9946 routeMsg(buildProxyMsg(subID,
9947 "{\"kinds\":[6,7,9735],\"#e\":" | ids | ",\"limit\":500}",
9948 urls))
9949 }
9950 }
9951
9952 func handleActionEvent(ev *nostr.Event) {
9953 if appSt.noteRepostCounts == nil || appSt.noteReactionMap == nil || appSt.noteRepostEls == nil || appSt.noteReactionEls == nil {
9954 return
9955 }
9956 // Find which event this references.
9957 var targetID string
9958 for _, tag := range ev.Tags {
9959 if len(tag) >= 2 && tag[0] == "e" {
9960 targetID = tag[1]
9961 break
9962 }
9963 }
9964 if targetID == "" {
9965 return
9966 }
9967
9968 if ev.Kind == 6 {
9969 pks := appSt.noteRepostCounts[targetID]
9970 if pks == nil {
9971 pks = map[string]bool{}
9972 appSt.noteRepostCounts[targetID] = pks
9973 }
9974 if !pks[ev.PubKey] {
9975 pks[ev.PubKey] = true
9976 if el, ok2 := appSt.noteRepostEls[targetID]; ok2 {
9977 dom.SetTextContent(el, itoa(len(pks)))
9978 }
9979 }
9980 } else if ev.Kind == 9735 {
9981 // Zap receipt: accumulate msat amount from the bolt11 tag (ground truth).
9982 seen := appSt.noteZapSeen[targetID]
9983 if seen == nil {
9984 seen = map[string]bool{}
9985 appSt.noteZapSeen[targetID] = seen
9986 }
9987 if seen[ev.ID] {
9988 return
9989 }
9990 seen[ev.ID] = true
9991 var bolt11 string
9992 for _, tag := range ev.Tags {
9993 if len(tag) >= 2 && tag[0] == "bolt11" {
9994 bolt11 = tag[1]
9995 break
9996 }
9997 }
9998 if bolt11 == "" {
9999 return
10000 }
10001 msats := parseBolt11Msat(bolt11)
10002 if msats <= 0 {
10003 return
10004 }
10005 cur := int64(0)
10006 if v, ok2 := appSt.noteZapTotals[targetID]; ok2 {
10007 cur = v
10008 }
10009 cur = cur + msats
10010 appSt.noteZapTotals[targetID] = cur
10011 if el, ok2 := appSt.noteZapEls[targetID]; ok2 {
10012 dom.SetTextContent(el, "\xe2\x9a\xa1 " | i64toa(cur/1000) | " sats")
10013 }
10014 } else if ev.Kind == 7 {
10015 emoji := ev.Content
10016 if emoji == "" || emoji == " | " {
10017 emoji = "\xe2\x9d\xa4\xef\xb8\x8f"
10018 }
10019 inner := appSt.noteReactionMap[targetID]
10020 if inner == nil {
10021 inner = map[string]map[string]bool{}
10022 }
10023 pks := inner[emoji]
10024 if pks == nil {
10025 pks = map[string]bool{}
10026 }
10027 if !pks[ev.PubKey] {
10028 pks[ev.PubKey] = true
10029 inner[emoji] = pks
10030 appSt.noteReactionMap[targetID] = inner
10031 if el, ok2 := appSt.noteReactionEls[targetID]; ok2 {
10032 fillReactionDisplay(el, targetID)
10033 }
10034 }
10035 }
10036 }
10037
10038 // --- Repost ---
10039
10040 func onRepostClick(ev *nostr.Event) {
10041 if !signer.HasSigner() || appSt.pubhex == "" {
10042 return
10043 }
10044 if !dom.Confirm("Repost this note?") {
10045 return
10046 }
10047 relay := ""
10048 if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
10049 relay = urls[0]
10050 }
10051 tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
10052 ts := dom.NowSeconds()
10053 unsigned := "{\"kind\":6,\"content\":" | jstr(ev.ToJSON()) |
10054 ",\"tags\":" | tags |
10055 ",\"created_at\":" | i64toa(ts) |
10056 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
10057 signer.SignEvent(unsigned, func(signed string) {
10058 if signed != "" {
10059 selected := composeSelectedRelays()
10060 if len(selected) > 0 {
10061 msg := "[\"PUBLISH_TO\"," | signed | ",["
10062 for i, u := range selected {
10063 if i > 0 {
10064 msg = msg | ","
10065 }
10066 msg = msg | jstr(u)
10067 }
10068 routeMsg(msg | "]]")
10069 } else {
10070 routeMsg("[\"EVENT\"," | signed | "]")
10071 }
10072 pks := appSt.noteRepostCounts[ev.ID]
10073 if pks == nil {
10074 pks = map[string]bool{}
10075 appSt.noteRepostCounts[ev.ID] = pks
10076 }
10077 pks[appSt.pubhex] = true
10078 if el, ok2 := appSt.noteRepostEls[ev.ID]; ok2 {
10079 dom.SetTextContent(el, itoa(len(pks)))
10080 }
10081 }
10082 })
10083 }
10084
10085 // --- Compose editor (reply + quote) ---
10086
10087 func editorDraftKey(mode string, evID string) (s string) {
10088 return mode | ":" | evID
10089 }
10090
10091 func showEditor(mode string, ev *nostr.Event, noteEl dom.Element) {
10092 if !signer.HasSigner() || appSt.pubhex == "" {
10093 return
10094 }
10095 if appSt.editorOpen {
10096 closeEditor()
10097 }
10098 appSt.editorOpen = true
10099 appSt.editorPreviewing = false
10100 appSt.editorMode = mode
10101 appSt.editorTargetEv = ev
10102 appSt.editorNoteEl = noteEl
10103
10104 composer := dom.CreateElement("div")
10105 dom.SetStyle(composer, "borderTop", "1px solid var(--border)")
10106 dom.SetStyle(composer, "marginTop", "8px")
10107 dom.SetStyle(composer, "paddingTop", "8px")
10108 dom.SetStyle(composer, "maxWidth", "65ch")
10109 dom.SetStyle(composer, "display", "flex")
10110 dom.SetStyle(composer, "flexDirection", "column")
10111 dom.SetStyle(composer, "gap", "8px")
10112 appSt.editorComposer = composer
10113
10114 // Header row: mode label + preview toggle + close.
10115 headerRow := dom.CreateElement("div")
10116 dom.SetStyle(headerRow, "display", "flex")
10117 dom.SetStyle(headerRow, "alignItems", "center")
10118 dom.SetStyle(headerRow, "gap", "8px")
10119
10120 modeLabel := dom.CreateElement("span")
10121 dom.SetStyle(modeLabel, "fontSize", "13px")
10122 dom.SetStyle(modeLabel, "fontWeight", "bold")
10123 dom.SetStyle(modeLabel, "color", "var(--muted)")
10124 if mode == "reply" {
10125 dom.SetTextContent(modeLabel, "Reply")
10126 } else {
10127 dom.SetTextContent(modeLabel, "Quote")
10128 }
10129 dom.AppendChild(headerRow, modeLabel)
10130
10131 editorImgBtn := dom.CreateElement("button")
10132 dom.SetInnerHTML(editorImgBtn, `<svg width="14" height="14" viewBox="0 0 18 18" fill="none"><rect x="2" y="3" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/><circle cx="6.5" cy="7.5" r="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M2 13l4-4 3 3 2-2 5 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
10133 dom.SetStyle(editorImgBtn, "padding", "2px 6px")
10134 dom.SetStyle(editorImgBtn, "border", "1px solid var(--border)")
10135 dom.SetStyle(editorImgBtn, "borderRadius", "4px")
10136 dom.SetStyle(editorImgBtn, "background", "transparent")
10137 dom.SetStyle(editorImgBtn, "color", "var(--fg)")
10138 dom.SetStyle(editorImgBtn, "cursor", "pointer")
10139 dom.SetStyle(editorImgBtn, "display", "flex")
10140 dom.SetStyle(editorImgBtn, "alignItems", "center")
10141 dom.AddEventListener(editorImgBtn, "click", dom.RegisterCallback(func() {
10142 dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
10143 if b64 != "" {
10144 blossomUpload(b64, mime, appSt.editorTextarea)
10145 }
10146 })
10147 }))
10148 dom.AppendChild(headerRow, editorImgBtn)
10149
10150 previewBtn := dom.CreateElement("button")
10151 dom.SetTextContent(previewBtn, "Preview")
10152 dom.SetStyle(previewBtn, "padding", "2px 8px")
10153 dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
10154 dom.SetStyle(previewBtn, "borderRadius", "4px")
10155 dom.SetStyle(previewBtn, "background", "transparent")
10156 dom.SetStyle(previewBtn, "color", "var(--fg)")
10157 dom.SetStyle(previewBtn, "cursor", "pointer")
10158 dom.SetStyle(previewBtn, "fontSize", "11px")
10159 dom.AppendChild(headerRow, previewBtn)
10160
10161 closeBtn := dom.CreateElement("span")
10162 dom.SetTextContent(closeBtn, "\u2715")
10163 dom.SetStyle(closeBtn, "marginLeft", "auto")
10164 dom.SetStyle(closeBtn, "cursor", "pointer")
10165 dom.SetStyle(closeBtn, "color", "var(--muted)")
10166 dom.SetStyle(closeBtn, "fontSize", "14px")
10167 dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(func() {
10168 closeEditor()
10169 }))
10170 dom.AppendChild(headerRow, closeBtn)
10171
10172 dom.AppendChild(composer, headerRow)
10173
10174 ta := dom.CreateElement("textarea")
10175 dom.SetStyle(ta, "width", "100%")
10176 dom.SetStyle(ta, "minHeight", "80px")
10177 dom.SetStyle(ta, "resize", "vertical")
10178 dom.SetStyle(ta, "overflowY", "auto")
10179 dom.SetStyle(ta, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
10180 dom.SetStyle(ta, "fontSize", "14px")
10181 dom.SetStyle(ta, "padding", "8px")
10182 dom.SetStyle(ta, "border", "1px solid var(--border)")
10183 dom.SetStyle(ta, "borderRadius", "4px")
10184 dom.SetStyle(ta, "background", "var(--bg)")
10185 dom.SetStyle(ta, "color", "var(--fg)")
10186 dom.SetStyle(ta, "boxSizing", "border-box")
10187 dom.SetAttribute(ta, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
10188 appSt.editorTextarea = ta
10189
10190 // Restore draft or set initial content.
10191 draftKey := editorDraftKey(mode, ev.ID)
10192 if draft, ok2 := appSt.editorDrafts[draftKey]; ok2 {
10193 dom.SetProperty(ta, "value", draft)
10194 } else if mode == "quote" {
10195 relay := ""
10196 if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
10197 relay = urls[0]
10198 }
10199 var relays []string
10200 if relay != "" {
10201 relays = []string{relay}
10202 }
10203 nevent := helpers.EncodeNevent(ev.ID, relays, ev.PubKey)
10204 dom.SetProperty(ta, "value", "nostr:" | nevent | "\n\n")
10205 }
10206
10207 wireEmojiHandler(ta)
10208 wireBlossomHandlers(ta)
10209 wireMentionHandler(ta)
10210 dom.AppendChild(composer, ta)
10211
10212 // Markdown preview area (hidden initially).
10213 prevArea := dom.CreateElement("div")
10214 dom.SetStyle(prevArea, "display", "none")
10215 dom.SetStyle(prevArea, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
10216 dom.SetStyle(prevArea, "fontSize", "14px")
10217 dom.SetStyle(prevArea, "lineHeight", "1.5")
10218 dom.SetStyle(prevArea, "padding", "8px")
10219 dom.SetStyle(prevArea, "border", "1px solid var(--border)")
10220 dom.SetStyle(prevArea, "borderRadius", "4px")
10221 dom.SetStyle(prevArea, "minHeight", "80px")
10222 dom.SetStyle(prevArea, "wordBreak", "break-word")
10223 dom.SetStyle(prevArea, "overflowY", "auto")
10224 appSt.editorPreviewEl = prevArea
10225 dom.AppendChild(composer, prevArea)
10226
10227 // Preview toggle.
10228 capturedTa := ta
10229 capturedPrev := prevArea
10230 capturedBtn := previewBtn
10231 dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
10232 appSt.editorPreviewing = !appSt.editorPreviewing
10233 if appSt.editorPreviewing {
10234 text := dom.GetProperty(capturedTa, "value")
10235 text = replaceEmojiCodes(text)
10236 setHTMLWithMedia(capturedPrev, renderMarkdown(text))
10237 resolveEmbeds()
10238 dom.SetStyle(capturedTa, "display", "none")
10239 dom.SetStyle(capturedPrev, "display", "block")
10240 dom.SetTextContent(capturedBtn, "Edit")
10241 dom.SetStyle(capturedBtn, "background", "var(--accent)")
10242 dom.SetStyle(capturedBtn, "color", "#fff")
10243 dom.SetStyle(capturedBtn, "border", "1px solid var(--accent)")
10244 } else {
10245 dom.SetStyle(capturedTa, "display", "")
10246 dom.SetStyle(capturedPrev, "display", "none")
10247 dom.SetTextContent(capturedBtn, "Preview")
10248 dom.SetStyle(capturedBtn, "background", "transparent")
10249 dom.SetStyle(capturedBtn, "color", "var(--fg)")
10250 dom.SetStyle(capturedBtn, "border", "1px solid var(--border)")
10251 }
10252 }))
10253
10254 // Button row.
10255 btnRow := dom.CreateElement("div")
10256 dom.SetStyle(btnRow, "display", "flex")
10257 dom.SetStyle(btnRow, "justifyContent", "flex-end")
10258 dom.SetStyle(btnRow, "gap", "8px")
10259
10260 pubBtn := dom.CreateElement("button")
10261 dom.SetTextContent(pubBtn, "Publish")
10262 dom.SetAttribute(pubBtn, "data-publish-btn", "1")
10263 dom.SetStyle(pubBtn, "padding", "5px 14px")
10264 dom.SetStyle(pubBtn, "border", "none")
10265 dom.SetStyle(pubBtn, "borderRadius", "4px")
10266 dom.SetStyle(pubBtn, "background", "var(--accent)")
10267 dom.SetStyle(pubBtn, "color", "#fff")
10268 dom.SetStyle(pubBtn, "cursor", "pointer")
10269 dom.SetStyle(pubBtn, "fontWeight", "bold")
10270 dom.SetStyle(pubBtn, "fontSize", "13px")
10271 dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
10272 publishEditorContent()
10273 }))
10274 dom.AppendChild(btnRow, pubBtn)
10275
10276 dom.AppendChild(composer, btnRow)
10277 dom.AppendChild(noteEl, composer)
10278 dom.Focus(ta)
10279 }
10280
10281 func closeEditor() {
10282 // Save draft before closing.
10283 if appSt.editorTextarea != 0 && appSt.editorTargetEv != nil {
10284 text := dom.GetProperty(appSt.editorTextarea, "value")
10285 key := editorDraftKey(appSt.editorMode, appSt.editorTargetEv.ID)
10286 if text != "" {
10287 appSt.editorDrafts[key] = text
10288 } else {
10289 delete(appSt.editorDrafts, key)
10290 }
10291 }
10292 if appSt.editorComposer != 0 && appSt.editorNoteEl != 0 {
10293 dom.RemoveChild(appSt.editorNoteEl, appSt.editorComposer)
10294 appSt.editorComposer = 0
10295 appSt.editorNoteEl = 0
10296 }
10297 appSt.editorOpen = false
10298 appSt.editorTargetEv = nil
10299 appSt.editorTextarea = 0
10300 appSt.editorPreviewEl = 0
10301 appSt.editorPreviewing = false
10302 closeEmojiAuto()
10303 closeMentionPopup()
10304 }
10305
10306 func publishEditorContent() {
10307 dom.ConsoleLog("[publish-reply] enter")
10308 if appSt.editorTargetEv == nil || appSt.editorTextarea == 0 {
10309 dom.ConsoleLog("[publish-reply] no target or textarea")
10310 return
10311 }
10312 content := dom.GetProperty(appSt.editorTextarea, "value")
10313 dom.ConsoleLog("[publish-reply] content len=" | itoa(len(content)))
10314 content = replaceEmojiCodes(content)
10315 if content == "" {
10316 dom.ConsoleLog("[publish-reply] empty after emoji replace")
10317 return
10318 }
10319
10320 ev := appSt.editorTargetEv
10321 mode := appSt.editorMode
10322 // Clear draft on publish.
10323 delete(appSt.editorDrafts, editorDraftKey(mode, ev.ID))
10324 closeEditor()
10325
10326 relay := ""
10327 if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
10328 relay = urls[0]
10329 }
10330
10331 // Build tags.
10332 tags := "["
10333 rootID := getRootID(ev)
10334
10335 if mode == "reply" {
10336 // e-tags: root + reply.
10337 if rootID != "" && rootID != ev.ID {
10338 tags = tags | "[\"e\"," | jstr(rootID) | "," | jstr(relay) | ",\"root\"],"
10339 tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"reply\"]"
10340 } else {
10341 // Replying to a root note - it becomes the root, our reply points to it.
10342 tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"root\"]"
10343 }
10344 // p-tags: collect all users in the chain.
10345 ptags := map[string]bool{}
10346 for _, tag := range ev.Tags {
10347 if len(tag) >= 2 && tag[0] == "p" && tag[1] != appSt.pubhex {
10348 ptags[tag[1]] = true
10349 }
10350 }
10351 if ev.PubKey != appSt.pubhex {
10352 ptags[ev.PubKey] = true
10353 }
10354 for pk := range ptags {
10355 tags = tags | ",[\"p\"," | jstr(pk) | "]"
10356 }
10357 } else {
10358 // Quote: q-tag + p-tag for quoted author.
10359 tags = tags | "[\"q\"," | jstr(ev.ID) | "]"
10360 if ev.PubKey != appSt.pubhex {
10361 tags = tags | ",[\"p\"," | jstr(ev.PubKey) | "]"
10362 }
10363 }
10364 // Client tag.
10365 if localstorage.GetItem("musiquay-client-tag") != "0" {
10366 tags = tags | ",[\"client\",\"https://smesh.lol\"]"
10367 }
10368 tags = tags | "]"
10369
10370 ts := dom.NowSeconds()
10371 unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
10372 ",\"tags\":" | tags |
10373 ",\"created_at\":" | i64toa(ts) |
10374 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
10375 dom.ConsoleLog("[publish-reply] calling SignEvent")
10376 signer.SignEvent(unsigned, func(signed string) {
10377 if signed == "" {
10378 logActivity("reply", "FAILED: signer error (vault locked?)", unsigned)
10379 dom.Confirm("publish failed: signer error (vault locked?)")
10380 return
10381 }
10382 // Publish to selected relays (or all if none selected).
10383 selected := composeSelectedRelays()
10384 preview := content
10385 if len(preview) > 80 {
10386 preview = preview[:80] | "..."
10387 }
10388 relayList := ""
10389 for j, u := range selected {
10390 if j > 0 {
10391 relayList = relayList | "\n"
10392 }
10393 relayList = relayList | u
10394 }
10395 if len(selected) > 0 {
10396 msg := "[\"PUBLISH_TO\"," | signed | ",["
10397 for i, u := range selected {
10398 if i > 0 {
10399 msg = msg | ","
10400 }
10401 msg = msg | jstr(u)
10402 }
10403 routeMsg(msg | "]]")
10404 } else {
10405 routeMsg("[\"EVENT\"," | signed | "]")
10406 }
10407 logActivity("reply", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
10408 published := nostr.ParseEvent(signed)
10409 if published != nil {
10410 insertPublishedNote(published)
10411 }
10412 })
10413 }
10414
10415 func publishDelete(targetID string) {
10416 if !dom.Confirm("Delete this note?") {
10417 return
10418 }
10419 ts := dom.NowSeconds()
10420 unsigned := "{\"kind\":5,\"content\":\"\"" |
10421 ",\"tags\":[[\"e\"," | jstr(targetID) | "]]" |
10422 ",\"created_at\":" | i64toa(ts) |
10423 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
10424 signer.SignEvent(unsigned, func(signed string) {
10425 if signed == "" {
10426 return
10427 }
10428 delEv := nostr.ParseEvent(signed)
10429 if delEv != nil {
10430 appSt.pendingDeletes[delEv.ID] = targetID
10431 delID := delEv.ID
10432 dom.SetTimeout(func() {
10433 delete(appSt.pendingDeletes, delID)
10434 }, 30000)
10435 }
10436 routeMsg("[\"EVENT\"," | signed | "]")
10437 })
10438 }
10439
10440 func removeNoteFromDOM(evID string) {
10441 el, ok := appSt.noteElements[evID]
10442 if !ok {
10443 return
10444 }
10445 dom.Remove(el)
10446 delete(appSt.noteElements, evID)
10447 delete(appSt.seenEvents, evID)
10448 }
10449
10450 func fetchDeletesForFeed() {
10451 // Collect event IDs from rendered notes. Presize from the map: push stops
10452 // at the first slice's capacity, so a nil sink trapped once five notes had
10453 // been rendered.
10454 ids := []string{:0:len(appSt.noteElements)}
10455 for id := range appSt.noteElements {
10456 ids = mxutil.Ensure(ids, 1)
10457 ids = push(ids, id)
10458 }
10459 if len(ids) == 0 {
10460 return
10461 }
10462 // Build a filter for kind 5 events referencing these note IDs.
10463 eList := jstr(ids[0])
10464 for i := 1; i < len(ids); i++ {
10465 eList = eList | "," | jstr(ids[i])
10466 }
10467 filter := "{\"kinds\":[5],\"#e\":[" | eList | "]}"
10468 routeMsg(buildProxyMsg("del-scan", filter, feedRelays()))
10469 }
10470
10471 func buildComposePage() {
10472 appSt.composeTextarea = dom.CreateElement("textarea")
10473 dom.SetAttribute(appSt.composeTextarea, "placeholder", "What's on your mind?")
10474 dom.SetStyle(appSt.composeTextarea, "width", "100%")
10475 dom.SetStyle(appSt.composeTextarea, "minHeight", "160px")
10476 dom.SetStyle(appSt.composeTextarea, "flex", "1")
10477 dom.SetStyle(appSt.composeTextarea, "padding", "12px")
10478 dom.SetStyle(appSt.composeTextarea, "fontSize", "15px")
10479 dom.SetStyle(appSt.composeTextarea, "lineHeight", "1.5")
10480 dom.SetStyle(appSt.composeTextarea, "border", "1px solid var(--border)")
10481 dom.SetStyle(appSt.composeTextarea, "borderRadius", "8px")
10482 dom.SetStyle(appSt.composeTextarea, "background", "var(--bg)")
10483 dom.SetStyle(appSt.composeTextarea, "color", "var(--fg)")
10484 dom.SetStyle(appSt.composeTextarea, "resize", "vertical")
10485 dom.SetStyle(appSt.composeTextarea, "overflowY", "auto")
10486 dom.SetStyle(appSt.composeTextarea, "boxSizing", "border-box")
10487 dom.SetStyle(appSt.composeTextarea, "fontFamily", "inherit")
10488 dom.SetAttribute(appSt.composeTextarea, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
10489 dom.AppendChild(appSt.composePage, appSt.composeTextarea)
10490
10491 // Preview area (hidden by default).
10492 appSt.composePreview = dom.CreateElement("div")
10493 dom.SetStyle(appSt.composePreview, "display", "none")
10494 dom.SetStyle(appSt.composePreview, "padding", "12px")
10495 dom.SetStyle(appSt.composePreview, "border", "1px solid var(--border)")
10496 dom.SetStyle(appSt.composePreview, "borderRadius", "8px")
10497 dom.SetStyle(appSt.composePreview, "marginTop", "8px")
10498 dom.SetStyle(appSt.composePreview, "lineHeight", "1.5")
10499 dom.SetStyle(appSt.composePreview, "wordBreak", "break-word")
10500 dom.AppendChild(appSt.composePage, appSt.composePreview)
10501
10502 // Button row.
10503 btnRow := dom.CreateElement("div")
10504 dom.SetStyle(btnRow, "display", "flex")
10505 dom.SetStyle(btnRow, "gap", "8px")
10506 dom.SetStyle(btnRow, "marginTop", "12px")
10507 dom.SetStyle(btnRow, "alignItems", "center")
10508
10509 // Relay selector button (left side).
10510 relaySelWrap := dom.CreateElement("div")
10511 dom.SetStyle(relaySelWrap, "position", "relative")
10512 dom.SetStyle(relaySelWrap, "display", "flex")
10513 dom.SetStyle(relaySelWrap, "alignItems", "center")
10514 dom.SetStyle(relaySelWrap, "gap", "6px")
10515
10516 relaySelBtn := dom.CreateElement("button")
10517 dom.SetStyle(relaySelBtn, "padding", "8px 16px")
10518 dom.SetStyle(relaySelBtn, "border", "1px solid var(--border)")
10519 dom.SetStyle(relaySelBtn, "borderRadius", "6px")
10520 dom.SetStyle(relaySelBtn, "background", "transparent")
10521 dom.SetStyle(relaySelBtn, "color", "var(--fg)")
10522 dom.SetStyle(relaySelBtn, "cursor", "pointer")
10523 updateRelaySelBtn(relaySelBtn, false)
10524
10525 composeWarnBadge := makeRelayWarnBadge()
10526
10527 appSt.composeRelayPopEl = dom.CreateElement("div")
10528 dom.SetStyle(appSt.composeRelayPopEl, "display", "none")
10529 dom.SetStyle(appSt.composeRelayPopEl, "position", "absolute")
10530 dom.SetStyle(appSt.composeRelayPopEl, "bottom", "100%")
10531 dom.SetStyle(appSt.composeRelayPopEl, "left", "0")
10532 dom.SetStyle(appSt.composeRelayPopEl, "marginBottom", "4px")
10533 dom.SetStyle(appSt.composeRelayPopEl, "background", "var(--bg)")
10534 dom.SetStyle(appSt.composeRelayPopEl, "border", "1px solid var(--border)")
10535 dom.SetStyle(appSt.composeRelayPopEl, "borderRadius", "6px")
10536 dom.SetStyle(appSt.composeRelayPopEl, "padding", "8px")
10537 dom.SetStyle(appSt.composeRelayPopEl, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
10538 dom.SetStyle(appSt.composeRelayPopEl, "zIndex", "1000")
10539 dom.SetStyle(appSt.composeRelayPopEl, "minWidth", "200px")
10540 dom.SetStyle(appSt.composeRelayPopEl, "maxWidth", "calc(100vw - 24px)")
10541 dom.SetStyle(appSt.composeRelayPopEl, "maxHeight", "200px")
10542 dom.SetStyle(appSt.composeRelayPopEl, "overflowY", "auto")
10543
10544 dom.AddEventListener(relaySelBtn, "click", dom.RegisterCallback(func() {
10545 if appSt.composeRelayPopOpen {
10546 dom.SetStyle(appSt.composeRelayPopEl, "display", "none")
10547 appSt.composeRelayPopOpen = false
10548 updateRelaySelBtn(relaySelBtn, false)
10549 } else {
10550 populateRelayPopover(appSt.composeRelayPopEl, func() {
10551 updateRelaySelBtn(relaySelBtn, true)
10552 updateRelayWarnBadge(composeWarnBadge)
10553 })
10554 dom.SetStyle(appSt.composeRelayPopEl, "display", "block")
10555 appSt.composeRelayPopOpen = true
10556 updateRelaySelBtn(relaySelBtn, true)
10557 }
10558 }))
10559
10560 dom.AppendChild(relaySelWrap, relaySelBtn)
10561 dom.AppendChild(relaySelWrap, composeWarnBadge)
10562 dom.AppendChild(relaySelWrap, appSt.composeRelayPopEl)
10563 dom.AppendChild(btnRow, relaySelWrap)
10564
10565 // Spacer.
10566 spacer := dom.CreateElement("div")
10567 dom.SetStyle(spacer, "flex", "1")
10568 dom.AppendChild(btnRow, spacer)
10569
10570 // Image upload button.
10571 imgBtn := dom.CreateElement("button")
10572 dom.SetInnerHTML(imgBtn, `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><rect x="2" y="3" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/><circle cx="6.5" cy="7.5" r="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M2 13l4-4 3 3 2-2 5 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
10573 dom.SetStyle(imgBtn, "padding", "8px 12px")
10574 dom.SetStyle(imgBtn, "border", "1px solid var(--border)")
10575 dom.SetStyle(imgBtn, "borderRadius", "6px")
10576 dom.SetStyle(imgBtn, "background", "transparent")
10577 dom.SetStyle(imgBtn, "color", "var(--fg)")
10578 dom.SetStyle(imgBtn, "cursor", "pointer")
10579 dom.SetStyle(imgBtn, "display", "flex")
10580 dom.SetStyle(imgBtn, "alignItems", "center")
10581 dom.AddEventListener(imgBtn, "click", dom.RegisterCallback(func() {
10582 dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
10583 if b64 != "" {
10584 blossomUpload(b64, mime, appSt.composeTextarea)
10585 }
10586 })
10587 }))
10588 dom.AppendChild(btnRow, imgBtn)
10589
10590 // Preview toggle.
10591 previewBtn := dom.CreateElement("button")
10592 dom.SetTextContent(previewBtn, "preview")
10593 dom.SetStyle(previewBtn, "padding", "8px 16px")
10594 dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
10595 dom.SetStyle(previewBtn, "borderRadius", "6px")
10596 dom.SetStyle(previewBtn, "background", "transparent")
10597 dom.SetStyle(previewBtn, "color", "var(--fg)")
10598 dom.SetStyle(previewBtn, "cursor", "pointer")
10599 composePreviewing := false
10600 dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
10601 content := dom.GetProperty(appSt.composeTextarea, "value")
10602 if !composePreviewing {
10603 dom.SetStyle(appSt.composePreview, "display", "block")
10604 dom.SetStyle(appSt.composeTextarea, "display", "none")
10605 setHTMLWithMedia(appSt.composePreview, renderMarkdown(content))
10606 dom.SetTextContent(previewBtn, "edit")
10607 composePreviewing = true
10608 } else {
10609 dom.SetStyle(appSt.composePreview, "display", "none")
10610 dom.SetStyle(appSt.composeTextarea, "display", "block")
10611 dom.SetTextContent(previewBtn, "preview")
10612 composePreviewing = false
10613 }
10614 }))
10615 dom.AppendChild(btnRow, previewBtn)
10616
10617 // Publish button.
10618 pubBtn := dom.CreateElement("button")
10619 dom.SetTextContent(pubBtn, "publish")
10620 dom.SetAttribute(pubBtn, "data-publish-btn", "1")
10621 dom.SetStyle(pubBtn, "padding", "8px 20px")
10622 dom.SetStyle(pubBtn, "border", "none")
10623 dom.SetStyle(pubBtn, "borderRadius", "6px")
10624 dom.SetStyle(pubBtn, "background", "var(--accent)")
10625 dom.SetStyle(pubBtn, "color", "#fff")
10626 dom.SetStyle(pubBtn, "cursor", "pointer")
10627 dom.SetStyle(pubBtn, "fontWeight", "bold")
10628 dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
10629 dom.ConsoleLog("[publish-compose] enter")
10630 content := dom.GetProperty(appSt.composeTextarea, "value")
10631 dom.ConsoleLog("[publish-compose] content len=" | itoa(len(content)))
10632 content = replaceEmojiCodes(content)
10633 if content == "" {
10634 dom.ConsoleLog("[publish-compose] empty")
10635 return
10636 }
10637 dom.ConsoleLog("[publish-compose] hasSigner=" | boolStr(signer.HasSigner()) | " pubhex=" | appSt.pubhex)
10638 if !signer.HasSigner() || appSt.pubhex == "" {
10639 dom.ConsoleLog("[publish-compose] no signer or pubhex")
10640 return
10641 }
10642 ts := dom.NowSeconds()
10643 tags := "["
10644 if localstorage.GetItem("musiquay-client-tag") != "0" {
10645 tags = tags | "[\"client\",\"https://smesh.lol\"]"
10646 }
10647 tags = tags | "]"
10648 unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
10649 ",\"tags\":" | tags |
10650 ",\"created_at\":" | i64toa(ts) |
10651 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
10652 dom.ConsoleLog("[publish-compose] calling SignEvent")
10653 capturedContent := content
10654 signer.SignEvent(unsigned, func(signed string) {
10655 if signed == "" {
10656 logActivity("publish", "FAILED: signer error", unsigned)
10657 return
10658 }
10659 // Build relay list from checked relays.
10660 selected := composeSelectedRelays()
10661 preview := capturedContent
10662 if len(preview) > 80 {
10663 preview = preview[:80] | "..."
10664 }
10665 relayList := ""
10666 for j, u := range selected {
10667 if j > 0 {
10668 relayList = relayList | "\n"
10669 }
10670 relayList = relayList | u
10671 }
10672 if len(selected) > 0 {
10673 msg := "[\"PUBLISH_TO\"," | signed | ",["
10674 for i, u := range selected {
10675 if i > 0 {
10676 msg = msg | ","
10677 }
10678 msg = msg | jstr(u)
10679 }
10680 routeMsg(msg | "]]")
10681 } else {
10682 routeMsg("[\"EVENT\"," | signed | "]")
10683 }
10684 logActivity("publish", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
10685 published := nostr.ParseEvent(signed)
10686 if published != nil {
10687 insertPublishedNote(published)
10688 }
10689 dom.SetProperty(appSt.composeTextarea, "value", "")
10690 dom.SetStyle(appSt.composePreview, "display", "none")
10691 dom.SetStyle(appSt.composeTextarea, "display", "block")
10692 appSt.composeRelayPopOpen = false
10693 dom.SetStyle(appSt.composeRelayPopEl, "display", "none")
10694 dom.SetProperty(appSt.contentArea, "scrollTop", "0")
10695 switchPage("feed")
10696 })
10697 }))
10698 dom.AppendChild(btnRow, pubBtn)
10699 dom.AppendChild(appSt.composePage, btnRow)
10700
10701 // Emoji autocomplete popup.
10702 appSt.emojiAutoEl = dom.CreateElement("div")
10703 dom.SetStyle(appSt.emojiAutoEl, "display", "none")
10704 dom.SetStyle(appSt.emojiAutoEl, "position", "fixed")
10705 dom.SetStyle(appSt.emojiAutoEl, "background", "var(--bg)")
10706 dom.SetStyle(appSt.emojiAutoEl, "border", "1px solid var(--border)")
10707 dom.SetStyle(appSt.emojiAutoEl, "borderRadius", "6px")
10708 dom.SetStyle(appSt.emojiAutoEl, "padding", "4px")
10709 dom.SetStyle(appSt.emojiAutoEl, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
10710 dom.SetStyle(appSt.emojiAutoEl, "zIndex", "2000")
10711 dom.SetStyle(appSt.emojiAutoEl, "maxHeight", "200px")
10712 dom.SetStyle(appSt.emojiAutoEl, "overflowY", "auto")
10713 dom.SetStyle(appSt.emojiAutoEl, "minWidth", "200px")
10714 dom.AppendChild(dom.Body(), appSt.emojiAutoEl)
10715
10716 wireEmojiHandler(appSt.composeTextarea)
10717 wireBlossomHandlers(appSt.composeTextarea)
10718 wireMentionHandler(appSt.composeTextarea)
10719 }
10720
10721 func wireEmojiHandler(ta dom.Element) {
10722 dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
10723 checkEmojiAutocomplete(ta)
10724 }))
10725 }
10726
10727 func wireBlossomHandlers(ta dom.Element) {
10728 upload := func(b64 string, mime string) {
10729 blossomUpload(b64, mime, ta)
10730 }
10731 dom.OnPasteImage(ta, upload)
10732 dom.OnDropImage(ta, upload)
10733 }
10734
10735 func blossomUpload(b64data string, mime string, ta dom.Element) {
10736 if appSt.blossomServerURL == "" {
10737 dom.ConsoleLog("blossom: no server configured")
10738 return
10739 }
10740 raw := helpers.Base64Decode(b64data)
10741 if len(raw) == 0 {
10742 return
10743 }
10744 hash := sha256.Sum(raw)
10745 hashHex := helpers.HexEncode(hash[:])
10746 ext := ".bin"
10747 if mime == "image/png" {
10748 ext = ".png"
10749 } else if mime == "image/jpeg" || mime == "image/jpg" {
10750 ext = ".jpg"
10751 } else if mime == "image/gif" {
10752 ext = ".gif"
10753 } else if mime == "image/webp" {
10754 ext = ".webp"
10755 } else if mime == "image/svg+xml" {
10756 ext = ".svg"
10757 } else if mime == "video/mp4" {
10758 ext = ".mp4"
10759 } else if mime == "video/webm" {
10760 ext = ".webm"
10761 } else if mime == "video/quicktime" {
10762 ext = ".mov"
10763 } else if mime == "video/x-matroska" {
10764 ext = ".mkv"
10765 }
10766 placeholder := "\xe2\x8f\xb3 uploading to " | appSt.blossomServerURL | "..."
10767 val := dom.GetProperty(ta, "value")
10768 if len(val) > 0 && val[len(val)-1] != '\n' {
10769 val = val | "\n"
10770 }
10771 dom.SetProperty(ta, "value", val | placeholder)
10772 ts := dom.NowSeconds()
10773 expiry := ts + 300
10774 tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
10775 unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
10776 "\",\"tags\":" | tags |
10777 ",\"created_at\":" | i64toa(ts) |
10778 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
10779 signer.SignEvent(unsigned, func(signed string) {
10780 if signed == "" {
10781 dom.ConsoleLog("blossom: sign failed")
10782 blossomReplacePlaceholder(ta, placeholder, "")
10783 return
10784 }
10785 authB64 := helpers.Base64Encode([]byte(signed))
10786 authHeader := "Nostr " | authB64
10787 url := appSt.blossomServerURL
10788 if len(url) > 0 && url[len(url)-1] != '/' {
10789 url = url | "/"
10790 }
10791 url = url | "upload"
10792 dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
10793 if resp == "" {
10794 dom.ConsoleLog("blossom: upload failed")
10795 blossomReplacePlaceholder(ta, placeholder, "")
10796 return
10797 }
10798 blobURL := helpers.JsonGetString(resp, "url")
10799 if blobURL == "" {
10800 blobURL = hashHex | ext
10801 }
10802 if len(blobURL) < 4 || blobURL[:4] != "http" {
10803 base := blossomOrigin(appSt.blossomServerURL)
10804 if len(blobURL) > 0 && blobURL[0] == '/' {
10805 blobURL = base | blobURL
10806 } else {
10807 blobURL = base | "/" | blobURL
10808 }
10809 }
10810 blossomReplacePlaceholder(ta, placeholder, blobURL)
10811 })
10812 })
10813 }
10814
10815 func blossomOrigin(u string) (s string) {
10816 slashes := 0
10817 for i := 0; i < len(u); i++ {
10818 if u[i] == '/' {
10819 slashes++
10820 if slashes == 3 {
10821 return u[:i]
10822 }
10823 }
10824 }
10825 return u
10826 }
10827
10828 func blossomReplacePlaceholder(ta dom.Element, placeholder, replacement string) {
10829 val := dom.GetProperty(ta, "value")
10830 idx := -1
10831 plen := len(placeholder)
10832 for i := 0; i <= len(val)-plen; i++ {
10833 if val[i:i+plen] == placeholder {
10834 idx = i
10835 break
10836 }
10837 }
10838 if idx >= 0 {
10839 dom.SetProperty(ta, "value", val[:idx] | replacement | val[idx+plen:])
10840 } else if replacement != "" {
10841 if len(val) > 0 && val[len(val)-1] != '\n' {
10842 val = val | "\n"
10843 }
10844 dom.SetProperty(ta, "value", val | replacement | "\n")
10845 }
10846 }
10847
10848 func blossomUploadToInput(b64data string, mime string, input dom.Element) {
10849 if appSt.blossomServerURL == "" {
10850 return
10851 }
10852 raw := helpers.Base64Decode(b64data)
10853 if len(raw) == 0 {
10854 return
10855 }
10856 hash := sha256.Sum(raw)
10857 hashHex := helpers.HexEncode(hash[:])
10858 ext := ".bin"
10859 if mime == "image/png" {
10860 ext = ".png"
10861 } else if mime == "image/jpeg" || mime == "image/jpg" {
10862 ext = ".jpg"
10863 } else if mime == "image/gif" {
10864 ext = ".gif"
10865 } else if mime == "image/webp" {
10866 ext = ".webp"
10867 } else if mime == "image/svg+xml" {
10868 ext = ".svg"
10869 } else if mime == "video/mp4" {
10870 ext = ".mp4"
10871 } else if mime == "video/webm" {
10872 ext = ".webm"
10873 } else if mime == "video/quicktime" {
10874 ext = ".mov"
10875 } else if mime == "video/x-matroska" {
10876 ext = ".mkv"
10877 }
10878 dom.SetProperty(input, "value", "\xe2\x8f\xb3 uploading...")
10879 ts := dom.NowSeconds()
10880 expiry := ts + 300
10881 tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
10882 unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
10883 "\",\"tags\":" | tags |
10884 ",\"created_at\":" | i64toa(ts) |
10885 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
10886 signer.SignEvent(unsigned, func(signed string) {
10887 if signed == "" {
10888 dom.SetProperty(input, "value", "")
10889 return
10890 }
10891 authB64 := helpers.Base64Encode([]byte(signed))
10892 authHeader := "Nostr " | authB64
10893 url := appSt.blossomServerURL
10894 if len(url) > 0 && url[len(url)-1] != '/' {
10895 url = url | "/"
10896 }
10897 url = url | "upload"
10898 dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
10899 if resp == "" {
10900 dom.SetProperty(input, "value", "")
10901 return
10902 }
10903 blobURL := helpers.JsonGetString(resp, "url")
10904 if blobURL == "" {
10905 blobURL = hashHex | ext
10906 }
10907 if len(blobURL) < 4 || blobURL[:4] != "http" {
10908 base := blossomOrigin(appSt.blossomServerURL)
10909 if len(blobURL) > 0 && blobURL[0] == '/' {
10910 blobURL = base | blobURL
10911 } else {
10912 blobURL = base | "/" | blobURL
10913 }
10914 }
10915 dom.SetProperty(input, "value", blobURL)
10916 })
10917 })
10918 }
10919
10920 type mentionMatch struct {
10921 pk string
10922 name string
10923 pic string
10924 }
10925
10926 func mentionSearch(query string) (ss []mentionMatch) {
10927 q := toLower(query)
10928 var results []mentionMatch
10929 for pk, content := range appSt.profileContentCache {
10930 if len(results) >= 10 {
10931 break
10932 }
10933 name := helpers.JsonGetString(content, "name")
10934 dname := helpers.JsonGetString(content, "display_name")
10935 nl := toLower(name)
10936 dl := toLower(dname)
10937 if (len(nl) > 0 && strIndex(nl, q) >= 0) || (len(dl) > 0 && strIndex(dl, q) >= 0) {
10938 label := name
10939 if len(label) == 0 {
10940 label = dname
10941 }
10942 if len(label) == 0 {
10943 continue
10944 }
10945 pic, _ := appSt.authorPics[pk]
10946 results = mxutil.Ensure(results, 1)
10947 results = push(results, mentionMatch{pk: pk, name: label, pic: pic})
10948 }
10949 }
10950 return results
10951 }
10952
10953 func initMentionPopup() {
10954 appSt.mentionPopup = dom.CreateElement("div")
10955 dom.SetStyle(appSt.mentionPopup, "display", "none")
10956 dom.SetStyle(appSt.mentionPopup, "position", "fixed")
10957 dom.SetStyle(appSt.mentionPopup, "background", "var(--bg)")
10958 dom.SetStyle(appSt.mentionPopup, "border", "1px solid var(--border)")
10959 dom.SetStyle(appSt.mentionPopup, "borderRadius", "6px")
10960 dom.SetStyle(appSt.mentionPopup, "padding", "4px")
10961 dom.SetStyle(appSt.mentionPopup, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
10962 dom.SetStyle(appSt.mentionPopup, "zIndex", "3000")
10963 dom.SetStyle(appSt.mentionPopup, "maxHeight", "200px")
10964 dom.SetStyle(appSt.mentionPopup, "overflowY", "auto")
10965 dom.SetStyle(appSt.mentionPopup, "minWidth", "220px")
10966 dom.AppendChild(dom.Body(), appSt.mentionPopup)
10967 }
10968
10969 func showMentionPopup(ta dom.Element, atPos int32, query string) {
10970 matches := mentionSearch(query)
10971 if len(matches) == 0 {
10972 closeMentionPopup()
10973 return
10974 }
10975 appSt.mentionTA = ta
10976 appSt.mentionQuery = query
10977 appSt.mentionAtPos = atPos
10978 appSt.mentionItems = matches
10979 appSt.mentionSelIdx = 0
10980
10981 clearChildren(appSt.mentionPopup)
10982 for i, m := range matches {
10983 row := dom.CreateElement("div")
10984 dom.SetStyle(row, "display", "flex")
10985 dom.SetStyle(row, "alignItems", "center")
10986 dom.SetStyle(row, "gap", "8px")
10987 dom.SetStyle(row, "padding", "4px 8px")
10988 dom.SetStyle(row, "cursor", "pointer")
10989 dom.SetStyle(row, "borderRadius", "4px")
10990 dom.SetStyle(row, "fontSize", "13px")
10991 if i == 0 {
10992 dom.SetStyle(row, "background", "var(--bg2)")
10993 }
10994
10995 if len(m.pic) > 0 {
10996 img := dom.CreateElement("img")
10997 setMediaSrc(img, m.pic)
10998 dom.SetStyle(img, "width", "24px")
10999 dom.SetStyle(img, "height", "24px")
11000 dom.SetStyle(img, "borderRadius", "50%")
11001 dom.SetStyle(img, "objectFit", "cover")
11002 dom.SetStyle(img, "flexShrink", "0")
11003 dom.AppendChild(row, img)
11004 } else {
11005 placeholder := dom.CreateElement("div")
11006 dom.SetStyle(placeholder, "width", "24px")
11007 dom.SetStyle(placeholder, "height", "24px")
11008 dom.SetStyle(placeholder, "borderRadius", "50%")
11009 dom.SetStyle(placeholder, "background", "var(--border)")
11010 dom.SetStyle(placeholder, "flexShrink", "0")
11011 dom.AppendChild(row, placeholder)
11012 }
11013
11014 nameSpan := dom.CreateElement("span")
11015 dom.SetTextContent(nameSpan, m.name)
11016 dom.AppendChild(row, nameSpan)
11017
11018 npubShort := helpers.EncodeNpub(helpers.HexDecode(m.pk))
11019 if len(npubShort) > 16 {
11020 npubShort = npubShort[:12] | "..."
11021 }
11022 npubSpan := dom.CreateElement("span")
11023 dom.SetTextContent(npubSpan, npubShort)
11024 dom.SetStyle(npubSpan, "color", "var(--muted)")
11025 dom.SetStyle(npubSpan, "fontSize", "11px")
11026 dom.AppendChild(row, npubSpan)
11027
11028 dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
11029 dom.SetStyle(row, "background", "var(--bg2)")
11030 }))
11031 dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
11032 if appSt.mentionSelIdx >= 0 && appSt.mentionSelIdx < len(appSt.mentionItems) {
11033 dom.SetStyle(row, "background", "transparent")
11034 }
11035 }))
11036
11037 capturedPK := m.pk
11038 dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
11039 insertMention(capturedPK)
11040 }))
11041
11042 dom.AppendChild(appSt.mentionPopup, row)
11043 }
11044
11045 positionMentionPopup(ta)
11046 dom.SetStyle(appSt.mentionPopup, "display", "block")
11047 }
11048
11049 func positionMentionPopup(ta dom.Element) {
11050 dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
11051 vh := dom.GetViewportHeight()
11052 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11053 val := dom.GetProperty(ta, "value")
11054 lineH := 22
11055 charsPerLine := width / 8
11056 if charsPerLine < 1 {
11057 charsPerLine = 40
11058 }
11059 linesBefore := 0
11060 col := 0
11061 for i := 0; i < cur && i < len(val); i++ {
11062 if val[i] == '\n' {
11063 linesBefore++
11064 col = 0
11065 } else {
11066 col++
11067 if col >= charsPerLine {
11068 linesBefore++
11069 col = 0
11070 }
11071 }
11072 }
11073 cursorY := top + 8 + linesBefore*lineH
11074 popupH := 200
11075 if cursorY < vh/2 {
11076 dom.SetStyle(appSt.mentionPopup, "top", itoa(cursorY+lineH) | "px")
11077 dom.SetStyle(appSt.mentionPopup, "bottom", "auto")
11078 } else {
11079 dom.SetStyle(appSt.mentionPopup, "bottom", itoa(vh-cursorY) | "px")
11080 dom.SetStyle(appSt.mentionPopup, "top", "auto")
11081 }
11082 _ = popupH
11083 xOff := left + col*8
11084 if xOff > right-220 {
11085 xOff = right - 220
11086 }
11087 if xOff < left {
11088 xOff = left
11089 }
11090 dom.SetStyle(appSt.mentionPopup, "left", itoa(xOff) | "px")
11091 })
11092 }
11093
11094 func insertMention(pk string) {
11095 if appSt.mentionTA == 0 {
11096 return
11097 }
11098 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
11099 val := dom.GetProperty(appSt.mentionTA, "value")
11100 cur := parseIntProp(dom.GetProperty(appSt.mentionTA, "selectionStart"))
11101 replacement := "nostr:" | npub | " "
11102 before := val[:appSt.mentionAtPos]
11103 after := val[cur:]
11104 dom.SetProperty(appSt.mentionTA, "value", before | replacement | after)
11105 newCur := itoa(len(before) + len(replacement))
11106 dom.SetProperty(appSt.mentionTA, "selectionStart", newCur)
11107 dom.SetProperty(appSt.mentionTA, "selectionEnd", newCur)
11108 closeMentionPopup()
11109 dom.Focus(appSt.mentionTA)
11110 }
11111
11112 func closeMentionPopup() {
11113 dom.SetStyle(appSt.mentionPopup, "display", "none")
11114 appSt.mentionItems = nil
11115 appSt.mentionSelIdx = 0
11116 appSt.mentionTA = 0
11117 }
11118
11119 func checkMentionAutocomplete(ta dom.Element) {
11120 val := dom.GetProperty(ta, "value")
11121 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11122 if cur > len(val) {
11123 cur = len(val)
11124 }
11125 atPos := -1
11126 for i := cur - 1; i >= 0; i-- {
11127 c := val[i]
11128 if c == '@' {
11129 atPos = i
11130 break
11131 }
11132 if c == ' ' || c == '\n' || c == '\t' {
11133 break
11134 }
11135 }
11136 if atPos < 0 || cur-atPos < 2 {
11137 closeMentionPopup()
11138 return
11139 }
11140 if atPos > 0 {
11141 prev := val[atPos-1]
11142 if prev != ' ' && prev != '\n' && prev != '\t' {
11143 closeMentionPopup()
11144 return
11145 }
11146 }
11147 query := val[atPos+1 : cur]
11148 showMentionPopup(ta, atPos, query)
11149 }
11150
11151 func wireMentionHandler(ta dom.Element) {
11152 dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
11153 checkMentionAutocomplete(ta)
11154 }))
11155 dom.AddEventListener(ta, "blur", dom.RegisterCallback(func() {
11156 dom.SetTimeout(func() { closeMentionPopup() }, 200)
11157 }))
11158 dom.OnKeydown(ta, func(key string, prevent func()) {
11159 if appSt.mentionTA == 0 || len(appSt.mentionItems) == 0 {
11160 if key == "Escape" {
11161 closeMentionPopup()
11162 }
11163 return
11164 }
11165 if key == "Escape" {
11166 closeMentionPopup()
11167 prevent()
11168 } else if key == "ArrowDown" {
11169 appSt.mentionSelIdx++
11170 if appSt.mentionSelIdx >= len(appSt.mentionItems) {
11171 appSt.mentionSelIdx = 0
11172 }
11173 highlightMentionRow()
11174 prevent()
11175 } else if key == "ArrowUp" {
11176 appSt.mentionSelIdx--
11177 if appSt.mentionSelIdx < 0 {
11178 appSt.mentionSelIdx = len(appSt.mentionItems) - 1
11179 }
11180 highlightMentionRow()
11181 prevent()
11182 } else if key == "Enter" || key == "Tab" {
11183 insertMention(appSt.mentionItems[appSt.mentionSelIdx].pk)
11184 prevent()
11185 }
11186 })
11187 }
11188
11189 func highlightMentionRow() {
11190 child := dom.FirstElementChild(appSt.mentionPopup)
11191 idx := 0
11192 for child != 0 {
11193 if idx == appSt.mentionSelIdx {
11194 dom.SetStyle(child, "background", "var(--bg2)")
11195 } else {
11196 dom.SetStyle(child, "background", "transparent")
11197 }
11198 child = dom.NextSibling(child)
11199 idx++
11200 }
11201 }
11202
11203 func checkEmojiAutocomplete(ta dom.Element) {
11204 val := dom.GetProperty(ta, "value")
11205 curStr := dom.GetProperty(ta, "selectionStart")
11206 cur := parseIntProp(curStr)
11207 if cur > len(val) {
11208 cur = len(val)
11209 }
11210
11211 searchFrom := cur - 1
11212 closingColon := false
11213 if searchFrom >= 0 && val[searchFrom] == ':' {
11214 closingColon = true
11215 searchFrom--
11216 }
11217 colonPos := -1
11218 for i := searchFrom; i >= 0; i-- {
11219 c := val[i]
11220 if c == ':' {
11221 colonPos = i
11222 break
11223 }
11224 if c == ' ' || c == '\n' || c == '\t' {
11225 break
11226 }
11227 }
11228
11229 if colonPos < 0 || cur-colonPos < 2 {
11230 closeEmojiAuto()
11231 return
11232 }
11233
11234 // Extract the text between the opening colon and cursor (excluding closing colon).
11235 queryEnd := cur
11236 if closingColon {
11237 queryEnd = cur - 1
11238 }
11239 query := val[colonPos+1 : queryEnd]
11240
11241 // If user typed a closing ':', auto-replace if exact match.
11242 if closingColon && len(query) > 0 {
11243 code := ":" | query | ":"
11244 if emoji, ok2 := appSt.emojiByName[code]; ok2 {
11245 before := val[:colonPos]
11246 after := val[cur:]
11247 newVal := before | emoji | after
11248 dom.SetProperty(ta, "value", newVal)
11249 newCur := itoa(len(before) + len(emoji))
11250 dom.SetProperty(ta, "selectionStart", newCur)
11251 dom.SetProperty(ta, "selectionEnd", newCur)
11252 closeEmojiAuto()
11253 return
11254 }
11255 }
11256
11257 // Filter matching emoji.
11258 var matches []emojiEntry
11259 q := toLower(query)
11260 for _, e := range emojiList {
11261 if len(matches) >= 10 {
11262 break
11263 }
11264 if len(e.name) >= len(q) && hasPrefix(e.name, q) {
11265 matches = mxutil.Ensure(matches, 1)
11266 matches = push(matches, e)
11267 }
11268 }
11269 // Also match substring.
11270 if len(matches) < 10 {
11271 for _, e := range emojiList {
11272 if len(matches) >= 10 {
11273 break
11274 }
11275 if !hasPrefix(e.name, q) && strIndex(e.name, q) >= 0 {
11276 matches = mxutil.Ensure(matches, 1)
11277 matches = push(matches, e)
11278 }
11279 }
11280 }
11281
11282 if len(matches) == 0 {
11283 closeEmojiAuto()
11284 return
11285 }
11286
11287 appSt.emojiAutoOpen = true
11288 appSt.emojiAutoStart = colonPos
11289 appSt.emojiAutoTA = ta
11290 clearChildren(appSt.emojiAutoEl)
11291
11292 for _, m := range matches {
11293 row := dom.CreateElement("div")
11294 dom.SetStyle(row, "display", "flex")
11295 dom.SetStyle(row, "alignItems", "center")
11296 dom.SetStyle(row, "gap", "6px")
11297 dom.SetStyle(row, "padding", "4px 8px")
11298 dom.SetStyle(row, "cursor", "pointer")
11299 dom.SetStyle(row, "borderRadius", "4px")
11300 dom.SetStyle(row, "fontSize", "13px")
11301
11302 emojiSpan := dom.CreateElement("span")
11303 dom.SetTextContent(emojiSpan, m.emoji)
11304 dom.SetStyle(emojiSpan, "fontSize", "18px")
11305 dom.AppendChild(row, emojiSpan)
11306
11307 nameSpan := dom.CreateElement("span")
11308 dom.SetTextContent(nameSpan, ":" | m.name | ":")
11309 dom.SetStyle(nameSpan, "color", "var(--muted)")
11310 dom.AppendChild(row, nameSpan)
11311
11312 dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
11313 dom.SetStyle(row, "background", "var(--bg2)")
11314 }))
11315 dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
11316 dom.SetStyle(row, "background", "transparent")
11317 }))
11318
11319 capturedEmoji := m.emoji
11320 dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
11321 insertEmojiAt(capturedEmoji)
11322 }))
11323
11324 dom.AppendChild(appSt.emojiAutoEl, row)
11325 }
11326
11327 dom.SetStyle(appSt.emojiAutoEl, "display", "block")
11328 positionEmojiPopup()
11329 }
11330
11331 func insertEmojiAt(emoji string) {
11332 ta := appSt.emojiAutoTA
11333 if ta == 0 {
11334 return
11335 }
11336 val := dom.GetProperty(ta, "value")
11337 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11338 before := val[:appSt.emojiAutoStart]
11339 after := val[cur:]
11340 newVal := before | emoji | after
11341 dom.SetProperty(ta, "value", newVal)
11342 newCur := itoa(len(before) + len(emoji))
11343 dom.SetProperty(ta, "selectionStart", newCur)
11344 dom.SetProperty(ta, "selectionEnd", newCur)
11345 closeEmojiAuto()
11346 dom.Focus(ta)
11347 }
11348
11349 func closeEmojiAuto() {
11350 if appSt.emojiAutoOpen {
11351 dom.SetStyle(appSt.emojiAutoEl, "display", "none")
11352 appSt.emojiAutoOpen = false
11353 }
11354 }
11355
11356 func positionEmojiPopup() {
11357 ta := appSt.emojiAutoTA
11358 if ta == 0 {
11359 return
11360 }
11361 dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
11362 vh := dom.GetViewportHeight()
11363 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11364 val := dom.GetProperty(ta, "value")
11365 lineH := 22
11366 charsPerLine := width / 8
11367 if charsPerLine < 1 {
11368 charsPerLine = 40
11369 }
11370 linesBefore := 0
11371 col := 0
11372 for i := 0; i < cur && i < len(val); i++ {
11373 if val[i] == '\n' {
11374 linesBefore++
11375 col = 0
11376 } else {
11377 col++
11378 if col >= charsPerLine {
11379 linesBefore++
11380 col = 0
11381 }
11382 }
11383 }
11384 cursorY := top + 8 + linesBefore*lineH
11385 if cursorY < vh/2 {
11386 dom.SetStyle(appSt.emojiAutoEl, "top", itoa(cursorY+lineH) | "px")
11387 dom.SetStyle(appSt.emojiAutoEl, "bottom", "auto")
11388 } else {
11389 dom.SetStyle(appSt.emojiAutoEl, "bottom", itoa(vh-cursorY) | "px")
11390 dom.SetStyle(appSt.emojiAutoEl, "top", "auto")
11391 }
11392 xOff := left + col*8
11393 if xOff > right-220 {
11394 xOff = right - 220
11395 }
11396 if xOff < left {
11397 xOff = left
11398 }
11399 dom.SetStyle(appSt.emojiAutoEl, "left", itoa(xOff) | "px")
11400 })
11401 }
11402
11403 func populateComposeRelays() {
11404 populateRelayPopover(appSt.composeRelayPopEl, nil)
11405 }
11406
11407 func populateRelayPopover(target dom.Element, onChanged func()) {
11408 renderRows := func() {
11409 clearChildren(target)
11410 populateRelayRows(target, onChanged)
11411 if onChanged != nil {
11412 onChanged()
11413 }
11414 }
11415 if appSt.composeRelayChecks == nil {
11416 appSt.composeRelayChecks = map[string]bool{}
11417 for _, u := range appSt.relayURLs {
11418 appSt.composeRelayChecks[u] = true
11419 }
11420 dom.IDBGet("cache", "compose_relays", func(val string) {
11421 if val != "" {
11422 appSt.composeRelayChecks = map[string]bool{}
11423 start := 0
11424 for i := 0; i <= len(val); i++ {
11425 if i == len(val) || val[i] == ',' {
11426 if i > start {
11427 appSt.composeRelayChecks[val[start:i]] = true
11428 }
11429 start = i + 1
11430 }
11431 }
11432 }
11433 renderRows()
11434 })
11435 return
11436 }
11437 renderRows()
11438 }
11439
11440 func populateRelayRows(target dom.Element, onChanged func()) {
11441 for idx, u := range appSt.relayURLs {
11442 row := dom.CreateElement("div")
11443 dom.SetStyle(row, "display", "flex")
11444 dom.SetStyle(row, "alignItems", "center")
11445 dom.SetStyle(row, "gap", "6px")
11446 dom.SetStyle(row, "padding", "0 4px")
11447 dom.SetStyle(row, "margin", "2px 0")
11448 dom.SetStyle(row, "cursor", "pointer")
11449 dom.SetStyle(row, "fontSize", "12px")
11450 dom.SetStyle(row, "overflow", "hidden")
11451
11452 relayURL := u
11453 selected := appSt.composeRelayChecks[u]
11454
11455 applyRowStyle := func(sel bool) {
11456 if sel {
11457 dom.SetStyle(row, "background", "var(--accent)")
11458 dom.SetStyle(row, "color", "#fff")
11459 } else {
11460 dom.SetStyle(row, "background", "transparent")
11461 dom.SetStyle(row, "color", "var(--fg)")
11462 }
11463 }
11464 applyRowStyle(selected)
11465
11466 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
11467 appSt.composeRelayChecks[relayURL] = !appSt.composeRelayChecks[relayURL]
11468 applyRowStyle(appSt.composeRelayChecks[relayURL])
11469 saveComposeRelaySelection()
11470 if onChanged != nil {
11471 onChanged()
11472 }
11473 }))
11474
11475 label := dom.CreateElement("span")
11476 dom.SetTextContent(label, stripScheme(u))
11477 dom.SetStyle(label, "overflow", "hidden")
11478 dom.SetStyle(label, "textOverflow", "ellipsis")
11479 dom.SetStyle(label, "whiteSpace", "nowrap")
11480 dom.SetStyle(label, "flex", "1")
11481 dom.SetStyle(label, "minWidth", "0")
11482 dom.AppendChild(row, label)
11483
11484 caps := appSt.relayCaps[idx]
11485 if caps.fetchedOK {
11486 if !hasNIP(caps.supportedNIPs, 42) {
11487 dom.AppendChild(row, makeRelayBadge("!42"))
11488 }
11489 if !hasNIP(caps.supportedNIPs, 70) {
11490 dom.AppendChild(row, makeRelayBadge("!70"))
11491 }
11492 }
11493
11494 dom.AppendChild(target, row)
11495 }
11496 }
11497
11498 func selectedRelaysLeaky() (ok bool) {
11499 if appSt.composeRelayChecks == nil {
11500 return false
11501 }
11502 for i, u := range appSt.relayURLs {
11503 if !appSt.composeRelayChecks[u] {
11504 continue
11505 }
11506 caps := appSt.relayCaps[i]
11507 if !caps.fetchedOK {
11508 continue
11509 }
11510 if !hasNIP(caps.supportedNIPs, 42) || !hasNIP(caps.supportedNIPs, 70) {
11511 return true
11512 }
11513 }
11514 return false
11515 }
11516
11517 func updateRelaySelBtn(btn dom.Element, open bool) {
11518 if open {
11519 dom.SetTextContent(btn, "relays \u2715")
11520 dom.SetStyle(btn, "background", "var(--accent)")
11521 dom.SetStyle(btn, "color", "#fff")
11522 } else {
11523 dom.SetTextContent(btn, "relays")
11524 dom.SetStyle(btn, "background", "transparent")
11525 dom.SetStyle(btn, "color", "var(--fg)")
11526 }
11527 }
11528
11529 func updateRelayWarnBadge(badge dom.Element) {
11530 if selectedRelaysLeaky() {
11531 dom.SetStyle(badge, "display", "inline-block")
11532 } else {
11533 dom.SetStyle(badge, "display", "none")
11534 }
11535 }
11536
11537 func makeRelayWarnBadge() (e dom.Element) {
11538 badge := dom.CreateElement("span")
11539 dom.SetTextContent(badge, "\u26a0")
11540 dom.SetStyle(badge, "display", "none")
11541 dom.SetStyle(badge, "fontSize", "16px")
11542 dom.SetStyle(badge, "lineHeight", "1")
11543 dom.SetStyle(badge, "background", "#e67e00")
11544 dom.SetStyle(badge, "borderRadius", "50%")
11545 dom.SetStyle(badge, "width", "22px")
11546 dom.SetStyle(badge, "height", "22px")
11547 dom.SetStyle(badge, "textAlign", "center")
11548 dom.SetStyle(badge, "cursor", "default")
11549 dom.SetAttribute(badge, "title", t("relay_leak_warn"))
11550 updateRelayWarnBadge(badge)
11551 return badge
11552 }
11553
11554 func saveComposeRelaySelection() {
11555 val := ""
11556 for u, sel := range appSt.composeRelayChecks {
11557 if sel {
11558 if val != "" {
11559 val = val | ","
11560 }
11561 val = val | u
11562 }
11563 }
11564 dom.IDBPut("cache", "compose_relays", val)
11565 }
11566
11567 func composeSelectedRelays() (ss []string) {
11568 if appSt.composeRelayChecks == nil {
11569 return appSt.relayURLs
11570 }
11571 var out []string
11572 for u, sel := range appSt.composeRelayChecks {
11573 if sel {
11574 out = mxutil.Ensure(out, 1)
11575 out = push(out, u)
11576 }
11577 }
11578 // If nothing selected, fall back to all relays.
11579 if len(out) == 0 {
11580 return appSt.relayURLs
11581 }
11582 return out
11583 }
11584
11585 func insertPublishedNote(ev *nostr.Event) {
11586 setEvent(ev.ID, ev)
11587 fillEmbed(ev)
11588
11589 // Thread view: insert and re-render, scrolling new reply into view.
11590 if appSt.threadOpen {
11591 appSt.threadEvents[ev.ID] = ev
11592 appSt.threadFocusID = ev.ID
11593 dom.ReplaceState("/t/" | appSt.threadRootID | "#" | ev.ID)
11594 renderThreadTree()
11595 }
11596
11597 // Feed: prepend at top. renderNote must run before seenEvents is set,
11598 // because renderNote's dedup guard checks seenEvents.
11599 trackOldestTs(ev)
11600 renderNote(ev)
11601 appSt.seenEvents[ev.ID] = true
11602 }
11603
11604 // --- Emoji picker ---
11605
11606 func showEmojiPicker(ev *nostr.Event, anchor dom.Element) {
11607 _ = anchor
11608 if !signer.HasSigner() || appSt.pubhex == "" {
11609 return
11610 }
11611 if appSt.emojiOpen {
11612 closeEmojiPicker()
11613 return
11614 }
11615 appSt.emojiOpen = true
11616 appSt.emojiTargetEv = ev
11617
11618 // Full-screen scrim.
11619 overlay := dom.CreateElement("div")
11620 dom.SetStyle(overlay, "position", "fixed")
11621 dom.SetStyle(overlay, "top", "0")
11622 dom.SetStyle(overlay, "left", "0")
11623 dom.SetStyle(overlay, "width", "100%")
11624 dom.SetStyle(overlay, "height", "100%")
11625 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
11626 dom.SetStyle(overlay, "zIndex", "400")
11627 dom.SetStyle(overlay, "display", "flex")
11628 dom.SetStyle(overlay, "alignItems", "center")
11629 dom.SetStyle(overlay, "justifyContent", "center")
11630 dom.AddSelfEventListener(overlay, "click", dom.RegisterCallback(func() {
11631 closeEmojiPicker()
11632 }))
11633 appSt.emojiOverlay = overlay
11634
11635 modal := dom.CreateElement("div")
11636 dom.SetStyle(modal, "background", "var(--bg)")
11637 dom.SetStyle(modal, "border", "1px solid var(--border)")
11638 dom.SetStyle(modal, "borderRadius", "8px")
11639 dom.SetStyle(modal, "padding", "12px")
11640 dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.4)")
11641 dom.SetStyle(modal, "width", "320px")
11642 dom.SetStyle(modal, "maxWidth", "90vw")
11643 dom.SetStyle(modal, "maxHeight", "60vh")
11644 dom.SetStyle(modal, "display", "flex")
11645 dom.SetStyle(modal, "flexDirection", "column")
11646 dom.SetStyle(modal, "gap", "8px")
11647
11648 // Search input.
11649 search := dom.CreateElement("input")
11650 dom.SetAttribute(search, "type", "text")
11651 dom.SetAttribute(search, "placeholder", "Search emoji...")
11652 dom.SetStyle(search, "width", "100%")
11653 dom.SetStyle(search, "padding", "6px 10px")
11654 dom.SetStyle(search, "border", "1px solid var(--border)")
11655 dom.SetStyle(search, "borderRadius", "4px")
11656 dom.SetStyle(search, "background", "var(--bg)")
11657 dom.SetStyle(search, "color", "var(--fg)")
11658 dom.SetStyle(search, "fontSize", "14px")
11659 dom.SetStyle(search, "boxSizing", "border-box")
11660 dom.SetStyle(search, "flexShrink", "0")
11661 dom.AppendChild(modal, search)
11662
11663 // Emoji grid - scrollable.
11664 grid := dom.CreateElement("div")
11665 dom.SetStyle(grid, "display", "flex")
11666 dom.SetStyle(grid, "flexWrap", "wrap")
11667 dom.SetStyle(grid, "gap", "2px")
11668 dom.SetStyle(grid, "overflowY", "auto")
11669 dom.SetStyle(grid, "flex", "1")
11670 dom.SetStyle(grid, "minHeight", "0")
11671 dom.SetStyle(grid, "alignContent", "flex-start")
11672 dom.SetStyle(grid, "WebkitOverflowScrolling", "touch")
11673
11674 capturedEv := ev
11675 seen := map[string]bool{}
11676 for _, entry := range emojiList {
11677 if seen[entry.emoji] {
11678 continue
11679 }
11680 seen[entry.emoji] = true
11681 cell := dom.CreateElement("span")
11682 dom.SetTextContent(cell, entry.emoji)
11683 dom.SetStyle(cell, "fontSize", "22px")
11684 dom.SetStyle(cell, "cursor", "pointer")
11685 dom.SetStyle(cell, "padding", "4px")
11686 dom.SetStyle(cell, "borderRadius", "4px")
11687 dom.SetStyle(cell, "textAlign", "center")
11688 dom.SetStyle(cell, "width", "36px")
11689 dom.SetStyle(cell, "height", "36px")
11690 dom.SetStyle(cell, "lineHeight", "36px")
11691 dom.SetStyle(cell, "display", "inline-flex")
11692 dom.SetStyle(cell, "alignItems", "center")
11693 dom.SetStyle(cell, "justifyContent", "center")
11694 dom.SetAttribute(cell, "title", entry.name)
11695 capturedEmoji := entry.emoji
11696 dom.AddEventListener(cell, "mouseenter", dom.RegisterCallback(func() {
11697 dom.SetStyle(cell, "background", "var(--border)")
11698 }))
11699 dom.AddEventListener(cell, "mouseleave", dom.RegisterCallback(func() {
11700 dom.SetStyle(cell, "background", "transparent")
11701 }))
11702 dom.AddEventListener(cell, "click", dom.RegisterCallback(func() {
11703 publishReaction(capturedEv, capturedEmoji)
11704 closeEmojiPicker()
11705 }))
11706 dom.AppendChild(grid, cell)
11707 }
11708 dom.AppendChild(modal, grid)
11709
11710 // Search filtering - match against all names for each emoji.
11711 capturedGrid := grid
11712 dom.AddEventListener(search, "input", dom.RegisterCallback(func() {
11713 q := toLower(dom.GetProperty(search, "value"))
11714 child := dom.FirstElementChild(capturedGrid)
11715 for child != 0 {
11716 next := dom.NextSibling(child)
11717 title := dom.GetProperty(child, "title")
11718 if q == "" || contains(title, q) {
11719 dom.SetStyle(child, "display", "inline-flex")
11720 } else {
11721 dom.SetStyle(child, "display", "none")
11722 }
11723 child = next
11724 }
11725 }))
11726
11727 dom.AppendChild(overlay, modal)
11728 dom.AppendChild(dom.Body(), overlay)
11729 dom.Focus(search)
11730 }
11731
11732 func closeEmojiPicker() {
11733 if appSt.emojiOverlay != 0 {
11734 dom.RemoveChild(dom.Body(), appSt.emojiOverlay)
11735 appSt.emojiOverlay = 0
11736 }
11737 appSt.emojiOpen = false
11738 appSt.emojiTargetEv = nil
11739 }
11740
11741 func publishReaction(ev *nostr.Event, emoji string) {
11742 if !signer.HasSigner() || appSt.pubhex == "" {
11743 return
11744 }
11745 relay := ""
11746 if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
11747 relay = urls[0]
11748 }
11749 tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
11750 ts := dom.NowSeconds()
11751 unsigned := "{\"kind\":7,\"content\":" | jstr(emoji) |
11752 ",\"tags\":" | tags |
11753 ",\"created_at\":" | i64toa(ts) |
11754 ",\"pubkey\":\"" | appSt.pubhex | "\"}"
11755 capturedEvID := ev.ID
11756 capturedEmoji := emoji
11757 signer.SignEvent(unsigned, func(signed string) {
11758 if signed != "" {
11759 selected := composeSelectedRelays()
11760 if len(selected) > 0 {
11761 msg := "[\"PUBLISH_TO\"," | signed | ",["
11762 for i, u := range selected {
11763 if i > 0 {
11764 msg = msg | ","
11765 }
11766 msg = msg | jstr(u)
11767 }
11768 routeMsg(msg | "]]")
11769 relayList := ""
11770 for j, u := range selected {
11771 if j > 0 {
11772 relayList = relayList | "\n"
11773 }
11774 relayList = relayList | u
11775 }
11776 logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "relays:\n" | relayList | "\n\nevent:\n" | signed)
11777 } else {
11778 routeMsg("[\"EVENT\"," | signed | "]")
11779 logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "no targeted relays\n\nevent:\n" | signed)
11780 }
11781 ri := appSt.noteReactionMap[capturedEvID]
11782 if ri == nil {
11783 ri = map[string]map[string]bool{}
11784 }
11785 pks := ri[capturedEmoji]
11786 if pks == nil {
11787 pks = map[string]bool{}
11788 }
11789 pks[appSt.pubhex] = true
11790 ri[capturedEmoji] = pks
11791 appSt.noteReactionMap[capturedEvID] = ri
11792 if el, ok2 := appSt.noteReactionEls[capturedEvID]; ok2 {
11793 fillReactionDisplay(el, capturedEvID)
11794 }
11795 }
11796 })
11797 }
11798
11799 func contains(s, sub string) (ok bool) {
11800 if len(sub) > len(s) {
11801 return false
11802 }
11803 for i := 0; i <= len(s)-len(sub); i++ {
11804 if s[i:i+len(sub)] == sub {
11805 return true
11806 }
11807 }
11808 return false
11809 }
11810
11811 // --- Settings page ---
11812
11813 func renderSettings() {
11814 clearChildren(appSt.settingsPage)
11815
11816 title := dom.CreateElement("h2")
11817 dom.SetTextContent(title, t("settings_title"))
11818 dom.SetStyle(title, "fontSize", "20px")
11819 dom.SetStyle(title, "marginBottom", "24px")
11820 dom.AppendChild(appSt.settingsPage, title)
11821
11822 // Language selector.
11823 langRow := dom.CreateElement("div")
11824 dom.SetStyle(langRow, "display", "flex")
11825 dom.SetStyle(langRow, "alignItems", "center")
11826 dom.SetStyle(langRow, "gap", "12px")
11827 dom.SetStyle(langRow, "marginBottom", "16px")
11828
11829 langLabel := dom.CreateElement("span")
11830 dom.SetTextContent(langLabel, t("lang_label"))
11831 dom.SetStyle(langLabel, "fontSize", "14px")
11832 dom.SetStyle(langLabel, "minWidth", "140px")
11833 dom.AppendChild(langRow, langLabel)
11834
11835 langSel := dom.CreateElement("select")
11836 dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
11837 dom.SetStyle(langSel, "fontSize", "13px")
11838 dom.SetStyle(langSel, "background", "var(--bg2)")
11839 dom.SetStyle(langSel, "color", "var(--fg)")
11840 dom.SetStyle(langSel, "border", "1px solid var(--border)")
11841 dom.SetStyle(langSel, "borderRadius", "4px")
11842 dom.SetStyle(langSel, "padding", "6px 12px")
11843 for code, name := range langNames {
11844 opt := dom.CreateElement("option")
11845 dom.SetAttribute(opt, "value", code)
11846 dom.SetTextContent(opt, name)
11847 if code == appSt.currentLang {
11848 dom.SetAttribute(opt, "selected", "selected")
11849 }
11850 dom.AppendChild(langSel, opt)
11851 }
11852 dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
11853 val := dom.GetProperty(langSel, "value")
11854 setLang(val)
11855 saveAppSettings()
11856 dom.SetTextContent(appSt.pageTitleEl, t("settings"))
11857 renderSettings()
11858 }))
11859 dom.AppendChild(langRow, langSel)
11860 dom.AppendChild(appSt.settingsPage, langRow)
11861
11862 // Theme selector.
11863 themeRow := dom.CreateElement("div")
11864 dom.SetStyle(themeRow, "display", "flex")
11865 dom.SetStyle(themeRow, "alignItems", "center")
11866 dom.SetStyle(themeRow, "gap", "12px")
11867 dom.SetStyle(themeRow, "marginBottom", "16px")
11868
11869 themeLabel := dom.CreateElement("span")
11870 dom.SetTextContent(themeLabel, t("theme_label"))
11871 dom.SetStyle(themeLabel, "fontSize", "14px")
11872 dom.SetStyle(themeLabel, "minWidth", "140px")
11873 dom.AppendChild(themeRow, themeLabel)
11874
11875 themeToggle := dom.CreateElement("button")
11876 if appSt.isDark {
11877 dom.SetTextContent(themeToggle, t("dark"))
11878 } else {
11879 dom.SetTextContent(themeToggle, t("light"))
11880 }
11881 dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
11882 dom.SetStyle(themeToggle, "fontSize", "13px")
11883 dom.SetStyle(themeToggle, "background", "var(--bg2)")
11884 dom.SetStyle(themeToggle, "color", "var(--fg)")
11885 dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
11886 dom.SetStyle(themeToggle, "borderRadius", "4px")
11887 dom.SetStyle(themeToggle, "padding", "6px 16px")
11888 dom.SetStyle(themeToggle, "cursor", "pointer")
11889 dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
11890 toggleTheme()
11891 if appSt.isDark {
11892 dom.SetTextContent(themeToggle, t("dark"))
11893 } else {
11894 dom.SetTextContent(themeToggle, t("light"))
11895 }
11896 saveAppSettings()
11897 }))
11898 dom.AppendChild(themeRow, themeToggle)
11899 dom.AppendChild(appSt.settingsPage, themeRow)
11900
11901 // Client tag setting.
11902 clientRow := dom.CreateElement("div")
11903 dom.SetStyle(clientRow, "display", "flex")
11904 dom.SetStyle(clientRow, "alignItems", "center")
11905 dom.SetStyle(clientRow, "gap", "12px")
11906 dom.SetStyle(clientRow, "marginBottom", "16px")
11907
11908 clientLabel := dom.CreateElement("span")
11909 dom.SetTextContent(clientLabel, "client tag")
11910 dom.SetStyle(clientLabel, "fontSize", "14px")
11911 dom.SetStyle(clientLabel, "minWidth", "140px")
11912 dom.AppendChild(clientRow, clientLabel)
11913
11914 clientCb := dom.CreateElement("input")
11915 dom.SetAttribute(clientCb, "type", "checkbox")
11916 if localstorage.GetItem("musiquay-client-tag") != "0" {
11917 dom.SetProperty(clientCb, "checked", "true")
11918 }
11919 dom.AddEventListener(clientCb, "change", dom.RegisterCallback(func() {
11920 checked := dom.GetProperty(clientCb, "checked")
11921 if checked == "true" {
11922 localstorage.RemoveItem("musiquay-client-tag")
11923 } else {
11924 localstorage.SetItem("musiquay-client-tag", "0")
11925 }
11926 saveAppSettings()
11927 }))
11928 dom.AppendChild(clientRow, clientCb)
11929
11930 clientDesc := dom.CreateElement("span")
11931 dom.SetTextContent(clientDesc, "add smesh.lol tag to posts")
11932 dom.SetStyle(clientDesc, "fontSize", "12px")
11933 dom.SetStyle(clientDesc, "color", "var(--muted)")
11934 dom.AppendChild(clientRow, clientDesc)
11935
11936 dom.AppendChild(appSt.settingsPage, clientRow)
11937
11938 // Blossom server URL.
11939 blossomRow := dom.CreateElement("div")
11940 dom.SetStyle(blossomRow, "display", "flex")
11941 dom.SetStyle(blossomRow, "flexWrap", "wrap")
11942 dom.SetStyle(blossomRow, "alignItems", "center")
11943 dom.SetStyle(blossomRow, "gap", "12px")
11944 dom.SetStyle(blossomRow, "marginBottom", "16px")
11945
11946 blossomLabel := dom.CreateElement("span")
11947 dom.SetTextContent(blossomLabel, "blossom server")
11948 dom.SetStyle(blossomLabel, "fontSize", "14px")
11949 dom.SetStyle(blossomLabel, "minWidth", "140px")
11950 dom.AppendChild(blossomRow, blossomLabel)
11951
11952 blossomInput := dom.CreateElement("input")
11953 dom.SetAttribute(blossomInput, "type", "text")
11954 dom.SetAttribute(blossomInput, "placeholder", "https://blossom.example.com")
11955 blossomVal := appSt.blossomServerURL
11956 if blossomVal == "" {
11957 blossomVal = defaultBlossomURL()
11958 }
11959 dom.SetProperty(blossomInput, "value", blossomVal)
11960 dom.SetStyle(blossomInput, "fontFamily", "'Fira Code', monospace")
11961 dom.SetStyle(blossomInput, "fontSize", "13px")
11962 dom.SetStyle(blossomInput, "background", "var(--bg)")
11963 dom.SetStyle(blossomInput, "color", "var(--fg)")
11964 dom.SetStyle(blossomInput, "border", "1px solid var(--border)")
11965 dom.SetStyle(blossomInput, "borderRadius", "4px")
11966 dom.SetStyle(blossomInput, "padding", "6px 12px")
11967 dom.SetStyle(blossomInput, "flex", "1")
11968 dom.SetStyle(blossomInput, "minWidth", "200px")
11969 dom.AppendChild(blossomRow, blossomInput)
11970
11971 blossomSave := dom.CreateElement("button")
11972 dom.SetTextContent(blossomSave, "save")
11973 dom.SetStyle(blossomSave, "padding", "6px 16px")
11974 dom.SetStyle(blossomSave, "borderRadius", "4px")
11975 dom.SetStyle(blossomSave, "border", "1px solid var(--border)")
11976 dom.SetStyle(blossomSave, "background", "var(--accent)")
11977 dom.SetStyle(blossomSave, "color", "var(--bg)")
11978 dom.SetStyle(blossomSave, "cursor", "pointer")
11979 dom.SetStyle(blossomSave, "fontSize", "13px")
11980 dom.AddEventListener(blossomSave, "click", dom.RegisterCallback(func() {
11981 val := dom.GetProperty(blossomInput, "value")
11982 appSt.blossomServerURL = val
11983 if val == "" {
11984 localstorage.RemoveItem(lsKeyBlossomServer)
11985 } else {
11986 localstorage.SetItem(lsKeyBlossomServer, val)
11987 }
11988 saveAppSettings()
11989 dom.SetTextContent(blossomSave, "saved!")
11990 dom.SetTimeout(func() {
11991 dom.SetTextContent(blossomSave, "save")
11992 }, 1500)
11993 }))
11994 dom.AppendChild(blossomRow, blossomSave)
11995 dom.AppendChild(appSt.settingsPage, blossomRow)
11996
11997 // Invidious URL setting.
11998 appendServiceURLSetting(appSt.settingsPage, "invidious server", appSt.invidiousURL,
11999 defaultServiceURL("invidious"), lsKeyInvidiousURL, func(v string) { appSt.invidiousURL = v },
12000 "use invidious.smesh.lol")
12001
12002 // Campion (Bandcamp player) URL setting.
12003 appendServiceURLSetting(appSt.settingsPage, "campion (bandcamp)", appSt.campionURL,
12004 defaultServiceURL("campion"), lsKeyCampionURL, func(v string) { appSt.campionURL = v },
12005 "use campion.smesh.lol")
12006
12007 renderRelaysSettings()
12008 }
12009
12010 func addRelayURL(url string) {
12011 addRelay(url, true)
12012 sendWriteRelays()
12013 populateFeedSelect()
12014 }
12015
12016 type relayFreqEntry struct {
12017 url string
12018 freq int32
12019 }
12020
12021 // sortRelaysByFreq: relayFreq moved to Profile Worker; Shell returns empty.
12022 func sortRelaysByFreq() (ss []relayFreqEntry) { return nil }
12023
12024 // renderRelaysSettings renders the "Relays" section of the settings page:
12025 // active relays with capability badges, required-capability checkboxes, and
12026 // the blocklist. Called from renderSettings(). Read-only behavior at this
12027 // step: checkboxes persist to localStorage but no enforcement is applied.
12028 // appendServiceURLSetting appends a labelled URL input + "use X" prefill button + save button.
12029 // onSet is called with the new value when saved. prefillLabel is the text on the prefill button.
12030 func appendServiceURLSetting(parent dom.Element, label, current, defaultVal, lsKey string, onSet func(string), prefillLabel string) {
12031 row := dom.CreateElement("div")
12032 dom.SetStyle(row, "display", "flex")
12033 dom.SetStyle(row, "flexWrap", "wrap")
12034 dom.SetStyle(row, "alignItems", "center")
12035 dom.SetStyle(row, "gap", "12px")
12036 dom.SetStyle(row, "marginBottom", "16px")
12037
12038 lbl := dom.CreateElement("span")
12039 dom.SetTextContent(lbl, label)
12040 dom.SetStyle(lbl, "fontSize", "14px")
12041 dom.SetStyle(lbl, "minWidth", "160px")
12042 dom.AppendChild(row, lbl)
12043
12044 input := dom.CreateElement("input")
12045 dom.SetAttribute(input, "type", "text")
12046 dom.SetAttribute(input, "placeholder", "https://...")
12047 val := current
12048 if val == "" && defaultVal != "" {
12049 val = defaultVal
12050 }
12051 dom.SetProperty(input, "value", val)
12052 dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
12053 dom.SetStyle(input, "fontSize", "13px")
12054 dom.SetStyle(input, "background", "var(--bg)")
12055 dom.SetStyle(input, "color", "var(--fg)")
12056 dom.SetStyle(input, "border", "1px solid var(--border)")
12057 dom.SetStyle(input, "borderRadius", "4px")
12058 dom.SetStyle(input, "padding", "6px 12px")
12059 dom.SetStyle(input, "flex", "1")
12060 dom.SetStyle(input, "minWidth", "200px")
12061 dom.AppendChild(row, input)
12062
12063 if defaultVal != "" {
12064 prefillBtn := dom.CreateElement("button")
12065 dom.SetTextContent(prefillBtn, prefillLabel)
12066 dom.SetStyle(prefillBtn, "padding", "6px 10px")
12067 dom.SetStyle(prefillBtn, "borderRadius", "4px")
12068 dom.SetStyle(prefillBtn, "border", "1px solid var(--border)")
12069 dom.SetStyle(prefillBtn, "background", "transparent")
12070 dom.SetStyle(prefillBtn, "color", "var(--fg)")
12071 dom.SetStyle(prefillBtn, "cursor", "pointer")
12072 dom.SetStyle(prefillBtn, "fontSize", "12px")
12073 capturedInput := input
12074 capturedDefault := defaultVal
12075 dom.AddEventListener(prefillBtn, "click", dom.RegisterCallback(func() {
12076 dom.SetProperty(capturedInput, "value", capturedDefault)
12077 }))
12078 dom.AppendChild(row, prefillBtn)
12079 }
12080
12081 saveBtn := dom.CreateElement("button")
12082 dom.SetTextContent(saveBtn, "save")
12083 dom.SetStyle(saveBtn, "padding", "6px 16px")
12084 dom.SetStyle(saveBtn, "borderRadius", "4px")
12085 dom.SetStyle(saveBtn, "border", "1px solid var(--border)")
12086 dom.SetStyle(saveBtn, "background", "var(--accent)")
12087 dom.SetStyle(saveBtn, "color", "var(--bg)")
12088 dom.SetStyle(saveBtn, "cursor", "pointer")
12089 dom.SetStyle(saveBtn, "fontSize", "13px")
12090 capturedInput2 := input
12091 capturedLsKey := lsKey
12092 capturedOnSet := onSet
12093 capturedSaveBtn := saveBtn
12094 dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
12095 v := dom.GetProperty(capturedInput2, "value")
12096 capturedOnSet(v)
12097 if v == "" {
12098 localstorage.RemoveItem(capturedLsKey)
12099 } else {
12100 localstorage.SetItem(capturedLsKey, v)
12101 }
12102 saveAppSettings()
12103 dom.SetTextContent(capturedSaveBtn, "saved!")
12104 dom.SetTimeout(func() { dom.SetTextContent(capturedSaveBtn, "save") }, 1500)
12105 }))
12106 dom.AppendChild(row, saveBtn)
12107 dom.AppendChild(parent, row)
12108 }
12109
12110 func renderRelaysSettings() {
12111 // Section header.
12112 hdr := dom.CreateElement("h3")
12113 dom.SetTextContent(hdr, t("relays_section"))
12114 dom.SetStyle(hdr, "fontSize", "16px")
12115 dom.SetStyle(hdr, "marginTop", "32px")
12116 dom.SetStyle(hdr, "marginBottom", "12px")
12117 dom.AppendChild(appSt.settingsPage, hdr)
12118
12119 // --- Required capabilities checkboxes ---
12120 reqHdr := dom.CreateElement("div")
12121 dom.SetTextContent(reqHdr, t("relays_required"))
12122 dom.SetStyle(reqHdr, "fontSize", "13px")
12123 dom.SetStyle(reqHdr, "color", "var(--muted)")
12124 dom.SetStyle(reqHdr, "marginBottom", "6px")
12125 dom.AppendChild(appSt.settingsPage, reqHdr)
12126
12127 addPolicyCheckbox := func(label string, bit int32) {
12128 row := dom.CreateElement("label")
12129 dom.SetStyle(row, "display", "flex")
12130 dom.SetStyle(row, "alignItems", "center")
12131 dom.SetStyle(row, "gap", "8px")
12132 dom.SetStyle(row, "padding", "4px 0")
12133 dom.SetStyle(row, "fontSize", "13px")
12134 dom.SetStyle(row, "cursor", "pointer")
12135
12136 cb := dom.CreateElement("input")
12137 dom.SetAttribute(cb, "type", "checkbox")
12138 if appSt.relayPolicyFlags&bit != 0 {
12139 dom.SetAttribute(cb, "checked", "checked")
12140 }
12141 dom.AddEventListener(cb, "change", dom.RegisterCallback(func() {
12142 if dom.GetProperty(cb, "checked") == "true" {
12143 appSt.relayPolicyFlags = appSt.relayPolicyFlags | bit
12144 } else {
12145 appSt.relayPolicyFlags = appSt.relayPolicyFlags &^ bit
12146 }
12147 saveRelayPolicy()
12148 }))
12149 dom.AppendChild(row, cb)
12150
12151 lblEl := dom.CreateElement("span")
12152 dom.SetTextContent(lblEl, label)
12153 dom.AppendChild(row, lblEl)
12154
12155 dom.AppendChild(appSt.settingsPage, row)
12156 }
12157
12158 addPolicyCheckbox(t("relays_req_nip42"), policyRequireAuth)
12159 addPolicyCheckbox(t("relays_req_nip70"), policyRequireProtect)
12160 addPolicyCheckbox(t("relays_block_payment"), policyBlockPayment)
12161 addPolicyCheckbox(t("relays_block_restrict"), policyBlockRestricted)
12162
12163 // --- Active relays list ---
12164 activeHdr := dom.CreateElement("div")
12165 dom.SetTextContent(activeHdr, t("relays_active"))
12166 dom.SetStyle(activeHdr, "fontSize", "13px")
12167 dom.SetStyle(activeHdr, "color", "var(--muted)")
12168 dom.SetStyle(activeHdr, "marginTop", "20px")
12169 dom.SetStyle(activeHdr, "marginBottom", "6px")
12170 dom.AppendChild(appSt.settingsPage, activeHdr)
12171
12172 for i, url := range appSt.relayURLs {
12173 thisURL := url // capture per-iteration for closures
12174 row := dom.CreateElement("div")
12175 dom.SetStyle(row, "padding", "3px 0")
12176 dom.SetStyle(row, "fontSize", "12px")
12177 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
12178 dom.SetStyle(row, "wordBreak", "break-all")
12179 dom.SetStyle(row, "display", "flex")
12180 dom.SetStyle(row, "alignItems", "center")
12181 dom.SetStyle(row, "gap", "6px")
12182
12183 // Block button first so it sits at the left edge.
12184 blockBtn := dom.CreateElement("button")
12185 dom.SetTextContent(blockBtn, "\u00D7")
12186 dom.SetStyle(blockBtn, "background", "transparent")
12187 dom.SetStyle(blockBtn, "border", "1px solid var(--border)")
12188 dom.SetStyle(blockBtn, "color", "var(--fg)")
12189 dom.SetStyle(blockBtn, "cursor", "pointer")
12190 dom.SetStyle(blockBtn, "fontFamily", "'Fira Code', monospace")
12191 dom.SetStyle(blockBtn, "fontSize", "11px")
12192 dom.SetStyle(blockBtn, "width", "18px")
12193 dom.SetStyle(blockBtn, "height", "18px")
12194 dom.SetStyle(blockBtn, "padding", "0")
12195 dom.SetStyle(blockBtn, "lineHeight", "1")
12196 dom.SetStyle(blockBtn, "flexShrink", "0")
12197 dom.AddEventListener(blockBtn, "click", dom.RegisterCallback(func() {
12198 if !dom.Confirm("Block " | thisURL | "?") {
12199 return
12200 }
12201 addToBlocklist(thisURL)
12202 renderSettings()
12203 }))
12204 dom.AppendChild(row, blockBtn)
12205
12206 urlSpan := dom.CreateElement("span")
12207 dom.SetTextContent(urlSpan, thisURL)
12208 dom.SetStyle(urlSpan, "flex", "1")
12209 dom.SetStyle(urlSpan, "overflow", "hidden")
12210 dom.SetStyle(urlSpan, "textOverflow", "ellipsis")
12211 dom.SetStyle(urlSpan, "whiteSpace", "nowrap")
12212 if appSt.relayUserPick[i] {
12213 dom.SetStyle(urlSpan, "fontWeight", "bold")
12214 }
12215 dom.AppendChild(row, urlSpan)
12216
12217 // Read / write role buttons.
12218 thisI := i
12219 makeSettingsRoleBtn := func(label string) dom.Element {
12220 b := dom.CreateElement("button")
12221 dom.SetTextContent(b, label)
12222 dom.SetStyle(b, "flexShrink", "0")
12223 dom.SetStyle(b, "fontSize", "10px")
12224 dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
12225 dom.SetStyle(b, "padding", "1px 5px")
12226 dom.SetStyle(b, "borderRadius", "3px")
12227 dom.SetStyle(b, "border", "1px solid var(--border)")
12228 dom.SetStyle(b, "cursor", "pointer")
12229 return b
12230 }
12231 srBtn := makeSettingsRoleBtn("r")
12232 swBtn := makeSettingsRoleBtn("w")
12233 role := "both"
12234 if thisI < len(appSt.relayRole) {
12235 role = appSt.relayRole[thisI]
12236 }
12237 applyRelayRoleBtns(srBtn, swBtn, role)
12238 capturedI := thisI
12239 dom.AddEventListener(srBtn, "click", dom.RegisterCallback(func() {
12240 if capturedI >= len(appSt.relayRole) {
12241 return
12242 }
12243 switch appSt.relayRole[capturedI] {
12244 case "both":
12245 appSt.relayRole[capturedI] = "write"
12246 case "read":
12247 appSt.relayRole[capturedI] = "write"
12248 default:
12249 appSt.relayRole[capturedI] = "both"
12250 }
12251 if capturedI < len(appSt.relayReadBtns) {
12252 applyRelayRoleBtns(appSt.relayReadBtns[capturedI], appSt.relayWriteBtns[capturedI], appSt.relayRole[capturedI])
12253 }
12254 applyRelayRoleBtns(srBtn, swBtn, appSt.relayRole[capturedI])
12255 saveRelayList()
12256 sendWriteRelays()
12257 }))
12258 dom.AddEventListener(swBtn, "click", dom.RegisterCallback(func() {
12259 if capturedI >= len(appSt.relayRole) {
12260 return
12261 }
12262 switch appSt.relayRole[capturedI] {
12263 case "both":
12264 appSt.relayRole[capturedI] = "read"
12265 case "write":
12266 appSt.relayRole[capturedI] = "read"
12267 default:
12268 appSt.relayRole[capturedI] = "both"
12269 }
12270 if capturedI < len(appSt.relayReadBtns) {
12271 applyRelayRoleBtns(appSt.relayReadBtns[capturedI], appSt.relayWriteBtns[capturedI], appSt.relayRole[capturedI])
12272 }
12273 applyRelayRoleBtns(srBtn, swBtn, appSt.relayRole[capturedI])
12274 saveRelayList()
12275 sendWriteRelays()
12276 }))
12277 caps := appSt.relayCaps[i]
12278 if !hasNIP(caps.supportedNIPs, 42) {
12279 dom.AppendChild(row, makeRelayBadge("!42"))
12280 }
12281 if !hasNIP(caps.supportedNIPs, 70) {
12282 dom.AppendChild(row, makeRelayBadge("!70"))
12283 }
12284 if caps.fetchedOK && hasNIP(caps.supportedNIPs, 50) {
12285 badge := makeRelayBadge("search")
12286 dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
12287 dom.SetStyle(badge, "color", "#0a0")
12288 dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
12289 dom.AppendChild(row, badge)
12290 }
12291 if !caps.fetchedOK {
12292 noDoc := dom.CreateElement("span")
12293 dom.SetTextContent(noDoc, t("relays_no_nip11"))
12294 dom.SetStyle(noDoc, "fontSize", "10px")
12295 dom.SetStyle(noDoc, "color", "var(--muted)")
12296 dom.SetStyle(noDoc, "marginLeft", "6px")
12297 dom.AppendChild(row, noDoc)
12298 }
12299
12300 // r/w buttons pushed to far right.
12301 dom.SetStyle(srBtn, "marginLeft", "auto")
12302 dom.AppendChild(row, srBtn)
12303 dom.AppendChild(row, swBtn)
12304 dom.AppendChild(appSt.settingsPage, row)
12305 }
12306
12307 // --- Add relay input ---
12308 addRow := dom.CreateElement("div")
12309 dom.SetStyle(addRow, "display", "flex")
12310 dom.SetStyle(addRow, "alignItems", "center")
12311 dom.SetStyle(addRow, "gap", "6px")
12312 dom.SetStyle(addRow, "marginTop", "8px")
12313 dom.SetStyle(addRow, "position", "relative")
12314
12315 addInput := dom.CreateElement("input")
12316 dom.SetAttribute(addInput, "type", "text")
12317 dom.SetAttribute(addInput, "placeholder", "wss://relay.example.com")
12318 dom.SetStyle(addInput, "flex", "1")
12319 dom.SetStyle(addInput, "padding", "6px 8px")
12320 dom.SetStyle(addInput, "fontSize", "12px")
12321 dom.SetStyle(addInput, "fontFamily", "'Fira Code', monospace")
12322 dom.SetStyle(addInput, "background", "var(--bg)")
12323 dom.SetStyle(addInput, "color", "var(--fg)")
12324 dom.SetStyle(addInput, "border", "1px solid var(--border)")
12325 dom.SetStyle(addInput, "borderRadius", "4px")
12326
12327 addBtn := dom.CreateElement("button")
12328 dom.SetTextContent(addBtn, "+")
12329 dom.SetStyle(addBtn, "padding", "6px 10px")
12330 dom.SetStyle(addBtn, "background", "var(--accent)")
12331 dom.SetStyle(addBtn, "color", "#fff")
12332 dom.SetStyle(addBtn, "border", "none")
12333 dom.SetStyle(addBtn, "borderRadius", "4px")
12334 dom.SetStyle(addBtn, "cursor", "pointer")
12335 dom.SetStyle(addBtn, "fontWeight", "bold")
12336 dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() {
12337 url := dom.GetProperty(addInput, "value")
12338 if url != "" {
12339 addRelayURL(url)
12340 dom.SetProperty(addInput, "value", "")
12341 renderSettings()
12342 }
12343 }))
12344
12345 // Suggestion dropdown button.
12346 sugWrap := dom.CreateElement("div")
12347 dom.SetStyle(sugWrap, "position", "relative")
12348
12349 sugBtn := dom.CreateElement("button")
12350 dom.SetTextContent(sugBtn, "\u25BE")
12351 dom.SetStyle(sugBtn, "padding", "6px 8px")
12352 dom.SetStyle(sugBtn, "background", "var(--bg2)")
12353 dom.SetStyle(sugBtn, "color", "var(--fg)")
12354 dom.SetStyle(sugBtn, "border", "1px solid var(--border)")
12355 dom.SetStyle(sugBtn, "borderRadius", "4px")
12356 dom.SetStyle(sugBtn, "cursor", "pointer")
12357
12358 sugPop := dom.CreateElement("div")
12359 dom.SetStyle(sugPop, "display", "none")
12360 dom.SetStyle(sugPop, "position", "absolute")
12361 dom.SetStyle(sugPop, "bottom", "100%")
12362 dom.SetStyle(sugPop, "right", "0")
12363 dom.SetStyle(sugPop, "marginBottom", "4px")
12364 dom.SetStyle(sugPop, "background", "var(--bg)")
12365 dom.SetStyle(sugPop, "border", "1px solid var(--border)")
12366 dom.SetStyle(sugPop, "borderRadius", "6px")
12367 dom.SetStyle(sugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
12368 dom.SetStyle(sugPop, "zIndex", "1000")
12369 dom.SetStyle(sugPop, "minWidth", "280px")
12370 dom.SetStyle(sugPop, "maxHeight", "200px")
12371 dom.SetStyle(sugPop, "overflowY", "auto")
12372 dom.SetStyle(sugPop, "padding", "4px")
12373
12374 sugOpen := false
12375 dom.AddEventListener(sugBtn, "click", dom.RegisterCallback(func() {
12376 if sugOpen {
12377 dom.SetStyle(sugPop, "display", "none")
12378 sugOpen = false
12379 return
12380 }
12381 // Build sorted suggestions.
12382 clearChildren(sugPop)
12383 sorted := sortRelaysByFreq()
12384 count := 0
12385 for _, entry := range sorted {
12386 u := entry.url
12387 // Skip already-active relays.
12388 already := false
12389 for _, active := range appSt.relayURLs {
12390 if active == u {
12391 already = true
12392 break
12393 }
12394 }
12395 if already {
12396 continue
12397 }
12398 sugRow := dom.CreateElement("div")
12399 dom.SetStyle(sugRow, "display", "flex")
12400 dom.SetStyle(sugRow, "alignItems", "center")
12401 dom.SetStyle(sugRow, "justifyContent", "space-between")
12402 dom.SetStyle(sugRow, "padding", "4px 8px")
12403 dom.SetStyle(sugRow, "cursor", "pointer")
12404 dom.SetStyle(sugRow, "borderRadius", "4px")
12405 dom.SetStyle(sugRow, "fontSize", "12px")
12406 dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
12407
12408 urlSpan := dom.CreateElement("span")
12409 dom.SetTextContent(urlSpan, stripScheme(u))
12410 dom.AppendChild(sugRow, urlSpan)
12411
12412 freqSpan := dom.CreateElement("span")
12413 dom.SetTextContent(freqSpan, itoa(entry.freq))
12414 dom.SetStyle(freqSpan, "color", "var(--muted)")
12415 dom.SetStyle(freqSpan, "fontSize", "10px")
12416 dom.AppendChild(sugRow, freqSpan)
12417
12418 dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
12419 dom.SetStyle(sugRow, "background", "var(--bg2)")
12420 }))
12421 dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
12422 dom.SetStyle(sugRow, "background", "transparent")
12423 }))
12424
12425 capURL := u
12426 dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
12427 addRelayURL(capURL)
12428 dom.SetStyle(sugPop, "display", "none")
12429 sugOpen = false
12430 renderSettings()
12431 }))
12432 dom.AppendChild(sugPop, sugRow)
12433 count++
12434 }
12435 if count == 0 {
12436 empty := dom.CreateElement("div")
12437 dom.SetTextContent(empty, "no suggestions yet")
12438 dom.SetStyle(empty, "color", "var(--muted)")
12439 dom.SetStyle(empty, "fontSize", "12px")
12440 dom.SetStyle(empty, "padding", "8px")
12441 dom.AppendChild(sugPop, empty)
12442 }
12443 dom.SetStyle(sugPop, "display", "block")
12444 sugOpen = true
12445 }))
12446
12447 dom.AppendChild(sugWrap, sugBtn)
12448 dom.AppendChild(sugWrap, sugPop)
12449
12450 dom.AppendChild(addRow, addInput)
12451 dom.AppendChild(addRow, addBtn)
12452 dom.AppendChild(addRow, sugWrap)
12453 dom.AppendChild(appSt.settingsPage, addRow)
12454
12455 // --- Blocklist ---
12456 blHdr := dom.CreateElement("div")
12457 dom.SetTextContent(blHdr, t("relays_blocklist"))
12458 dom.SetStyle(blHdr, "fontSize", "13px")
12459 dom.SetStyle(blHdr, "color", "var(--muted)")
12460 dom.SetStyle(blHdr, "marginTop", "20px")
12461 dom.SetStyle(blHdr, "marginBottom", "6px")
12462 dom.AppendChild(appSt.settingsPage, blHdr)
12463
12464 if len(appSt.relayBlocklist) == 0 {
12465 empty := dom.CreateElement("div")
12466 dom.SetTextContent(empty, t("relays_blocklist_empty"))
12467 dom.SetStyle(empty, "fontSize", "12px")
12468 dom.SetStyle(empty, "color", "var(--muted)")
12469 dom.SetStyle(empty, "fontStyle", "italic")
12470 dom.AppendChild(appSt.settingsPage, empty)
12471 } else {
12472 for _, url := range appSt.relayBlocklist {
12473 thisURL := url // capture per-iteration for closure
12474 row := dom.CreateElement("div")
12475 dom.SetStyle(row, "padding", "3px 0")
12476 dom.SetStyle(row, "fontSize", "12px")
12477 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
12478 dom.SetStyle(row, "wordBreak", "break-all")
12479 dom.SetStyle(row, "display", "flex")
12480 dom.SetStyle(row, "alignItems", "center")
12481 dom.SetStyle(row, "gap", "6px")
12482
12483 unblockBtn := dom.CreateElement("button")
12484 dom.SetTextContent(unblockBtn, "\u00D7")
12485 dom.SetStyle(unblockBtn, "background", "transparent")
12486 dom.SetStyle(unblockBtn, "border", "1px solid var(--border)")
12487 dom.SetStyle(unblockBtn, "color", "var(--fg)")
12488 dom.SetStyle(unblockBtn, "cursor", "pointer")
12489 dom.SetStyle(unblockBtn, "fontFamily", "'Fira Code', monospace")
12490 dom.SetStyle(unblockBtn, "fontSize", "11px")
12491 dom.SetStyle(unblockBtn, "width", "18px")
12492 dom.SetStyle(unblockBtn, "height", "18px")
12493 dom.SetStyle(unblockBtn, "padding", "0")
12494 dom.SetStyle(unblockBtn, "lineHeight", "1")
12495 dom.SetStyle(unblockBtn, "flexShrink", "0")
12496 dom.AddEventListener(unblockBtn, "click", dom.RegisterCallback(func() {
12497 removeFromBlocklist(thisURL)
12498 renderSettings()
12499 }))
12500 dom.AppendChild(row, unblockBtn)
12501
12502 urlSpan := dom.CreateElement("span")
12503 dom.SetTextContent(urlSpan, thisURL)
12504 dom.AppendChild(row, urlSpan)
12505
12506 dom.AppendChild(appSt.settingsPage, row)
12507 }
12508 }
12509
12510 // --- Add to blocklist input ---
12511 blAddRow := dom.CreateElement("div")
12512 dom.SetStyle(blAddRow, "display", "flex")
12513 dom.SetStyle(blAddRow, "alignItems", "center")
12514 dom.SetStyle(blAddRow, "gap", "6px")
12515 dom.SetStyle(blAddRow, "marginTop", "8px")
12516 dom.SetStyle(blAddRow, "position", "relative")
12517
12518 blAddInput := dom.CreateElement("input")
12519 dom.SetAttribute(blAddInput, "type", "text")
12520 dom.SetAttribute(blAddInput, "placeholder", "wss://relay.example.com")
12521 dom.SetStyle(blAddInput, "flex", "1")
12522 dom.SetStyle(blAddInput, "padding", "6px 8px")
12523 dom.SetStyle(blAddInput, "fontSize", "12px")
12524 dom.SetStyle(blAddInput, "fontFamily", "'Fira Code', monospace")
12525 dom.SetStyle(blAddInput, "background", "var(--bg)")
12526 dom.SetStyle(blAddInput, "color", "var(--fg)")
12527 dom.SetStyle(blAddInput, "border", "1px solid var(--border)")
12528 dom.SetStyle(blAddInput, "borderRadius", "4px")
12529
12530 blAddBtn := dom.CreateElement("button")
12531 dom.SetTextContent(blAddBtn, "+")
12532 dom.SetStyle(blAddBtn, "padding", "6px 10px")
12533 dom.SetStyle(blAddBtn, "background", "var(--accent)")
12534 dom.SetStyle(blAddBtn, "color", "#fff")
12535 dom.SetStyle(blAddBtn, "border", "none")
12536 dom.SetStyle(blAddBtn, "borderRadius", "4px")
12537 dom.SetStyle(blAddBtn, "cursor", "pointer")
12538 dom.SetStyle(blAddBtn, "fontWeight", "bold")
12539 dom.AddEventListener(blAddBtn, "click", dom.RegisterCallback(func() {
12540 url := dom.GetProperty(blAddInput, "value")
12541 if url != "" {
12542 addToBlocklist(url)
12543 dom.SetProperty(blAddInput, "value", "")
12544 renderSettings()
12545 }
12546 }))
12547
12548 // Suggestion dropdown button for blocklist.
12549 blSugWrap := dom.CreateElement("div")
12550 dom.SetStyle(blSugWrap, "position", "relative")
12551
12552 blSugBtn := dom.CreateElement("button")
12553 dom.SetTextContent(blSugBtn, "\u25BE")
12554 dom.SetStyle(blSugBtn, "padding", "6px 8px")
12555 dom.SetStyle(blSugBtn, "background", "var(--bg2)")
12556 dom.SetStyle(blSugBtn, "color", "var(--fg)")
12557 dom.SetStyle(blSugBtn, "border", "1px solid var(--border)")
12558 dom.SetStyle(blSugBtn, "borderRadius", "4px")
12559 dom.SetStyle(blSugBtn, "cursor", "pointer")
12560
12561 blSugPop := dom.CreateElement("div")
12562 dom.SetStyle(blSugPop, "display", "none")
12563 dom.SetStyle(blSugPop, "position", "absolute")
12564 dom.SetStyle(blSugPop, "bottom", "100%")
12565 dom.SetStyle(blSugPop, "right", "0")
12566 dom.SetStyle(blSugPop, "marginBottom", "4px")
12567 dom.SetStyle(blSugPop, "background", "var(--bg)")
12568 dom.SetStyle(blSugPop, "border", "1px solid var(--border)")
12569 dom.SetStyle(blSugPop, "borderRadius", "6px")
12570 dom.SetStyle(blSugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
12571 dom.SetStyle(blSugPop, "zIndex", "1000")
12572 dom.SetStyle(blSugPop, "minWidth", "280px")
12573 dom.SetStyle(blSugPop, "maxHeight", "200px")
12574 dom.SetStyle(blSugPop, "overflowY", "auto")
12575 dom.SetStyle(blSugPop, "padding", "4px")
12576
12577 blSugOpen := false
12578 dom.AddEventListener(blSugBtn, "click", dom.RegisterCallback(func() {
12579 if blSugOpen {
12580 dom.SetStyle(blSugPop, "display", "none")
12581 blSugOpen = false
12582 return
12583 }
12584 clearChildren(blSugPop)
12585 sorted := sortRelaysByFreq()
12586 count := 0
12587 for _, entry := range sorted {
12588 u := entry.url
12589 if isBlocked(u) {
12590 continue
12591 }
12592 sugRow := dom.CreateElement("div")
12593 dom.SetStyle(sugRow, "display", "flex")
12594 dom.SetStyle(sugRow, "alignItems", "center")
12595 dom.SetStyle(sugRow, "justifyContent", "space-between")
12596 dom.SetStyle(sugRow, "padding", "4px 8px")
12597 dom.SetStyle(sugRow, "cursor", "pointer")
12598 dom.SetStyle(sugRow, "borderRadius", "4px")
12599 dom.SetStyle(sugRow, "fontSize", "12px")
12600 dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
12601
12602 urlSpan := dom.CreateElement("span")
12603 dom.SetTextContent(urlSpan, stripScheme(u))
12604 dom.AppendChild(sugRow, urlSpan)
12605
12606 freqSpan := dom.CreateElement("span")
12607 dom.SetTextContent(freqSpan, itoa(entry.freq))
12608 dom.SetStyle(freqSpan, "color", "var(--muted)")
12609 dom.SetStyle(freqSpan, "fontSize", "10px")
12610 dom.AppendChild(sugRow, freqSpan)
12611
12612 dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
12613 dom.SetStyle(sugRow, "background", "var(--bg2)")
12614 }))
12615 dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
12616 dom.SetStyle(sugRow, "background", "transparent")
12617 }))
12618
12619 capURL := u
12620 dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
12621 addToBlocklist(capURL)
12622 dom.SetStyle(blSugPop, "display", "none")
12623 blSugOpen = false
12624 renderSettings()
12625 }))
12626 dom.AppendChild(blSugPop, sugRow)
12627 count++
12628 }
12629 if count == 0 {
12630 empty := dom.CreateElement("div")
12631 dom.SetTextContent(empty, "no suggestions yet")
12632 dom.SetStyle(empty, "color", "var(--muted)")
12633 dom.SetStyle(empty, "fontSize", "12px")
12634 dom.SetStyle(empty, "padding", "8px")
12635 dom.AppendChild(blSugPop, empty)
12636 }
12637 dom.SetStyle(blSugPop, "display", "block")
12638 blSugOpen = true
12639 }))
12640
12641 dom.AppendChild(blSugWrap, blSugBtn)
12642 dom.AppendChild(blSugWrap, blSugPop)
12643
12644 dom.AppendChild(blAddRow, blAddInput)
12645 dom.AppendChild(blAddRow, blAddBtn)
12646 dom.AppendChild(blAddRow, blSugWrap)
12647 dom.AppendChild(appSt.settingsPage, blAddRow)
12648
12649 // Wallet UI at the very bottom of the settings page.
12650 if int32(appSt.walletPanel) != 0 {
12651 dom.AppendChild(appSt.settingsPage, appSt.walletPanel)
12652 renderWalletUI()
12653 }
12654 }
12655
12656 // --- Messaging ---
12657
12658 // initMessaging is unreachable while messaging is disabled (no sidebar entry,
12659 // no /msg route). Keep it for the MLS/DM fix; re-enable by restoring the
12660 // sidebar button, the router cases, the switchPage case and the mlsWorkerTypes
12661 // routing in routeMsg.
12662 func initMessaging() {
12663 // Render new-chat button immediately - don't wait for DM_LIST round-trip.
12664 clearChildren(appSt.msgListContainer)
12665
12666 if !signer.HasSigner() {
12667 notice := dom.CreateElement("div")
12668 dom.SetStyle(notice, "padding", "24px")
12669 dom.SetStyle(notice, "textAlign", "center")
12670 dom.SetStyle(notice, "color", "var(--muted)")
12671 dom.SetStyle(notice, "fontSize", "13px")
12672 dom.SetStyle(notice, "lineHeight", "1.6")
12673 dom.SetInnerHTML(notice, t("dm_notice"))
12674 dom.AppendChild(appSt.msgListContainer, notice)
12675 return
12676 }
12677
12678 renderNewChatRow()
12679
12680 // Init MLS if not already done + request conversation list.
12681 if !appSt.marmotInited {
12682 appSt.marmotInited = true
12683 routeMsg("[\"MLS_INIT\"," | relayURLsJSON() | "]")
12684 }
12685 routeMsg("[\"MLS_DM_LIST\"]")
12686 }
12687
12688 func renderNewChatRow() {
12689 row := dom.CreateElement("div")
12690 dom.SetStyle(row, "display", "flex")
12691 dom.SetStyle(row, "gap", "6px")
12692 dom.SetStyle(row, "marginBottom", "8px")
12693 dom.SetStyle(row, "alignItems", "center")
12694
12695 // --- Follows group (default visible): [follows ▾] [+] ---
12696 followsGroup := dom.CreateElement("div")
12697 dom.SetStyle(followsGroup, "display", "flex")
12698 dom.SetStyle(followsGroup, "gap", "6px")
12699 dom.SetStyle(followsGroup, "alignItems", "center")
12700
12701 selWrap := dom.CreateElement("div")
12702 dom.SetStyle(selWrap, "position", "relative")
12703
12704 selBtn := dom.CreateElement("button")
12705 dom.SetTextContent(selBtn, t("follows") | " \u25BE")
12706 dom.SetStyle(selBtn, "padding", "8px 12px")
12707 dom.SetStyle(selBtn, "fontFamily", "'Fira Code', monospace")
12708 dom.SetStyle(selBtn, "fontSize", "12px")
12709 dom.SetStyle(selBtn, "background", "var(--bg2)")
12710 dom.SetStyle(selBtn, "border", "1px solid var(--border)")
12711 dom.SetStyle(selBtn, "borderRadius", "4px")
12712 dom.SetStyle(selBtn, "color", "var(--fg)")
12713 dom.SetStyle(selBtn, "cursor", "pointer")
12714
12715 dropdown := dom.CreateElement("div")
12716 dom.SetStyle(dropdown, "display", "none")
12717 dom.SetStyle(dropdown, "position", "absolute")
12718 dom.SetStyle(dropdown, "left", "0")
12719 dom.SetStyle(dropdown, "top", "100%")
12720 dom.SetStyle(dropdown, "marginTop", "4px")
12721 dom.SetStyle(dropdown, "background", "var(--bg)")
12722 dom.SetStyle(dropdown, "border", "1px solid var(--border)")
12723 dom.SetStyle(dropdown, "borderRadius", "6px")
12724 dom.SetStyle(dropdown, "maxHeight", "300px")
12725 dom.SetStyle(dropdown, "overflowY", "auto")
12726 dom.SetStyle(dropdown, "minWidth", "200px")
12727 dom.SetStyle(dropdown, "maxWidth", "calc(100vw - 32px)")
12728 dom.SetStyle(dropdown, "zIndex", "100")
12729 dom.SetStyle(dropdown, "boxShadow", "0 4px 12px rgba(0,0,0,0.3)")
12730
12731 populateFollowsDropdown(dropdown)
12732
12733 dom.SetAttribute(selWrap, "onclick", "event.stopPropagation()")
12734
12735 dropOpen := false
12736 dom.AddEventListener(selBtn, "click", dom.RegisterCallback(func() {
12737 if !dropOpen {
12738 populateFollowsDropdown(dropdown)
12739 dom.SetStyle(dropdown, "display", "block")
12740 dropOpen = true
12741 } else {
12742 dom.SetStyle(dropdown, "display", "none")
12743 dropOpen = false
12744 }
12745 }))
12746
12747 dom.AddEventListener(dom.Body(), "click", dom.RegisterCallback(func() {
12748 dom.SetStyle(dropdown, "display", "none")
12749 dropOpen = false
12750 }))
12751
12752 dom.AppendChild(selWrap, selBtn)
12753 dom.AppendChild(selWrap, dropdown)
12754
12755 addBtn := dom.CreateElement("button")
12756 dom.SetTextContent(addBtn, "+")
12757 dom.SetStyle(addBtn, "padding", "8px 10px")
12758 dom.SetStyle(addBtn, "fontFamily", "'Fira Code', monospace")
12759 dom.SetStyle(addBtn, "fontSize", "14px")
12760 dom.SetStyle(addBtn, "fontWeight", "bold")
12761 dom.SetStyle(addBtn, "background", "var(--bg2)")
12762 dom.SetStyle(addBtn, "border", "1px solid var(--border)")
12763 dom.SetStyle(addBtn, "borderRadius", "4px")
12764 dom.SetStyle(addBtn, "color", "var(--fg)")
12765 dom.SetStyle(addBtn, "cursor", "pointer")
12766 dom.SetStyle(addBtn, "lineHeight", "1")
12767
12768 dom.AppendChild(followsGroup, selWrap)
12769 dom.AppendChild(followsGroup, addBtn)
12770
12771 // --- Input group (hidden until + clicked): [input] [go] [×] ---
12772 inputGroup := dom.CreateElement("div")
12773 dom.SetStyle(inputGroup, "display", "none")
12774 dom.SetStyle(inputGroup, "flex", "1")
12775 dom.SetStyle(inputGroup, "gap", "6px")
12776 dom.SetStyle(inputGroup, "alignItems", "center")
12777
12778 inp := dom.CreateElement("input")
12779 dom.SetAttribute(inp, "type", "text")
12780 dom.SetAttribute(inp, "placeholder", t("npub_placeholder"))
12781 dom.SetStyle(inp, "flex", "1 1 120px")
12782 dom.SetStyle(inp, "minWidth", "0")
12783 dom.SetStyle(inp, "padding", "8px")
12784 dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace")
12785 dom.SetStyle(inp, "fontSize", "12px")
12786 dom.SetStyle(inp, "background", "var(--bg)")
12787 dom.SetStyle(inp, "border", "1px solid var(--border)")
12788 dom.SetStyle(inp, "borderRadius", "4px")
12789 dom.SetStyle(inp, "color", "var(--fg)")
12790
12791 goBtn := dom.CreateElement("button")
12792 dom.SetTextContent(goBtn, t("go"))
12793 dom.SetStyle(goBtn, "padding", "8px 14px")
12794 dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace")
12795 dom.SetStyle(goBtn, "fontSize", "12px")
12796 dom.SetStyle(goBtn, "background", "var(--accent)")
12797 dom.SetStyle(goBtn, "color", "#fff")
12798 dom.SetStyle(goBtn, "border", "none")
12799 dom.SetStyle(goBtn, "borderRadius", "4px")
12800 dom.SetStyle(goBtn, "cursor", "pointer")
12801 dom.SetStyle(goBtn, "flexShrink", "0")
12802
12803 closeBtn := dom.CreateElement("button")
12804 dom.SetTextContent(closeBtn, "\u00D7")
12805 dom.SetStyle(closeBtn, "padding", "8px 10px")
12806 dom.SetStyle(closeBtn, "fontFamily", "'Fira Code', monospace")
12807 dom.SetStyle(closeBtn, "fontSize", "14px")
12808 dom.SetStyle(closeBtn, "fontWeight", "bold")
12809 dom.SetStyle(closeBtn, "background", "var(--bg2)")
12810 dom.SetStyle(closeBtn, "border", "1px solid var(--border)")
12811 dom.SetStyle(closeBtn, "borderRadius", "4px")
12812 dom.SetStyle(closeBtn, "color", "var(--fg)")
12813 dom.SetStyle(closeBtn, "cursor", "pointer")
12814 dom.SetStyle(closeBtn, "lineHeight", "1")
12815
12816 dom.AppendChild(inputGroup, inp)
12817 dom.AppendChild(inputGroup, goBtn)
12818 dom.AppendChild(inputGroup, closeBtn)
12819
12820 // Toggle between follows group and input group.
12821 showInput := func() {
12822 dom.SetStyle(dropdown, "display", "none")
12823 dropOpen = false
12824 dom.SetStyle(followsGroup, "display", "none")
12825 dom.SetStyle(inputGroup, "display", "flex")
12826 dom.SetProperty(inp, "value", "")
12827 dom.Focus(inp)
12828 }
12829 showFollows := func() {
12830 dom.SetStyle(inputGroup, "display", "none")
12831 dom.SetStyle(followsGroup, "display", "flex")
12832 }
12833
12834 dom.AddEventListener(addBtn, "click", dom.RegisterCallback(showInput))
12835 dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(showFollows))
12836
12837 // Submit handler for input + go button.
12838 submitNewChat := func() {
12839 val := dom.GetProperty(inp, "value")
12840 if val == "" {
12841 return
12842 }
12843 var hexPK string
12844 if len(val) == 64 {
12845 hexPK = val
12846 } else if len(val) > 4 && val[:4] == "npub" {
12847 decoded := helpers.DecodeNpub(val)
12848 if decoded == nil {
12849 return
12850 }
12851 hexPK = helpers.HexEncode(decoded)
12852 } else {
12853 return
12854 }
12855 openThread(hexPK)
12856 }
12857
12858 dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat))
12859 dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}")
12860
12861 dom.AppendChild(row, followsGroup)
12862 dom.AppendChild(row, inputGroup)
12863 dom.AppendChild(appSt.msgListContainer, row)
12864 }
12865
12866 func populateFollowsDropdown(dropdown dom.Element) {
12867 clearChildren(dropdown)
12868 pks := appSt.myFollows
12869 if len(pks) == 0 {
12870 empty := dom.CreateElement("div")
12871 dom.SetStyle(empty, "padding", "12px")
12872 dom.SetStyle(empty, "color", "var(--muted)")
12873 dom.SetStyle(empty, "fontSize", "12px")
12874 dom.SetTextContent(empty, t("no_follows"))
12875 dom.AppendChild(dropdown, empty)
12876 return
12877 }
12878 // Sort by display name (named first, then by name alpha, unnamed last by short hex).
12879 type entry struct {
12880 pk string
12881 name string
12882 }
12883 var named []entry
12884 var unnamed []entry
12885 for _, pk := range pks {
12886 n := appSt.authorNames[pk]
12887 if n != "" {
12888 named = mxutil.Ensure(named, 1)
12889 named = push(named, entry{pk, n})
12890 } else {
12891 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
12892 if len(npub) > 16 {
12893 npub = npub[:16] | "..."
12894 }
12895 unnamed = mxutil.Ensure(unnamed, 1)
12896 unnamed = push(unnamed, entry{pk, npub})
12897 }
12898 }
12899 // Simple insertion sort by name.
12900 for i := 1; i < len(named); i++ {
12901 j := i
12902 for j > 0 && toLower(named[j].name) < toLower(named[j-1].name) {
12903 named[j], named[j-1] = named[j-1], named[j]
12904 j--
12905 }
12906 }
12907 var all []entry
12908 for _, e := range named {
12909 all = mxutil.Ensure(all, 1)
12910 all = push(all, e)
12911 }
12912 for _, e := range unnamed {
12913 all = mxutil.Ensure(all, 1)
12914 all = push(all, e)
12915 }
12916 for _, e := range all {
12917 item := dom.CreateElement("div")
12918 dom.SetStyle(item, "padding", "6px 12px")
12919 dom.SetStyle(item, "fontSize", "13px")
12920 dom.SetStyle(item, "cursor", "pointer")
12921 dom.SetStyle(item, "display", "flex")
12922 dom.SetStyle(item, "alignItems", "center")
12923 dom.SetStyle(item, "gap", "8px")
12924
12925 av := dom.CreateElement("img")
12926 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
12927 dom.SetAttribute(av, "width", "24")
12928 dom.SetAttribute(av, "height", "24")
12929 dom.SetStyle(av, "borderRadius", "50%")
12930 dom.SetStyle(av, "objectFit", "cover")
12931 dom.SetStyle(av, "flexShrink", "0")
12932 if pic, ok := appSt.authorPics[e.pk]; ok && pic != "" {
12933 setMediaSrc(av, pic)
12934 } else {
12935 dom.SetStyle(av, "background", "var(--bg2)")
12936 }
12937 dom.SetAttribute(av, "onerror", "this.style.display='none'")
12938 dom.AppendChild(item, av)
12939
12940 nameSpan := dom.CreateElement("span")
12941 dom.SetStyle(nameSpan, "overflow", "hidden")
12942 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
12943 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
12944 dom.SetTextContent(nameSpan, e.name)
12945 dom.AppendChild(item, nameSpan)
12946
12947 pk := e.pk
12948 dom.AddEventListener(item, "mouseenter", dom.RegisterCallback(func() {
12949 dom.SetStyle(item, "background", "var(--bg2)")
12950 }))
12951 dom.AddEventListener(item, "mouseleave", dom.RegisterCallback(func() {
12952 dom.SetStyle(item, "background", "transparent")
12953 }))
12954 dom.AddEventListener(item, "click", dom.RegisterCallback(func() {
12955 dom.SetStyle(dropdown, "display", "none")
12956 openThread(pk)
12957 }))
12958 dom.AppendChild(dropdown, item)
12959 }
12960 }
12961
12962 func renderConversationList(listJSON string) {
12963 if appSt.msgView != "list" {
12964 return
12965 }
12966 clearChildren(appSt.msgListContainer)
12967 renderNewChatRow()
12968
12969 // Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...]
12970 if listJSON == "" || listJSON == "[]" {
12971 empty := dom.CreateElement("div")
12972 dom.SetStyle(empty, "color", "var(--muted)")
12973 dom.SetStyle(empty, "textAlign", "center")
12974 dom.SetStyle(empty, "marginTop", "48px")
12975 dom.SetTextContent(empty, t("no_convos"))
12976 dom.AppendChild(appSt.msgListContainer, empty)
12977 return
12978 }
12979
12980 // Walk the JSON array manually - each element is an object.
12981 i := 0
12982 for i < len(listJSON) && listJSON[i] != '[' {
12983 i++
12984 }
12985 i++ // skip '['
12986 for i < len(listJSON) {
12987 // Find next object.
12988 for i < len(listJSON) && listJSON[i] != '{' {
12989 if listJSON[i] == ']' {
12990 return
12991 }
12992 i++
12993 }
12994 if i >= len(listJSON) {
12995 break
12996 }
12997 // Extract the object.
12998 objStart := i
12999 depth := 0
13000 for i < len(listJSON) {
13001 if listJSON[i] == '{' {
13002 depth++
13003 } else if listJSON[i] == '}' {
13004 depth--
13005 if depth == 0 {
13006 i++
13007 break
13008 }
13009 } else if listJSON[i] == '"' {
13010 i++
13011 for i < len(listJSON) && listJSON[i] != '"' {
13012 if listJSON[i] == '\\' {
13013 i++
13014 }
13015 i++
13016 }
13017 }
13018 i++
13019 }
13020 obj := listJSON[objStart:i]
13021
13022 peer := helpers.JsonGetString(obj, "peer")
13023 lastMsg := helpers.JsonGetString(obj, "lastMessage")
13024 lastTs := jsonGetNum(obj, "lastTs")
13025 if peer == "" {
13026 continue
13027 }
13028
13029 renderConversationRow(peer, lastMsg, lastTs)
13030 }
13031 }
13032
13033 func renderConversationRow(peer, lastMsg string, lastTs int64) {
13034 row := dom.CreateElement("div")
13035 dom.SetStyle(row, "display", "flex")
13036 dom.SetStyle(row, "alignItems", "center")
13037 dom.SetStyle(row, "gap", "10px")
13038 dom.SetStyle(row, "padding", "10px 4px")
13039 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
13040 dom.SetStyle(row, "cursor", "pointer")
13041
13042 // Avatar.
13043 av := dom.CreateElement("img")
13044 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
13045 dom.SetAttribute(av, "width", "32")
13046 dom.SetAttribute(av, "height", "32")
13047 dom.SetStyle(av, "borderRadius", "50%")
13048 dom.SetStyle(av, "objectFit", "cover")
13049 dom.SetStyle(av, "flexShrink", "0")
13050 if pic, ok := appSt.authorPics[peer]; ok && pic != "" {
13051 setMediaSrc(av, pic)
13052 } else {
13053 dom.SetStyle(av, "background", "var(--bg2)")
13054 }
13055 dom.SetAttribute(av, "onerror", "this.style.display='none'")
13056 dom.AppendChild(row, av)
13057
13058 // Name + preview column.
13059 col := dom.CreateElement("div")
13060 dom.SetStyle(col, "flex", "1")
13061 dom.SetStyle(col, "minWidth", "0")
13062
13063 nameSpan := dom.CreateElement("div")
13064 dom.SetStyle(nameSpan, "fontSize", "14px")
13065 dom.SetStyle(nameSpan, "fontWeight", "bold")
13066 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
13067 dom.SetStyle(nameSpan, "overflow", "hidden")
13068 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
13069 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
13070 if name, ok := appSt.authorNames[peer]; ok && name != "" {
13071 dom.SetTextContent(nameSpan, name)
13072 } else {
13073 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
13074 if len(npub) > 20 {
13075 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
13076 } else {
13077 dom.SetTextContent(nameSpan, npub)
13078 }
13079 }
13080 dom.AppendChild(col, nameSpan)
13081
13082 preview := dom.CreateElement("div")
13083 dom.SetStyle(preview, "fontSize", "12px")
13084 dom.SetStyle(preview, "color", "var(--muted)")
13085 dom.SetStyle(preview, "overflow", "hidden")
13086 dom.SetStyle(preview, "textOverflow", "ellipsis")
13087 dom.SetStyle(preview, "whiteSpace", "nowrap")
13088 if len(lastMsg) > 80 {
13089 lastMsg = lastMsg[:80] | "..."
13090 }
13091 dom.SetTextContent(preview, lastMsg)
13092 dom.AppendChild(col, preview)
13093 dom.AppendChild(row, col)
13094
13095 // Timestamp.
13096 if lastTs > 0 {
13097 tsSpan := dom.CreateElement("span")
13098 dom.SetStyle(tsSpan, "fontSize", "11px")
13099 dom.SetStyle(tsSpan, "color", "var(--muted)")
13100 dom.SetStyle(tsSpan, "flexShrink", "0")
13101 dom.SetTextContent(tsSpan, formatTime(lastTs))
13102 dom.AppendChild(row, tsSpan)
13103 }
13104
13105 rowPeer := peer
13106 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
13107 openThread(rowPeer)
13108 }))
13109
13110 // Lazy profile fetch - batched to avoid 50+ simultaneous subscriptions.
13111 if _, cached := appSt.authorNames[peer]; !cached && !lookupFetchedK0(peer) {
13112 profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
13113 }
13114
13115 dom.AppendChild(appSt.msgListContainer, row)
13116 }
13117
13118 func openThread(peer string) {
13119 appSt.msgCurrentConvoID = peer
13120 appSt.msgCurrentKind = "dm"
13121 appSt.msgView = "thread"
13122
13123 if !appSt.navPop {
13124 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
13125 dom.PushState("/msg/" | npub)
13126 }
13127
13128 // Hide list, show thread.
13129 dom.SetStyle(appSt.msgListContainer, "display", "none")
13130 dom.SetStyle(appSt.msgThreadContainer, "display", "flex")
13131
13132 // Build thread UI.
13133 clearChildren(appSt.msgThreadContainer)
13134
13135 // Header: back + avatar + name.
13136 hdr := dom.CreateElement("div")
13137 dom.SetStyle(hdr, "display", "flex")
13138 dom.SetStyle(hdr, "alignItems", "center")
13139 dom.SetStyle(hdr, "gap", "10px")
13140 dom.SetStyle(hdr, "padding", "12px 16px")
13141 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
13142 dom.SetStyle(hdr, "flexShrink", "0")
13143
13144 backBtn := dom.CreateElement("button")
13145 dom.SetInnerHTML(backBtn, "←") // ←
13146 dom.SetStyle(backBtn, "background", "none")
13147 dom.SetStyle(backBtn, "border", "none")
13148 dom.SetStyle(backBtn, "fontSize", "20px")
13149 dom.SetStyle(backBtn, "cursor", "pointer")
13150 dom.SetStyle(backBtn, "color", "var(--fg)")
13151 dom.SetStyle(backBtn, "padding", "0")
13152 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
13153 closeThread()
13154 }))
13155 dom.AppendChild(hdr, backBtn)
13156
13157 // Thread header avatar + name - uses same img-then-span structure
13158 // as note headers so pendingNotes/updateNoteHeader can update them.
13159 threadHdrInner := dom.CreateElement("div")
13160 av := dom.CreateElement("img")
13161 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
13162 dom.SetAttribute(av, "width", "28")
13163 dom.SetAttribute(av, "height", "28")
13164 dom.SetStyle(av, "borderRadius", "50%")
13165 dom.SetStyle(av, "objectFit", "cover")
13166 dom.SetStyle(av, "flexShrink", "0")
13167 if pic, ok := appSt.authorPics[peer]; ok && pic != "" {
13168 setMediaSrc(av, pic)
13169 } else {
13170 dom.SetStyle(av, "display", "none")
13171 }
13172 dom.SetAttribute(av, "onerror", "this.style.display='none'")
13173 dom.AppendChild(threadHdrInner, av)
13174
13175 nameSpan := dom.CreateElement("span")
13176 dom.SetStyle(nameSpan, "fontSize", "15px")
13177 dom.SetStyle(nameSpan, "fontWeight", "bold")
13178 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
13179 dom.SetStyle(nameSpan, "overflow", "hidden")
13180 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
13181 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
13182 dom.SetStyle(nameSpan, "minWidth", "0")
13183 if name, ok := appSt.authorNames[peer]; ok && len(name) > 0 {
13184 dom.SetTextContent(nameSpan, name)
13185 } else {
13186 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
13187 if len(npub) > 20 {
13188 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
13189 }
13190 }
13191 dom.AppendChild(threadHdrInner, nameSpan)
13192 dom.SetStyle(threadHdrInner, "display", "flex")
13193 dom.SetStyle(threadHdrInner, "alignItems", "center")
13194 dom.SetStyle(threadHdrInner, "gap", "10px")
13195 dom.SetStyle(threadHdrInner, "flexGrow", "1")
13196 dom.SetStyle(threadHdrInner, "minWidth", "0")
13197 dom.SetStyle(threadHdrInner, "cursor", "pointer")
13198 profilePeer := peer
13199 dom.AddEventListener(threadHdrInner, "click", dom.RegisterCallback(func() {
13200 showProfile(profilePeer)
13201 }))
13202 dom.AppendChild(hdr, threadHdrInner)
13203
13204 // Relay selector button.
13205 dmRelayWrap := dom.CreateElement("div")
13206 dom.SetStyle(dmRelayWrap, "position", "relative")
13207 dom.SetStyle(dmRelayWrap, "display", "flex")
13208 dom.SetStyle(dmRelayWrap, "alignItems", "center")
13209 dom.SetStyle(dmRelayWrap, "gap", "6px")
13210
13211 dmWarnBadge := makeRelayWarnBadge()
13212
13213 dmRelayBtn := dom.CreateElement("button")
13214 dom.SetStyle(dmRelayBtn, "background", "none")
13215 dom.SetStyle(dmRelayBtn, "border", "1px solid var(--border)")
13216 dom.SetStyle(dmRelayBtn, "borderRadius", "4px")
13217 dom.SetStyle(dmRelayBtn, "color", "var(--fg)")
13218 dom.SetStyle(dmRelayBtn, "cursor", "pointer")
13219 dom.SetStyle(dmRelayBtn, "fontSize", "11px")
13220 dom.SetStyle(dmRelayBtn, "padding", "4px 8px")
13221 dom.SetStyle(dmRelayBtn, "fontFamily", "'Fira Code', monospace")
13222 updateRelaySelBtn(dmRelayBtn, false)
13223
13224 dmRelayPop := dom.CreateElement("div")
13225 dom.SetStyle(dmRelayPop, "display", "none")
13226 dom.SetStyle(dmRelayPop, "position", "absolute")
13227 dom.SetStyle(dmRelayPop, "top", "100%")
13228 dom.SetStyle(dmRelayPop, "right", "0")
13229 dom.SetStyle(dmRelayPop, "marginTop", "4px")
13230 dom.SetStyle(dmRelayPop, "background", "var(--bg)")
13231 dom.SetStyle(dmRelayPop, "border", "1px solid var(--border)")
13232 dom.SetStyle(dmRelayPop, "borderRadius", "6px")
13233 dom.SetStyle(dmRelayPop, "padding", "8px")
13234 dom.SetStyle(dmRelayPop, "boxShadow", "0 2px 8px rgba(0,0,0,0.2)")
13235 dom.SetStyle(dmRelayPop, "zIndex", "1000")
13236 dom.SetStyle(dmRelayPop, "minWidth", "200px")
13237 dom.SetStyle(dmRelayPop, "maxWidth", "calc(100vw - 24px)")
13238 dom.SetStyle(dmRelayPop, "maxHeight", "200px")
13239 dom.SetStyle(dmRelayPop, "overflowY", "auto")
13240
13241 dmRelayOpen := false
13242 dom.AddEventListener(dmRelayBtn, "click", dom.RegisterCallback(func() {
13243 if dmRelayOpen {
13244 dom.SetStyle(dmRelayPop, "display", "none")
13245 dmRelayOpen = false
13246 updateRelaySelBtn(dmRelayBtn, false)
13247 } else {
13248 populateRelayPopover(dmRelayPop, func() {
13249 updateRelaySelBtn(dmRelayBtn, true)
13250 updateRelayWarnBadge(dmWarnBadge)
13251 })
13252 dom.SetStyle(dmRelayPop, "display", "block")
13253 dmRelayOpen = true
13254 updateRelaySelBtn(dmRelayBtn, true)
13255 }
13256 }))
13257
13258 dom.AppendChild(dmRelayWrap, dmWarnBadge)
13259 dom.AppendChild(dmRelayWrap, dmRelayBtn)
13260 dom.AppendChild(dmRelayWrap, dmRelayPop)
13261 dom.AppendChild(hdr, dmRelayWrap)
13262
13263 // Delete + ratchet button.
13264 ratchetBtn := dom.CreateElement("button")
13265 dom.SetInnerHTML(ratchetBtn, "🗑") // 🗑
13266 dom.SetAttribute(ratchetBtn, "title", "Delete messages and rotate keys")
13267 dom.SetStyle(ratchetBtn, "background", "none")
13268 dom.SetStyle(ratchetBtn, "border", "none")
13269 dom.SetStyle(ratchetBtn, "cursor", "pointer")
13270 dom.SetStyle(ratchetBtn, "fontSize", "16px")
13271 dom.SetStyle(ratchetBtn, "padding", "4px")
13272 dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() {
13273 if dom.Confirm("Delete all messages and rotate encryption keys?") {
13274 routeMsg("[\"MLS_RATCHET_DM\"," | jstr(peer) | "]")
13275 clearChildren(appSt.msgThreadMessages)
13276 }
13277 }))
13278 dom.AppendChild(hdr, ratchetBtn)
13279
13280 dom.AppendChild(appSt.msgThreadContainer, hdr)
13281
13282 // Track for live update when profile arrives.
13283 if _, cached := appSt.authorNames[peer]; !cached {
13284 appSt.pendingNotes[peer] = mxutil.Ensure(appSt.pendingNotes[peer], 1)
13285 appSt.pendingNotes[peer] = push(appSt.pendingNotes[peer], threadHdrInner)
13286 }
13287
13288 // Message area.
13289 appSt.msgThreadMessages = dom.CreateElement("div")
13290 dom.SetStyle(appSt.msgThreadMessages, "flex", "1")
13291 dom.SetStyle(appSt.msgThreadMessages, "overflowY", "auto")
13292 dom.SetStyle(appSt.msgThreadMessages, "padding", "12px 16px")
13293 dom.AppendChild(appSt.msgThreadContainer, appSt.msgThreadMessages)
13294
13295 // Compose area.
13296 compose := dom.CreateElement("div")
13297 dom.SetStyle(compose, "display", "flex")
13298 dom.SetStyle(compose, "gap", "8px")
13299 dom.SetStyle(compose, "padding", "8px 16px")
13300 dom.SetStyle(compose, "borderTop", "1px solid var(--border)")
13301 dom.SetStyle(compose, "flexShrink", "0")
13302
13303 appSt.msgComposeInput = dom.CreateElement("textarea")
13304 dom.SetAttribute(appSt.msgComposeInput, "placeholder", t("msg_placeholder"))
13305 dom.SetStyle(appSt.msgComposeInput, "flex", "1")
13306 dom.SetStyle(appSt.msgComposeInput, "padding", "8px")
13307 dom.SetStyle(appSt.msgComposeInput, "fontFamily", "'Fira Code', monospace")
13308 dom.SetStyle(appSt.msgComposeInput, "fontSize", "13px")
13309 dom.SetStyle(appSt.msgComposeInput, "background", "var(--bg)")
13310 dom.SetStyle(appSt.msgComposeInput, "border", "1px solid var(--border)")
13311 dom.SetStyle(appSt.msgComposeInput, "borderRadius", "4px")
13312 dom.SetStyle(appSt.msgComposeInput, "color", "var(--fg)")
13313 dom.SetStyle(appSt.msgComposeInput, "minHeight", "22px")
13314 dom.SetStyle(appSt.msgComposeInput, "maxHeight", "120px")
13315 dom.SetStyle(appSt.msgComposeInput, "overflowY", "auto")
13316 dom.SetAttribute(appSt.msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}")
13317 wireBlossomHandlers(appSt.msgComposeInput)
13318 wireMentionHandler(appSt.msgComposeInput)
13319 dom.AppendChild(compose, appSt.msgComposeInput)
13320
13321 sendBtn := dom.CreateElement("button")
13322 dom.SetTextContent(sendBtn, t("send"))
13323 dom.SetStyle(sendBtn, "padding", "8px 16px")
13324 dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace")
13325 dom.SetStyle(sendBtn, "fontSize", "13px")
13326 dom.SetStyle(sendBtn, "background", "var(--accent)")
13327 dom.SetStyle(sendBtn, "color", "#fff")
13328 dom.SetStyle(sendBtn, "border", "none")
13329 dom.SetStyle(sendBtn, "borderRadius", "4px")
13330 dom.SetStyle(sendBtn, "cursor", "pointer")
13331 dom.SetStyle(sendBtn, "alignSelf", "flex-end")
13332 dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() {
13333 sendMessage()
13334 }))
13335 dom.AppendChild(compose, sendBtn)
13336
13337 dom.AppendChild(appSt.msgThreadContainer, compose)
13338
13339 // Fetch profile if metadata is missing - fetchedK0 can be true while
13340 // the relay response is still in flight (showProfile resets + re-sets it).
13341 _, nameOK := appSt.authorNames[peer]
13342 if !nameOK {
13343 setFetchedK0(peer, false)
13344 profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
13345 }
13346
13347 // Request history.
13348 routeMsg("[\"MLS_DM_HISTORY\"," | jstr(peer) | ",50,0]")
13349 }
13350
13351 func closeThread() {
13352 appSt.msgCurrentConvoID = ""
13353 appSt.msgCurrentKind = ""
13354 appSt.msgView = "list"
13355
13356 dom.SetStyle(appSt.msgThreadContainer, "display", "none")
13357 dom.SetStyle(appSt.msgListContainer, "display", "block")
13358
13359 if !appSt.navPop {
13360 dom.PushState("/msg")
13361 }
13362
13363 // Refresh list.
13364 routeMsg("[\"MLS_DM_LIST\"]")
13365 }
13366
13367 func renderThreadMessages(peer, msgsJSON string) {
13368 if peer != appSt.msgCurrentConvoID {
13369 return
13370 }
13371 if msgsJSON == "" || msgsJSON == "[]" {
13372 return
13373 }
13374
13375 // Parse messages array - each element is a DMRecord object.
13376 // IDB returns newest-first; collect then reverse for oldest-at-top.
13377 type dmMsg struct {
13378 from string
13379 content string
13380 ts int64
13381 }
13382 var msgs []dmMsg
13383
13384 i := 0
13385 for i < len(msgsJSON) && msgsJSON[i] != '[' {
13386 i++
13387 }
13388 i++
13389 for i < len(msgsJSON) {
13390 for i < len(msgsJSON) && msgsJSON[i] != '{' {
13391 if msgsJSON[i] == ']' {
13392 goto done
13393 }
13394 i++
13395 }
13396 if i >= len(msgsJSON) {
13397 break
13398 }
13399 objStart := i
13400 depth := 0
13401 for i < len(msgsJSON) {
13402 if msgsJSON[i] == '{' {
13403 depth++
13404 } else if msgsJSON[i] == '}' {
13405 depth--
13406 if depth == 0 {
13407 i++
13408 break
13409 }
13410 } else if msgsJSON[i] == '"' {
13411 i++
13412 for i < len(msgsJSON) && msgsJSON[i] != '"' {
13413 if msgsJSON[i] == '\\' {
13414 i++
13415 }
13416 i++
13417 }
13418 }
13419 i++
13420 }
13421 obj := msgsJSON[objStart:i]
13422
13423 from := helpers.JsonGetString(obj, "from")
13424 content := helpers.JsonGetString(obj, "content")
13425 ts := jsonGetNum(obj, "created_at")
13426 msgs = mxutil.Ensure(msgs, 1)
13427 msgs = push(msgs, dmMsg{from, content, ts})
13428 }
13429 done:
13430
13431 // Reverse for oldest-first.
13432 for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 {
13433 msgs[l], msgs[r] = msgs[r], msgs[l]
13434 }
13435
13436 clearChildren(appSt.msgThreadMessages)
13437 for _, m := range msgs {
13438 appendBubble(m.from, m.content, m.ts)
13439 }
13440 scrollToBottom()
13441 }
13442
13443 func appendBubble(from, content string, ts int64) {
13444 isSent := from == appSt.pubhex
13445
13446 wrap := dom.CreateElement("div")
13447 dom.SetStyle(wrap, "display", "flex")
13448 dom.SetStyle(wrap, "marginBottom", "6px")
13449 if isSent {
13450 dom.SetStyle(wrap, "justifyContent", "flex-end")
13451 }
13452
13453 outer := dom.CreateElement("div")
13454 dom.SetStyle(outer, "maxWidth", "80%")
13455 if isSent {
13456 dom.SetStyle(outer, "textAlign", "right")
13457 }
13458
13459 bubble := dom.CreateElement("div")
13460 dom.SetStyle(bubble, "display", "inline-block")
13461 dom.SetStyle(bubble, "padding", "8px 12px")
13462 dom.SetStyle(bubble, "borderRadius", "12px")
13463 dom.SetStyle(bubble, "fontSize", "14px")
13464 dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
13465 dom.SetStyle(bubble, "lineHeight", "1.4")
13466 dom.SetStyle(bubble, "wordBreak", "break-word")
13467 dom.SetStyle(bubble, "textAlign", "left")
13468 if isSent {
13469 dom.SetStyle(bubble, "background", "var(--accent)")
13470 dom.SetStyle(bubble, "color", "#fff")
13471 } else {
13472 dom.SetStyle(bubble, "background", "var(--bg2)")
13473 dom.SetStyle(bubble, "color", "var(--fg)")
13474 }
13475 setHTMLWithMedia(bubble, renderMarkdown(content))
13476
13477 // Timestamp below bubble.
13478 tsEl := dom.CreateElement("div")
13479 dom.SetStyle(tsEl, "fontSize", "10px")
13480 dom.SetStyle(tsEl, "color", "var(--muted)")
13481 dom.SetStyle(tsEl, "marginTop", "2px")
13482 if isSent {
13483 dom.SetStyle(tsEl, "textAlign", "right")
13484 }
13485 dom.SetTextContent(tsEl, formatTime(ts))
13486 if isSent && ts == 0 {
13487 appSt.pendingTsEls = mxutil.Ensure(appSt.pendingTsEls, 1)
13488 appSt.pendingTsEls = push(appSt.pendingTsEls, tsEl)
13489 }
13490
13491 dom.AppendChild(outer, bubble)
13492 dom.AppendChild(outer, tsEl)
13493
13494 // Email quote-reply button for received messages with email headers.
13495 if !isSent {
13496 emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content)
13497 if isEmail {
13498 replyBtn := dom.CreateElement("div")
13499 dom.SetStyle(replyBtn, "fontSize", "11px")
13500 dom.SetStyle(replyBtn, "color", "var(--accent)")
13501 dom.SetStyle(replyBtn, "cursor", "pointer")
13502 dom.SetStyle(replyBtn, "marginTop", "2px")
13503 dom.SetTextContent(replyBtn, "\u21a9 Reply")
13504 dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
13505 quoted := quoteReply(emailFrom, emailSubject, emailBody)
13506 dom.SetProperty(appSt.msgComposeInput, "value", quoted)
13507 }))
13508 dom.AppendChild(outer, replyBtn)
13509 }
13510 }
13511
13512 dom.AppendChild(wrap, outer)
13513 dom.AppendChild(appSt.msgThreadMessages, wrap)
13514 }
13515
13516 func appendSystemBubble(text string) {
13517 wrap := dom.CreateElement("div")
13518 dom.SetStyle(wrap, "display", "flex")
13519 dom.SetStyle(wrap, "justifyContent", "center")
13520 dom.SetStyle(wrap, "marginBottom", "6px")
13521
13522 bubble := dom.CreateElement("div")
13523 dom.SetStyle(bubble, "maxWidth", "85%")
13524 dom.SetStyle(bubble, "padding", "8px 12px")
13525 dom.SetStyle(bubble, "borderRadius", "8px")
13526 dom.SetStyle(bubble, "fontSize", "12px")
13527 dom.SetStyle(bubble, "fontFamily", "monospace")
13528 dom.SetStyle(bubble, "lineHeight", "1.5")
13529 dom.SetStyle(bubble, "whiteSpace", "pre-wrap")
13530 dom.SetStyle(bubble, "background", "var(--bg2)")
13531 dom.SetStyle(bubble, "color", "var(--muted)")
13532 dom.SetStyle(bubble, "border", "1px solid var(--muted)")
13533 dom.SetTextContent(bubble, text)
13534
13535 dom.AppendChild(wrap, bubble)
13536 dom.AppendChild(appSt.msgThreadMessages, wrap)
13537 scrollToBottom()
13538 }
13539
13540 func scrollToBottom() {
13541 dom.SetProperty(appSt.msgThreadMessages, "scrollTop", "999999")
13542 }
13543
13544 func sendMessage() {
13545 content := dom.GetProperty(appSt.msgComposeInput, "value")
13546 if content == "" || appSt.msgCurrentConvoID == "" {
13547 return
13548 }
13549
13550 // Clear input.
13551 dom.SetProperty(appSt.msgComposeInput, "value", "")
13552
13553 if appSt.msgCurrentKind == "group" {
13554 routeMsg("[\"MLS_SEND_GROUP\"," | jstr(appSt.msgCurrentConvoID) | "," | jstr(content) | "]")
13555 } else {
13556 routeMsg("[\"MLS_SEND_DM\"," | jstr(appSt.msgCurrentConvoID) | "," | jstr(content) | "]")
13557 }
13558
13559 // Optimistic render (ts=0 - timestamp not shown for "just sent").
13560 appendBubble(appSt.pubhex, content, 0)
13561 scrollToBottom()
13562 }
13563
13564 func handleDMReceived(dmJSON string) {
13565 peer := helpers.JsonGetString(dmJSON, "peer")
13566 from := helpers.JsonGetString(dmJSON, "from")
13567 content := helpers.JsonGetString(dmJSON, "content")
13568 ts := jsonGetNum(dmJSON, "created_at")
13569
13570 if from != appSt.pubhex {
13571 showNotifDot()
13572 }
13573
13574 if appSt.msgView == "thread" && peer == appSt.msgCurrentConvoID {
13575 if from == appSt.pubhex {
13576 return
13577 }
13578 appendBubble(from, content, ts)
13579 scrollToBottom()
13580 } else if appSt.msgView == "list" {
13581 routeMsg("[\"MLS_DM_LIST\"]")
13582 }
13583 }
13584
13585 // --- Notifications ---
13586
13587 func subscribeNotifications() {
13588 // Musiquay base: notifications are deliberately left empty - nothing is
13589 // subscribed, so the page renders its empty state. The subscription code
13590 // stays for when it is wired up.
13591 return
13592
13593 if appSt.pubhex == "" {
13594 return
13595 }
13596 // Reset Shell-side state.
13597 appSt.oldestNotifTs = 0
13598 appSt.notifExhausted = false
13599 appSt.notifEmptyStrk = 0
13600 appSt.notifInitLoad = true
13601 notifEventsReset()
13602 appSt.notifSeen = nil
13603 // Sync state to Notif Worker, then tell it to subscribe.
13604 // Notif Worker owns the relay subscriptions via N_SUB/N_CLOSE.
13605 notif.Send(`["N_SET_PUBKEY",` | jstr(appSt.pubhex) | `]`)
13606 notif.Send(`["N_SET_RELAYS",` | readRelayURLsJSON() | `]`)
13607 notif.Send(`["N_READ_TS",` | i64toa(appSt.notifReadTs) | `]`)
13608 if len(appSt.myMutes) > 0 {
13609 notif.Send(`["N_SET_MUTES",` | buildJSONStrArr(appSt.myMutes) | `]`)
13610 }
13611 notif.Send(`["N_SUBSCRIBE"]`)
13612 }
13613
13614 func handleNotifEvent(ev *nostr.Event) {
13615 if appSt.notifSeen == nil {
13616 appSt.notifSeen = map[string]bool{}
13617 }
13618 if appSt.notifSeen[ev.ID] {
13619 return
13620 }
13621 appSt.notifSeen[ev.ID] = true
13622 // Advance cursor before filters so muted/self events don't stall pagination.
13623 if appSt.oldestNotifTs == 0 || ev.CreatedAt < appSt.oldestNotifTs {
13624 appSt.oldestNotifTs = ev.CreatedAt
13625 }
13626 self := ev.PubKey == appSt.pubhex
13627 muted := isMuted(notifSenderPK(ev))
13628 rtm := repliesToMuted(ev)
13629 if self || muted || rtm {
13630 return
13631 }
13632 if looksLikeJSONSpam(ev.Content) {
13633 return
13634 }
13635 // Queue missing referenced events for batch fetch after EOSE.
13636 if ev.Kind != 1 {
13637 refID := notifRefID(ev)
13638 if refID != "" {
13639 if _, ok := lookupEvent(refID); !ok {
13640 appSt.notifMissing = mxutil.Ensure(appSt.notifMissing, 1)
13641 appSt.notifMissing = push(appSt.notifMissing, refID)
13642 }
13643 }
13644 }
13645 // Remove empty-state placeholder on first event.
13646 if appSt.notifEmptyEl != 0 {
13647 dom.RemoveChild(appSt.notifContainer, appSt.notifEmptyEl)
13648 appSt.notifEmptyEl = 0
13649 }
13650 if appSt.notifInitLoad {
13651 appSt.notifEvents = push(appSt.notifEvents, ev)
13652 notifEventsTrim()
13653 if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(ev) {
13654 row := renderNotifRow(ev)
13655 dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore)
13656 }
13657 } else {
13658 notifEventsPrepend(ev)
13659 if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(ev) {
13660 row := renderNotifRow(ev)
13661 first := dom.FirstElementChild(appSt.notifContainer)
13662 if first != 0 {
13663 dom.InsertBefore(appSt.notifContainer, row, first)
13664 } else {
13665 dom.AppendChild(appSt.notifContainer, row)
13666 }
13667 }
13668 if ev.Kind != 1 {
13669 if appSt.notifRefTimer != 0 {
13670 dom.ClearTimeout(appSt.notifRefTimer)
13671 }
13672 appSt.notifRefTimer = dom.SetTimeout(func() {
13673 appSt.notifRefTimer = 0
13674 fetchNotifRefs()
13675 }, 500)
13676 }
13677 }
13678 maybeShowNotifDot(ev)
13679 }
13680
13681 func fetchNotifRefs() {
13682 if len(appSt.notifMissing) == 0 {
13683 return
13684 }
13685 // Dedup against eventCache and already-requested.
13686 seen := map[string]bool{}
13687 var ids []string
13688 for _, id := range appSt.notifMissing {
13689 if _, ok2 := lookupEvent(id); ok2 {
13690 continue
13691 }
13692 if seen[id] {
13693 continue
13694 }
13695 seen[id] = true
13696 ids = mxutil.Ensure(ids, 1)
13697 ids = push(ids, id)
13698 }
13699 appSt.notifMissing = nil
13700 if len(ids) == 0 {
13701 return
13702 }
13703 filter := "{\"ids\":["
13704 for i, id := range ids {
13705 if i > 0 {
13706 filter = filter | ","
13707 }
13708 filter = filter | jstr(id)
13709 }
13710 filter = filter | "]}"
13711 routeMsg(buildProxyMsg("ntf-ref", filter, readRelayURLs()))
13712 }
13713
13714 func handleNotifRefEvent(ev *nostr.Event) {
13715 setEvent(ev.ID, ev)
13716 fillEmbed(ev)
13717 }
13718
13719 func sortNotifEvents() {
13720 for i := 0; i < len(appSt.notifEvents); i++ {
13721 for j := i + 1; j < len(appSt.notifEvents); j++ {
13722 if appSt.notifEvents[j].CreatedAt > appSt.notifEvents[i].CreatedAt {
13723 appSt.notifEvents[i], appSt.notifEvents[j] = appSt.notifEvents[j], appSt.notifEvents[i]
13724 }
13725 }
13726 }
13727 }
13728
13729 func insertAtTop(row dom.Element) {
13730 first := dom.FirstChild(appSt.notifContainer)
13731 if first != 0 {
13732 dom.InsertBefore(appSt.notifContainer, row, first)
13733 } else {
13734 dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore)
13735 }
13736 }
13737
13738 func loadOlderNotifs() {
13739 appSt.notifLoading = true
13740 appSt.notifMoreGot = 0
13741 dom.ConsoleLog("[ntf-more] loading until=" | i64toa(appSt.oldestNotifTs-1))
13742 filter := "{\"kinds\":[1,6,7,9735],\"#p\":[" | jstr(appSt.pubhex) | "],\"limit\":20,\"until\":" | i64toa(appSt.oldestNotifTs-1) | "}"
13743 routeMsg(buildProxyMsg("ntf-more", filter, readRelayURLs()))
13744 if appSt.notifMoreTimer != 0 {
13745 dom.ClearTimeout(appSt.notifMoreTimer)
13746 }
13747 appSt.notifMoreTimer = dom.SetTimeout(func() {
13748 appSt.notifMoreTimer = 0
13749 if appSt.notifLoading {
13750 appSt.notifLoading = false
13751 if appSt.notifMoreGot == 0 {
13752 appSt.notifEmptyStrk++
13753 if appSt.notifEmptyStrk >= 3 {
13754 appSt.notifExhausted = true
13755 }
13756 } else {
13757 appSt.notifEmptyStrk = 0
13758 }
13759 if appSt.notifExhausted {
13760 dom.SetStyle(appSt.notifLoadMore, "display", "none")
13761 } else if appSt.oldestNotifTs > 0 {
13762 dom.SetStyle(appSt.notifLoadMore, "display", "block")
13763 }
13764 }
13765 }, 10000)
13766 }
13767
13768 func handleNotifMoreEvent(ev *nostr.Event) {
13769 if appSt.notifSeen == nil {
13770 appSt.notifSeen = map[string]bool{}
13771 }
13772 if appSt.notifSeen[ev.ID] {
13773 return
13774 }
13775 appSt.notifSeen[ev.ID] = true
13776 // Advance cursor before filters so muted/self events don't stall pagination.
13777 if appSt.oldestNotifTs == 0 || ev.CreatedAt < appSt.oldestNotifTs {
13778 appSt.oldestNotifTs = ev.CreatedAt
13779 }
13780 if ev.PubKey == appSt.pubhex {
13781 return
13782 }
13783 if isMuted(notifSenderPK(ev)) {
13784 return
13785 }
13786 if repliesToMuted(ev) {
13787 return
13788 }
13789 appSt.notifMoreGot++
13790 if ev.Kind != 1 {
13791 refID := notifRefID(ev)
13792 if refID != "" {
13793 if _, ok := lookupEvent(refID); !ok {
13794 appSt.notifMissing = mxutil.Ensure(appSt.notifMissing, 1)
13795 appSt.notifMissing = push(appSt.notifMissing, refID)
13796 }
13797 }
13798 }
13799 appSt.notifEvents = push(appSt.notifEvents, ev)
13800 notifEventsTrim()
13801 if appSt.activePage == "notifications" && appSt.notifLoadMore != 0 && notifPassesFilter(ev) {
13802 row := renderNotifRow(ev)
13803 dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore)
13804 }
13805 }
13806
13807 func showNotifDot() {
13808 if !appSt.notifUnread {
13809 appSt.notifUnread = true
13810 dom.SetStyle(appSt.notifDot, "display", "block")
13811 }
13812 }
13813
13814 func maybeShowNotifDot(ev *nostr.Event) {
13815 if ev.CreatedAt > appSt.notifReadTs && appSt.activePage != "notifications" {
13816 showNotifDot()
13817 }
13818 }
13819
13820 func notifPassesFilter(ev *nostr.Event) (ok bool) {
13821 switch appSt.notifFilter {
13822 case "mentions":
13823 return ev.Kind == 1
13824 case "reactions":
13825 return ev.Kind == 6 || ev.Kind == 7
13826 case "zaps":
13827 return ev.Kind == 9735
13828 }
13829 return true
13830 }
13831
13832 func renderNotifPage() {
13833 if appSt.notifContainer == 0 {
13834 return
13835 }
13836 appSt.notifBuilt = true
13837 sortNotifEvents()
13838 clearChildren(appSt.notifContainer)
13839
13840 shown := 0
13841 for _, ev := range appSt.notifEvents {
13842 if !notifPassesFilter(ev) {
13843 continue
13844 }
13845 shown++
13846 row := renderNotifRow(ev)
13847 dom.AppendChild(appSt.notifContainer, row)
13848 }
13849
13850 if shown == 0 {
13851 appSt.notifEmptyEl = dom.CreateElement("div")
13852 dom.SetTextContent(appSt.notifEmptyEl, t("no_notifications"))
13853 dom.SetStyle(appSt.notifEmptyEl, "color", "var(--muted)")
13854 dom.SetStyle(appSt.notifEmptyEl, "fontFamily", "'Fira Code', monospace")
13855 dom.SetStyle(appSt.notifEmptyEl, "fontSize", "13px")
13856 dom.AppendChild(appSt.notifContainer, appSt.notifEmptyEl)
13857 } else {
13858 appSt.notifEmptyEl = 0
13859 }
13860
13861 appSt.notifLoadMore = dom.CreateElement("div")
13862 dom.SetStyle(appSt.notifLoadMore, "textAlign", "center")
13863 dom.SetStyle(appSt.notifLoadMore, "padding", "16px 0")
13864 if appSt.notifExhausted || appSt.oldestNotifTs == 0 {
13865 dom.SetStyle(appSt.notifLoadMore, "display", "none")
13866 }
13867 lnk := dom.CreateElement("span")
13868 dom.SetTextContent(lnk, "load more")
13869 dom.SetStyle(lnk, "cursor", "pointer")
13870 dom.SetStyle(lnk, "color", "var(--accent)")
13871 dom.SetStyle(lnk, "fontSize", "14px")
13872 dom.SetStyle(lnk, "fontFamily", "'Fira Code', monospace")
13873 dom.AddEventListener(lnk, "click", dom.RegisterCallback(func() {
13874 if !appSt.notifLoading && !appSt.notifExhausted && appSt.oldestNotifTs > 0 {
13875 dom.SetStyle(appSt.notifLoadMore, "display", "none")
13876 loadOlderNotifs()
13877 }
13878 }))
13879 dom.AppendChild(appSt.notifLoadMore, lnk)
13880 dom.AppendChild(appSt.notifContainer, appSt.notifLoadMore)
13881 }
13882
13883 // releaseNotifRowCBs releases all Moxie callback IDs registered during notif
13884 // row rendering. Called before rebuild and on page exit to prevent the callback
13885 // table from growing without bound across filter changes and page visits.
13886 func releaseNotifRowCBs() {
13887 for i := int32(0); i < notifRowCBs.Len(); i++ {
13888 dom.ReleaseCallback(notifRowCBs.At(i))
13889 }
13890 notifRowCBs.Reset()
13891 }
13892
13893 // notifRegCB registers a callback and records its ID for later release.
13894 func notifRegCB(fn func()) (n int32) {
13895 id := dom.RegisterCallback(fn)
13896 notifRowCBs.Push(id)
13897 return id
13898 }
13899
13900 func rebuildNotifRows() {
13901 if appSt.notifContainer == 0 {
13902 return
13903 }
13904 releaseNotifRowCBs()
13905 clearChildren(appSt.notifContainer)
13906 shown := 0
13907 for _, ev := range appSt.notifEvents {
13908 if !notifPassesFilter(ev) {
13909 continue
13910 }
13911 shown++
13912 dom.AppendChild(appSt.notifContainer, renderNotifRow(ev))
13913 }
13914 if shown == 0 {
13915 appSt.notifEmptyEl = dom.CreateElement("div")
13916 dom.SetTextContent(appSt.notifEmptyEl, t("no_notifications"))
13917 dom.SetStyle(appSt.notifEmptyEl, "color", "var(--muted)")
13918 dom.SetStyle(appSt.notifEmptyEl, "fontFamily", "'Fira Code', monospace")
13919 dom.SetStyle(appSt.notifEmptyEl, "fontSize", "13px")
13920 dom.AppendChild(appSt.notifContainer, appSt.notifEmptyEl)
13921 } else {
13922 appSt.notifEmptyEl = 0
13923 }
13924 if appSt.notifLoadMore != 0 {
13925 dom.AppendChild(appSt.notifContainer, appSt.notifLoadMore)
13926 }
13927 }
13928
13929 func markNotifsRead() {
13930 if len(appSt.notifEvents) > 0 {
13931 appSt.notifReadTs = appSt.notifEvents[0].CreatedAt
13932 dom.IDBPut("settings", "notif-read-ts", i64toa(appSt.notifReadTs))
13933 // Publish watermark to relay so it syncs to other devices/sessions.
13934 saveAppSettings()
13935 }
13936 appSt.notifUnread = false
13937 dom.SetStyle(appSt.notifDot, "display", "none")
13938 el := dom.QuerySelectorFrom(appSt.notifContainer, "[data-notif-new]")
13939 for el != 0 {
13940 dom.RemoveAttribute(el, "data-notif-new")
13941 dom.SetStyle(el, "borderLeft", "none")
13942 dom.SetStyle(el, "paddingLeft", "0")
13943 dom.SetStyle(el, "fontWeight", "normal")
13944 el = dom.QuerySelectorFrom(appSt.notifContainer, "[data-notif-new]")
13945 }
13946 }
13947
13948 // zapAmount extracts the sats amount from a kind 9735 zap receipt.
13949 // Checks the "amount" tag first (millisats), then falls back to bolt11.
13950 func zapAmount(ev *nostr.Event) (s string) {
13951 for _, tag := range ev.Tags {
13952 if len(tag) >= 2 && tag[0] == "amount" {
13953 // Value is in millisats - divide by 1000.
13954 msats := parseI64(tag[1])
13955 if msats > 0 {
13956 return i64toa(msats / 1000)
13957 }
13958 }
13959 }
13960 return ""
13961 }
13962
13963 // zapSenderPK tries to extract the real sender pubkey from the zap request
13964 // embedded in the "description" tag of a kind 9735 receipt.
13965 func zapSenderPK(ev *nostr.Event) (s string) {
13966 for _, tag := range ev.Tags {
13967 if len(tag) >= 2 && tag[0] == "description" {
13968 desc := tag[1]
13969 pk := extractJSONField(desc, "pubkey")
13970 if len(pk) == 64 {
13971 return pk
13972 }
13973 }
13974 }
13975 return ""
13976 }
13977
13978 // extractJSONField finds "key":"value" in a JSON string and returns value.
13979 func extractJSONField(json string, key string) (s string) {
13980 needle := "\"" | key | "\":\""
13981 idx := -1
13982 for i := 0; i+len(needle) <= len(json); i++ {
13983 if json[i:i+len(needle)] == needle {
13984 idx = i + len(needle)
13985 break
13986 }
13987 }
13988 if idx < 0 {
13989 return ""
13990 }
13991 end := idx
13992 for end < len(json) && json[end] != '"' {
13993 end++
13994 }
13995 return json[idx:end]
13996 }
13997
13998 func notifRefID(ev *nostr.Event) (s string) {
13999 for _, tag := range ev.Tags {
14000 if len(tag) >= 2 && tag[0] == "e" {
14001 return tag[1]
14002 }
14003 }
14004 return ""
14005 }
14006
14007 func relativeTime(ts int64) (s string) {
14008 now := dom.NowSeconds()
14009 d := now - ts
14010 if d < 0 {
14011 d = 0
14012 }
14013 if d < 60 {
14014 return "now"
14015 }
14016 if d < 3600 {
14017 return i64toa(d/60) | "m"
14018 }
14019 if d < 86400 {
14020 return i64toa(d/3600) | "h"
14021 }
14022 return i64toa(d/86400) | "d"
14023 }
14024
14025 func notifSenderPK(ev *nostr.Event) (s string) {
14026 if ev.Kind == 9735 {
14027 pk := zapSenderPK(ev)
14028 if pk != "" {
14029 return pk
14030 }
14031 }
14032 return ev.PubKey
14033 }
14034
14035 func renderNotifRow(ev *nostr.Event) (e dom.Element) {
14036 senderPK := notifSenderPK(ev)
14037 unread := ev.CreatedAt > appSt.notifReadTs
14038
14039 row := dom.CreateElement("div")
14040 dom.SetStyle(row, "display", "flex")
14041 dom.SetStyle(row, "alignItems", "flex-start")
14042 dom.SetStyle(row, "gap", "6px")
14043 dom.SetStyle(row, "padding", "4px 0")
14044 dom.SetStyle(row, "cursor", "pointer")
14045 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
14046 dom.SetStyle(row, "fontSize", "12px")
14047 if unread {
14048 dom.SetStyle(row, "borderLeft", "3px solid var(--accent)")
14049 dom.SetStyle(row, "paddingLeft", "6px")
14050 dom.SetStyle(row, "fontWeight", "bold")
14051 dom.SetAttribute(row, "data-notif-new", "1")
14052 }
14053
14054 // Avatar.
14055 ava := dom.CreateElement("img")
14056 pic, picOK := appSt.authorPics[senderPK]
14057 if !picOK || pic == "" {
14058 pic = "/musiquay-logo.svg"
14059 profile.Send(`["P_RESOLVE_FORCE",` | jstr(senderPK) | `]`)
14060 // Register ava so applyAuthorProfile updates it immediately when loaded.
14061 appSt.pendingNotifAvatars[senderPK] = mxutil.Ensure(appSt.pendingNotifAvatars[senderPK], 1)
14062 appSt.pendingNotifAvatars[senderPK] = push(appSt.pendingNotifAvatars[senderPK], ava)
14063 }
14064 setMediaSrc(ava, pic)
14065 dom.SetStyle(ava, "width", "24px")
14066 dom.SetStyle(ava, "height", "24px")
14067 dom.SetStyle(ava, "borderRadius", "50%")
14068 dom.SetStyle(ava, "flexShrink", "0")
14069 dom.SetStyle(ava, "cursor", "pointer")
14070 dom.SetAttribute(ava, "onclick", "event.stopPropagation()")
14071 pk := senderPK
14072 dom.AddEventListener(ava, "click", notifRegCB(func() {
14073 showProfile(pk)
14074 }))
14075 dom.AppendChild(row, ava)
14076
14077 // Icon + action label.
14078 var icon string
14079 var label string
14080 switch ev.Kind {
14081 case 1:
14082 icon = "\xf0\x9f\x92\xac" // 💬
14083 label = ""
14084 case 6:
14085 icon = "\xf0\x9f\x94\x81" // 🔁
14086 label = ""
14087 case 7:
14088 emoji := ev.Content
14089 if emoji == "" || emoji == "+" {
14090 emoji = "\xe2\x9d\xa4\xef\xb8\x8f" // ❤️
14091 }
14092 icon = emoji
14093 label = ""
14094 case 9735:
14095 icon = "\xe2\x9a\xa1" // ⚡
14096 amt := zapAmount(ev)
14097 if amt != "" {
14098 label = amt | " sats"
14099 }
14100 }
14101
14102 iconEl := dom.CreateElement("span")
14103 dom.SetStyle(iconEl, "flexShrink", "0")
14104 dom.SetTextContent(iconEl, icon)
14105 dom.AppendChild(row, iconEl)
14106
14107 if label != "" {
14108 lblEl := dom.CreateElement("span")
14109 dom.SetStyle(lblEl, "flexShrink", "0")
14110 dom.SetStyle(lblEl, "color", "var(--accent)")
14111 dom.SetTextContent(lblEl, label)
14112 dom.AppendChild(row, lblEl)
14113 }
14114
14115 // Content preview - line-clamped to 10 lines, same font as regular notes.
14116 refID := notifRefID(ev)
14117 var previewText string
14118 if ev.Kind == 1 {
14119 previewText = ev.Content
14120 } else if refID != "" {
14121 if ref, ok := lookupEvent(refID); ok && ref != nil {
14122 previewText = ref.Content
14123 }
14124 }
14125 prev := dom.CreateElement("span")
14126 dom.SetStyle(prev, "flex", "1")
14127 dom.SetStyle(prev, "minWidth", "0")
14128 dom.SetStyle(prev, "overflow", "hidden")
14129 dom.SetStyle(prev, "color", "var(--muted)")
14130 dom.SetStyle(prev, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
14131 dom.SetStyle(prev, "fontSize", "14px")
14132 dom.SetStyle(prev, "lineHeight", "1.5")
14133 dom.SetStyle(prev, "whiteSpace", "nowrap")
14134 dom.SetStyle(prev, "textOverflow", "ellipsis")
14135 dom.SetTextContent(prev, previewText)
14136 dom.AppendChild(row, prev)
14137
14138 // Relative timestamp, right-aligned.
14139 tsEl := dom.CreateElement("span")
14140 dom.SetStyle(tsEl, "flexShrink", "0")
14141 dom.SetStyle(tsEl, "color", "var(--muted)")
14142 dom.SetStyle(tsEl, "fontSize", "11px")
14143 dom.SetTextContent(tsEl, relativeTime(ev.CreatedAt))
14144 dom.AppendChild(row, tsEl)
14145
14146 // Click navigates to the referenced event.
14147 if refID != "" {
14148 tid := refID
14149 dom.AddEventListener(row, "click", notifRegCB(func() {
14150 rootID := tid
14151 if ref, ok := lookupEvent(tid); ok && ref != nil {
14152 if r := getRootID(ref); r != "" {
14153 rootID = r
14154 }
14155 }
14156 showNoteThread(rootID, tid)
14157 }))
14158 }
14159
14160 return row
14161 }
14162
14163 // Wallet panel - implementation lives in wallet.mx.
14164
14165 // --- Email header parsing for quote-reply ---
14166
14167 // parseEmailHeaders checks if content looks like a forwarded email and extracts
14168 // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found.
14169 func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) {
14170 lines := splitLines(content)
14171 headerEnd := -1
14172 for i, line := range lines {
14173 if line == "" {
14174 headerEnd = i
14175 break
14176 }
14177 if hasPrefix(line, "From: ") {
14178 from = line[6:]
14179 } else if hasPrefix(line, "Subject: ") {
14180 subject = line[9:]
14181 } else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") {
14182 // Known header, continue
14183 } else if i == 0 {
14184 return "", "", "", false
14185 }
14186 }
14187 if from == "" && subject == "" {
14188 return "", "", "", false
14189 }
14190 if headerEnd >= 0 && headerEnd+1 < len(lines) {
14191 body = joinLines(lines[headerEnd+1:])
14192 }
14193 return from, subject, body, true
14194 }
14195
14196 func quoteReply(from, subject, body string) (s string) {
14197 out := "To: " | from | "\n"
14198 if subject != "" {
14199 if !hasPrefix(subject, "Re: ") {
14200 subject = "Re: " | subject
14201 }
14202 out = out | "Subject: " | subject | "\n"
14203 }
14204 out = out | "\n\n"
14205 if body != "" {
14206 lines := splitLines(body)
14207 for _, line := range lines {
14208 out = out | "> " | line | "\n"
14209 }
14210 }
14211 return out
14212 }
14213
14214 func splitLines(s string) (ss []string) {
14215 var lines []string
14216 for {
14217 idx := strIndex(s, "\n")
14218 if idx < 0 {
14219 lines = mxutil.Ensure(lines, 1)
14220 lines = push(lines, s)
14221 return lines
14222 }
14223 lines = mxutil.Ensure(lines, 1)
14224 lines = push(lines, s[:idx])
14225 s = s[idx+1:]
14226 }
14227 }
14228
14229 func joinLines(lines []string) (s string) {
14230 out := ""
14231 for i, line := range lines {
14232 if i > 0 {
14233 out = out | "\n"
14234 }
14235 out = out | line
14236 }
14237 return out
14238 }
14239
14240 func hasPrefix(s, prefix string) (ok bool) {
14241 return len(s) >= len(prefix) && s[:len(prefix)] == prefix
14242 }
14243
14244 // --- Markdown rendering ---
14245 // All functions use string concatenation and indexOf - no byte-level ops.
14246 // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji.
14247
14248 // renderMarkdown converts note text to safe HTML via markdown-it jsbridge.
14249 // Pre-processes nostr entities and emoji shortcodes with placeholders,
14250 // runs markdown-it for commonmark + unicode-aware URL linkification,
14251 // then restores placeholders and converts media links to embeds.
14252 func renderMarkdown(s string) (sv string) {
14253 // Extract nostr: entities before markdown-it touches the text.
14254 var nostrSlots []string
14255 s = extractNostrEntities(s, &nostrSlots)
14256
14257 // Extract emoji shortcodes.
14258 var emojiSlots []string
14259 s = extractEmojiShortcodes(s, &emojiSlots)
14260
14261 // Extract hashtags (#tag) so markdown-it doesn't strip the #.
14262 var hashSlots []string
14263 s = extractHashtags(s, &hashSlots)
14264
14265 // markdown-it: commonmark, auto-linkify (unicode-aware), breaks.
14266 s = markdown.Render(s)
14267
14268 // Restore hashtag placeholders.
14269 for i, html := range hashSlots {
14270 s = strReplace(s, "\x02HASH" | itoa(i) | "\x02", html)
14271 }
14272
14273 // Restore emoji placeholders.
14274 for i, html := range emojiSlots {
14275 s = strReplace(s, "\x02EMOJI" | itoa(i) | "\x02", html)
14276 }
14277
14278 // Restore nostr entity placeholders.
14279 for i, html := range nostrSlots {
14280 s = strReplace(s, "\x02NOSTR" | itoa(i) | "\x02", html)
14281 }
14282
14283 // Convert <a> tags pointing to image/video URLs into embeds.
14284 s = convertMediaLinks(s)
14285
14286 return s
14287 }
14288
14289 // extractNostrEntities replaces nostr:... entities with placeholders.
14290 func extractNostrEntities(s string, slots *[]string) (sv string) {
14291 out := ""
14292 for {
14293 idx := strIndex(s, "nostr:")
14294 if idx < 0 {
14295 return out | s
14296 }
14297 out = out | s[:idx]
14298 rest := s[idx+6:]
14299 end := 0
14300 for end < len(rest) {
14301 c := rest[end]
14302 if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
14303 end++
14304 } else {
14305 break
14306 }
14307 }
14308 if end < 10 {
14309 out = out | "nostr:"
14310 s = rest
14311 continue
14312 }
14313 entity := rest[:end]
14314 s = rest[end:]
14315 html := renderNostrEntity(entity)
14316 id := len(*slots)
14317 *slots = push(*slots, html)
14318 out = out | "\x02NOSTR" | itoa(id) | "\x02"
14319 }
14320 }
14321
14322 // renderNostrEntity produces the HTML for a single nostr bech32 entity.
14323 func renderNostrEntity(entity string) (s string) {
14324 if len(entity) > 6 && entity[:6] == "naddr1" {
14325 na := helpers.DecodeNaddr(entity)
14326 if na != nil {
14327 coord := helpers.NaddrCoordKey(na.Kind, na.Pubkey, na.D)
14328 for _, r := range na.Relays {
14329 appSt.embedRelayHints[coord] = mxutil.Ensure(appSt.embedRelayHints[coord], 1)
14330 appSt.embedRelayHints[coord] = push(appSt.embedRelayHints[coord], r)
14331 }
14332 if !lookupFetchedK0(na.Pubkey) {
14333 profile.Send(`["P_RESOLVE",` | jstr(na.Pubkey) | `]`)
14334 }
14335 return makeCoordEmbedPlaceholder(coord, na.Kind, na.Pubkey, na.D)
14336 }
14337 } else if len(entity) > 7 && entity[:7] == "nevent1" {
14338 nev := helpers.DecodeNevent(entity)
14339 if nev != nil {
14340 if len(nev.Relays) > 0 {
14341 appSt.embedRelayHints[nev.ID] = nev.Relays
14342 }
14343 return makeEmbedPlaceholder(nev.ID)
14344 }
14345 } else if len(entity) > 5 && entity[:5] == "note1" {
14346 id := helpers.DecodeNote(entity)
14347 if id != nil {
14348 return makeEmbedPlaceholder(helpers.HexEncode(id))
14349 }
14350 } else if len(entity) > 9 && entity[:9] == "nprofile1" {
14351 np := helpers.DecodeNprofile(entity)
14352 if np != nil {
14353 name := np.Pubkey[:8] | "..."
14354 if n, ok := appSt.authorNames[np.Pubkey]; ok && n != "" {
14355 name = n
14356 } else {
14357 profile.Send(`["P_RESOLVE",` | jstr(np.Pubkey) | `]`)
14358 }
14359 npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey))
14360 return "<a href=\"/p/" | npub | "\" data-pk=\"" | np.Pubkey | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
14361 }
14362 } else if len(entity) > 5 && entity[:5] == "npub1" {
14363 pk := helpers.DecodeNpub(entity)
14364 if pk != nil {
14365 hex := helpers.HexEncode(pk)
14366 name := hex[:8] | "..."
14367 if n, ok := appSt.authorNames[hex]; ok && n != "" {
14368 name = n
14369 } else {
14370 profile.Send(`["P_RESOLVE",` | jstr(hex) | `]`)
14371 }
14372 npub := helpers.EncodeNpub(pk)
14373 return "<a href=\"/p/" | npub | "\" data-pk=\"" | hex | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
14374 }
14375 }
14376 return "nostr:" | entity
14377 }
14378
14379 // extractEmojiShortcodes replaces :name: shortcodes with placeholders.
14380 func extractEmojiShortcodes(s string, slots *[]string) (sv string) {
14381 out := ""
14382 i := 0
14383 for i < len(s) {
14384 if s[i] == ':' {
14385 end := -1
14386 valid := true
14387 for j := i + 1; j < len(s) && j < i+40; j++ {
14388 c := s[j]
14389 if c == ':' {
14390 end = j
14391 break
14392 }
14393 if c == ' ' || c == '\n' || c == '\r' || c == '<' {
14394 valid = false
14395 break
14396 }
14397 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
14398 valid = false
14399 break
14400 }
14401 }
14402 if valid && end > i+1 {
14403 name := s[i+1 : end]
14404 code := ":" | name | ":"
14405 var html string
14406 if emoji, ok2 := appSt.emojiByName[code]; ok2 {
14407 html = emoji
14408 } else {
14409 html = "<span style=\"font-size:0;line-height:0\">" | code | "</span>"
14410 }
14411 id := len(*slots)
14412 *slots = push(*slots, html)
14413 out = out | "\x02EMOJI" | itoa(id) | "\x02"
14414 i = end + 1
14415 continue
14416 }
14417 }
14418 b := s[i]
14419 n := 1
14420 if b >= 0xF0 {
14421 n = 4
14422 } else if b >= 0xE0 {
14423 n = 3
14424 } else if b >= 0xC0 {
14425 n = 2
14426 }
14427 if i+n > len(s) {
14428 n = len(s) - i
14429 }
14430 out = out | s[i : i+n]
14431 i += n
14432 }
14433 return out
14434 }
14435
14436 // convertMediaLinks finds <a> tags with image/video URLs and replaces them.
14437 func convertMediaLinks(s string) (sv string) {
14438 out := ""
14439 for {
14440 idx := strIndex(s, "<a ")
14441 if idx < 0 {
14442 return out | s
14443 }
14444 out = out | s[:idx]
14445 s = s[idx:]
14446 // Find closing </a>.
14447 endTag := strIndex(s, "</a>")
14448 if endTag < 0 {
14449 return out | s
14450 }
14451 full := s[:endTag+4]
14452 s = s[endTag+4:]
14453 // Extract href value.
14454 hrefIdx := strIndex(full, "href=\"")
14455 if hrefIdx < 0 {
14456 out = out | full
14457 continue
14458 }
14459 hrefStart := hrefIdx + 6
14460 hrefEnd := strIndex(full[hrefStart:], "\"")
14461 if hrefEnd < 0 {
14462 out = out | full
14463 continue
14464 }
14465 href := full[hrefStart : hrefStart+hrefEnd]
14466 if len(href) > 8 && strIndex(href[8:], "http") >= 0 {
14467 out = out | full
14468 continue
14469 }
14470 if ytID := youTubeVideoID(href); ytID != "" {
14471 // Strip surrounding <p> when the YouTube link is the sole paragraph.
14472 const pOpen = "<p style=\"margin:0 0 1em 0\">"
14473 if hasSuffix(out, pOpen) {
14474 out = out[:len(out)-len(pOpen)]
14475 if hasPrefix(s, "</p>") {
14476 s = s[4:]
14477 }
14478 }
14479 thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
14480 // data-invidious-id triggers the Invidious player when clicked.
14481 // The fallback href still opens YouTube in a new tab.
14482 out = out | "<div data-invidious-id=\"" | escapeHTMLAttr(ytID) | "\" data-yt-href=\"" | escapeHTMLAttr(href) | "\" style=\"display:block;margin:4px 0;cursor:pointer\">" |
14483 "<img src=\"" | escapeHTMLAttr(mediaProxyPath(thumbURL)) | "\" referrerpolicy=\"no-referrer\" style=\"display:block;max-width:67%;max-height:50vh;object-fit:contain;border-radius:8px;margin:0 0 4px 0\" loading=\"lazy\">" |
14484 "<span data-ytid=\"" | escapeHTMLAttr(ytID) | "\" style=\"display:block;font-size:13px;color:var(--accent);word-break:break-word\"></span>" |
14485 "</div>"
14486 } else if isBandcampURL(href) {
14487 // Bandcamp link: show as a plain link with a "▶ campion" button alongside.
14488 out = out | "<div style=\"margin:4px 0\">" |
14489 "<a href=\"" | escapeHTMLAttr(href) | "\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent);word-break:break-all\">" | escapeHTMLAttr(href) | "</a>" |
14490 " <span data-campion-url=\"" | escapeHTMLAttr(href) | "\" style=\"cursor:pointer;font-size:12px;color:var(--accent);opacity:0.8;white-space:nowrap\">\xe2\x96\xb6 campion</span>" |
14491 "</div>"
14492 } else if isImageURL(href) {
14493 orig := "\" data-orig-url=\"" | escapeHTMLAttr(href) | "\""
14494 out = out | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(href)) | orig | " referrerpolicy=\"no-referrer\" style=\"display:block;max-width:100%;max-height:80vh;object-fit:contain;border-radius:8px;margin:4px 0;cursor:zoom-in\" loading=\"lazy\">"
14495 } else if isVideoURL(href) {
14496 out = out | "<video src=\"" | escapeHTMLAttr(mediaProxyPath(href)) | "\" controls preload=\"none\" referrerpolicy=\"no-referrer\" style=\"display:block;max-width:100%;max-height:80vh;border-radius:8px;margin:4px 0\"></video>"
14497 } else {
14498 out = out | full
14499 }
14500 }
14501 }
14502
14503 // truncateByLine cuts `text` at the end of the line containing position `target`.
14504 // Returns (truncated, true) if content was removed. Returns (text, false) otherwise.
14505 // This avoids mid-line cuts where only a few words would remain hidden.
14506 func truncateByLine(text string, target int32) (s string, ok bool) {
14507 if len(text) <= target {
14508 return text, false
14509 }
14510 cut := target
14511 for cut < len(text) && text[cut] != '\n' {
14512 cut++
14513 }
14514 if cut >= len(text) {
14515 return text, false
14516 }
14517 return text[:cut], true
14518 }
14519
14520 // strReplace replaces all occurrences of old with new using indexOf.
14521 func strReplace(s, old, nw string) (result string) {
14522 out := ""
14523 for {
14524 idx := strIndex(s, old)
14525 if idx < 0 {
14526 return out | s
14527 }
14528 out = out | s[:idx] | nw
14529 s = s[idx+len(old):]
14530 }
14531 }
14532
14533 func isImageURL(url string) (ok bool) {
14534 u := toLower(url)
14535 return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") ||
14536 hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg")
14537 }
14538
14539 // migratedRelayTypes is the set of message types routed to the relay-proxy
14540 // worker. Anything else still flows to the legacy SW.
14541 //
14542 var migratedRelayTypes map[string]bool
14543
14544 // swTypes are routed to the Service Worker.
14545 var swTypes map[string]bool
14546
14547 // routeMsg dispatches a JSON-array message to the appropriate worker.
14548 func routeMsg(msg string) {
14549 if len(msg) >= 4 && msg[0] == '[' && msg[1] == '"' {
14550 end := 2
14551 for end < len(msg) && msg[end] != '"' {
14552 end++
14553 }
14554 typ := msg[2:end]
14555 if migratedRelayTypes[typ] {
14556 relayproxy.Send(msg)
14557 return
14558 }
14559 if swTypes[typ] {
14560 dom.PostToSW(msg)
14561 return
14562 }
14563 dom.ConsoleLog("routeMsg: UNROUTED message type: " | typ)
14564 return
14565 }
14566 if msg == "activate-update" {
14567 dom.PostToSW(msg)
14568 return
14569 }
14570 n := len(msg)
14571 if n > 60 { n = 60 }
14572 dom.ConsoleLog("routeMsg: UNROUTED non-array message: " | msg[:n])
14573 }
14574
14575 // setHTMLWithMedia sets innerHTML and processes media links and lightboxes.
14576 // makeShowMore creates the show-more/less toggle span (initially hidden).
14577 // attachShowMore must be called after the note is in the DOM to decide
14578 // whether to reveal it based on actual rendered height.
14579 func makeShowMore(content dom.Element) (e dom.Element) {
14580 more := dom.CreateElement("span")
14581 dom.SetTextContent(more, t("show_more"))
14582 dom.SetStyle(more, "color", "var(--accent)")
14583 dom.SetStyle(more, "cursor", "pointer")
14584 dom.SetStyle(more, "fontSize", "13px")
14585 dom.SetStyle(more, "display", "none")
14586 expanded := false
14587 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
14588 expanded = !expanded
14589 if expanded {
14590 dom.SetAttribute(content, "data-expanded", "1")
14591 dom.SetStyle(content, "overflow", "visible")
14592 dom.SetStyle(content, "maxHeight", "none")
14593 dom.SetTextContent(more, t("show_less"))
14594 } else {
14595 dom.RemoveAttribute(content, "data-expanded")
14596 dom.SetStyle(content, "overflow", "hidden")
14597 dom.SetStyle(content, "maxHeight", "18em")
14598 dom.SetTextContent(more, t("show_more"))
14599 }
14600 }))
14601 return more
14602 }
14603
14604 func attachShowMore(content, more dom.Element) {
14605 c := content
14606 m := more
14607 check := func() {
14608 if dom.GetAttribute(c, "data-expanded") == "1" {
14609 return
14610 }
14611 scrollH := parseI64(dom.GetProperty(c, "scrollHeight"))
14612 clientH := parseI64(dom.GetProperty(c, "clientHeight"))
14613 if clientH > 0 && scrollH <= clientH {
14614 dom.SetStyle(m, "display", "none")
14615 } else if scrollH > clientH {
14616 dom.SetStyle(m, "display", "inline-flex")
14617 }
14618 }
14619 dom.ObserveResize(c, dom.RegisterCallback(check))
14620 dom.SetTimeout(check, 200)
14621 }
14622
14623 func isBandcampURL(url string) (ok bool) {
14624 // Matches *.bandcamp.com/* URLs.
14625 i := strIndex(url, "://")
14626 if i < 0 {
14627 return false
14628 }
14629 host := url[i+3:]
14630 slash := strIndex(host, "/")
14631 if slash >= 0 {
14632 host = host[:slash]
14633 }
14634 return hasSuffix(host, ".bandcamp.com") || host == "bandcamp.com"
14635 }
14636
14637 func setHTMLWithMedia(rootp6 dom.Element, html string) {
14638 dom.SetInnerHTML(rootp6, html)
14639 flushYtTitles(rootp6)
14640 attachImageLightboxes(rootp6)
14641 attachYTLightboxes(rootp6)
14642 attachCampionLinks(rootp6)
14643 attachHashtagLinks(rootp6)
14644 }
14645
14646 // ytTitleCache maps YouTube video ID -> fetched title.
14647
14648 // flushYtTitles finds every [data-ytid] span and fills it with the video title.
14649 func flushYtTitles(rootp5 dom.Element) {
14650 el := dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14651 for el != 0 {
14652 ytID := dom.GetAttribute(el, "data-ytid")
14653 dom.RemoveAttribute(el, "data-ytid")
14654 if ytID == "" {
14655 el = dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14656 continue
14657 }
14658 if title, ok2 := appSt.ytTitleCache[ytID]; ok2 {
14659 dom.SetTextContent(el, title)
14660 el = dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14661 continue
14662 }
14663 capturedEl := el
14664 capturedID := ytID
14665 oembedURL := "https://www.youtube.com/oembed?url=" |
14666 "https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D" | ytID |
14667 "&format=json"
14668 dom.FetchText(oembedURL, func(body string) {
14669 title := helpers.JsonGetString(body, "title")
14670 if title == "" {
14671 title = capturedID
14672 }
14673 appSt.ytTitleCache[capturedID] = title
14674 dom.SetTextContent(capturedEl, title)
14675 })
14676 el = dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14677 }
14678 }
14679
14680 // attachImageLightboxes finds every img[data-orig-url] in root, removes the
14681 // attribute (so the loop terminates), and wires a click handler that opens
14682 // the full-screen lightbox. Called from setHTMLWithMedia after innerHTML is set.
14683 func attachImageLightboxes(rootp3 dom.Element) {
14684 el := dom.QuerySelectorFrom(rootp3, "[data-orig-url]")
14685 for el != 0 {
14686 url := dom.GetAttribute(el, "data-orig-url")
14687 dom.RemoveAttribute(el, "data-orig-url")
14688 if url != "" {
14689 capturedURL := url
14690 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14691 showImageLightbox(capturedURL)
14692 }))
14693 }
14694 el = dom.QuerySelectorFrom(rootp3, "[data-orig-url]")
14695 }
14696 }
14697
14698 // attachYTLightboxes wires click handlers on [data-invidious-id] divs.
14699 // Clicking replaces the thumbnail inline with an Invidious embed iframe.
14700 func attachYTLightboxes(rootp4 dom.Element) {
14701 el := dom.QuerySelectorFrom(rootp4, "[data-invidious-id]")
14702 for el != 0 {
14703 ytID := dom.GetAttribute(el, "data-invidious-id")
14704 ytHref := dom.GetAttribute(el, "data-yt-href")
14705 dom.RemoveAttribute(el, "data-invidious-id")
14706 dom.RemoveAttribute(el, "data-yt-href")
14707 if ytID != "" {
14708 capturedEl := el
14709 capturedID := ytID
14710 capturedHref := ytHref
14711 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14712 if appSt.invidiousURL != "" {
14713 inlineInvidiousEmbed(capturedEl, capturedID)
14714 } else {
14715 dom.WindowOpen(capturedHref)
14716 }
14717 }))
14718 }
14719 el = dom.QuerySelectorFrom(rootp4, "[data-invidious-id]")
14720 }
14721 }
14722
14723 // inlineInvidiousEmbed replaces the thumbnail wrapper div's content with an
14724 // Invidious embed iframe sized at 16:9, plus a small control row below.
14725 // Stamps data-inv-ytid on the wrapper so stopInlineMedia can restore it.
14726 func inlineInvidiousEmbed(wrapper dom.Element, ytID string) {
14727 dom.ReleaseChildren(wrapper)
14728 dom.SetInnerHTML(wrapper, "")
14729 dom.SetStyle(wrapper, "cursor", "default")
14730 dom.SetAttribute(wrapper, "data-inv-ytid", ytID)
14731 dom.SetStyle(wrapper, "maxWidth", "100%")
14732
14733 iframe := dom.CreateElement("iframe")
14734 dom.SetAttribute(iframe, "src", appSt.invidiousURL | "/embed/" | ytID | "?autoplay=1")
14735 dom.SetAttribute(iframe, "allowfullscreen", "true")
14736 dom.SetAttribute(iframe, "credentialless", "")
14737 dom.SetStyle(iframe, "width", "100%")
14738 dom.SetStyle(iframe, "aspectRatio", "16/9")
14739 dom.SetStyle(iframe, "border", "none")
14740 dom.SetStyle(iframe, "display", "block")
14741 dom.SetStyle(iframe, "borderRadius", "8px")
14742 dom.SetStyle(iframe, "background", "#000")
14743 dom.AppendChild(wrapper, iframe)
14744
14745 // Control row: "open in invidious ↗" on the right.
14746 bar := dom.CreateElement("div")
14747 dom.SetStyle(bar, "display", "flex")
14748 dom.SetStyle(bar, "justifyContent", "flex-end")
14749 dom.SetStyle(bar, "marginTop", "2px")
14750
14751 link := dom.CreateElement("span")
14752 dom.SetTextContent(link, "open in invidious \xe2\x86\x97")
14753 dom.SetStyle(link, "fontSize", "10px")
14754 dom.SetStyle(link, "color", "var(--accent)")
14755 dom.SetStyle(link, "cursor", "pointer")
14756 dom.SetStyle(link, "opacity", "0.7")
14757 capturedID := ytID
14758 dom.AddEventListener(link, "click", dom.RegisterCallback(func() {
14759 dom.WindowOpen(appSt.invidiousURL | "/watch?v=" | capturedID)
14760 }))
14761 dom.AppendChild(bar, link)
14762 dom.AppendChild(wrapper, bar)
14763
14764 }
14765
14766 // attachCampionLinks wires click handlers on [data-campion-url] spans.
14767 // Opens Campion in a new tab (window.open) to avoid COEP iframe conflicts.
14768 func attachCampionLinks(rootp1 dom.Element) {
14769 el := dom.QuerySelectorFrom(rootp1, "[data-campion-url]")
14770 for el != 0 {
14771 bcURL := dom.GetAttribute(el, "data-campion-url")
14772 dom.RemoveAttribute(el, "data-campion-url")
14773 if bcURL != "" {
14774 capturedURL := bcURL
14775 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14776 if appSt.campionURL != "" {
14777 dom.WindowOpen(appSt.campionURL | "?url=" | dom.EncodeURIComponent(capturedURL))
14778 } else {
14779 dom.WindowOpen(capturedURL)
14780 }
14781 }))
14782 }
14783 el = dom.QuerySelectorFrom(rootp1, "[data-campion-url]")
14784 }
14785 }
14786
14787 // zoomStr converts a zoom level in 1/4x steps to a CSS scale() string.
14788 // Level 4 = 1.0x. Level 1 = 0.25x. Level 20 = 5.0x.
14789 func zoomStr(level int32) (s string) {
14790 whole := level / 4
14791 frac := (level % 4) * 25
14792 if frac == 0 {
14793 return itoa(whole)
14794 }
14795 fs := itoa(frac)
14796 for len(fs) > 1 && fs[len(fs)-1] == '0' {
14797 fs = fs[:len(fs)-1]
14798 }
14799 return itoa(whole) | "." | fs
14800 }
14801
14802 func zoomCtrlBtn(label string) (e dom.Element) {
14803 btn := dom.CreateElement("span")
14804 dom.SetTextContent(btn, label)
14805 dom.SetStyle(btn, "cursor", "pointer")
14806 dom.SetStyle(btn, "fontSize", "28px")
14807 dom.SetStyle(btn, "fontWeight", "bold")
14808 dom.SetStyle(btn, "color", "#fff")
14809 dom.SetStyle(btn, "userSelect", "none")
14810 dom.SetStyle(btn, "padding", "0 6px")
14811 dom.SetStyle(btn, "lineHeight", "1")
14812 return btn
14813 }
14814
14815 // showImageLightbox opens a full-screen modal for the given remote image URL.
14816 // Top-right: zoom [+] [-] [×close] bar. Bottom-left: clipboard button.
14817 // All controls are position:absolute inside the position:fixed scrim.
14818 func showImageLightbox(url string) {
14819 zoomLevel := 4 // 1.0x
14820
14821 scrim := dom.CreateElement("div")
14822 dom.SetStyle(scrim, "position", "fixed")
14823 dom.SetStyle(scrim, "inset", "0")
14824 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.88)")
14825 dom.SetStyle(scrim, "zIndex", "2000")
14826 dom.SetStyle(scrim, "display", "flex")
14827 dom.SetStyle(scrim, "alignItems", "center")
14828 dom.SetStyle(scrim, "justifyContent", "center")
14829 dom.SetStyle(scrim, "overflow", "hidden")
14830
14831 img := dom.CreateElement("img")
14832 dom.SetAttribute(img, "referrerpolicy", "no-referrer")
14833 dom.SetStyle(img, "maxWidth", "90vw")
14834 dom.SetStyle(img, "maxHeight", "90vh")
14835 dom.SetStyle(img, "objectFit", "contain")
14836 dom.SetStyle(img, "display", "block")
14837 dom.SetStyle(img, "transformOrigin", "center center")
14838 setMediaSrc(img, url)
14839
14840 // Click the image to copy it as a PNG blob to the system clipboard.
14841 capturedImg2 := img
14842 dom.AddEventListener(img, "click", dom.RegisterCallback(func() {
14843 dom.CopyImageToClipboard(capturedImg2, func(ok bool) {
14844 if ok {
14845 overlay := dom.CreateElement("div")
14846 dom.SetTextContent(overlay, "image copied")
14847 dom.SetStyle(overlay, "position", "absolute")
14848 dom.SetStyle(overlay, "top", "50%")
14849 dom.SetStyle(overlay, "left", "50%")
14850 dom.SetStyle(overlay, "transform", "translate(-50%,-50%)")
14851 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.75)")
14852 dom.SetStyle(overlay, "color", "#fff")
14853 dom.SetStyle(overlay, "padding", "12px 24px")
14854 dom.SetStyle(overlay, "borderRadius", "8px")
14855 dom.SetStyle(overlay, "fontSize", "18px")
14856 dom.SetStyle(overlay, "pointerEvents", "none")
14857 dom.AppendChild(scrim, overlay)
14858 capturedOverlay := overlay
14859 dom.SetTimeout(func() {
14860 dom.RemoveChild(scrim, capturedOverlay)
14861 }, 1200)
14862 }
14863 })
14864 }))
14865 dom.SetStyle(img, "cursor", "copy")
14866 dom.AppendChild(scrim, img)
14867
14868 // Clipboard button - absolute bottom-left, copies the remote URL.
14869 clipBtn := dom.CreateElement("div")
14870 dom.SetTextContent(clipBtn, "\xf0\x9f\x93\x8b") // 📋
14871 dom.SetStyle(clipBtn, "position", "absolute")
14872 dom.SetStyle(clipBtn, "bottom", "20px")
14873 dom.SetStyle(clipBtn, "left", "20px")
14874 dom.SetStyle(clipBtn, "cursor", "pointer")
14875 dom.SetStyle(clipBtn, "fontSize", "32px")
14876 dom.SetStyle(clipBtn, "background", "rgba(0,0,0,0.6)")
14877 dom.SetStyle(clipBtn, "borderRadius", "10px")
14878 dom.SetStyle(clipBtn, "padding", "8px 12px")
14879 dom.SetStyle(clipBtn, "lineHeight", "1")
14880 dom.SetStyle(clipBtn, "userSelect", "none")
14881 capturedURL := url
14882 capturedClip := clipBtn
14883 dom.AddEventListener(clipBtn, "click", dom.RegisterCallback(func() {
14884 dom.WritePrimarySelection(capturedURL)
14885 dom.WriteClipboard(capturedURL, func(ok bool) {
14886 if ok {
14887 dom.SetTextContent(capturedClip, "\xe2\x9c\x93") // ✓
14888 dom.SetTimeout(func() {
14889 dom.SetTextContent(capturedClip, "\xf0\x9f\x93\x8b")
14890 }, 1500)
14891 }
14892 })
14893 }))
14894 dom.AppendChild(scrim, clipBtn)
14895
14896 // Zoom + close bar - absolute top-right. × closes the lightbox.
14897 zoomBar := dom.CreateElement("div")
14898 dom.SetStyle(zoomBar, "position", "absolute")
14899 dom.SetStyle(zoomBar, "top", "12px")
14900 dom.SetStyle(zoomBar, "right", "16px")
14901 dom.SetStyle(zoomBar, "display", "flex")
14902 dom.SetStyle(zoomBar, "gap", "4px")
14903 dom.SetStyle(zoomBar, "alignItems", "center")
14904 dom.SetStyle(zoomBar, "background", "rgba(0,0,0,0.6)")
14905 dom.SetStyle(zoomBar, "borderRadius", "10px")
14906 dom.SetStyle(zoomBar, "padding", "6px 12px")
14907
14908 capturedImg := img
14909 applyZoom := func() {
14910 dom.SetStyle(capturedImg, "transform", "scale(" | zoomStr(zoomLevel) | ")")
14911 }
14912
14913 plusBtn := zoomCtrlBtn("+")
14914 dom.AddEventListener(plusBtn, "click", dom.RegisterCallback(func() {
14915 if zoomLevel < 20 {
14916 zoomLevel++
14917 }
14918 applyZoom()
14919 }))
14920 dom.AppendChild(zoomBar, plusBtn)
14921
14922 minusBtn := zoomCtrlBtn("-")
14923 dom.AddEventListener(minusBtn, "click", dom.RegisterCallback(func() {
14924 if zoomLevel > 1 {
14925 zoomLevel--
14926 }
14927 applyZoom()
14928 }))
14929 dom.AppendChild(zoomBar, minusBtn)
14930
14931 // × closes (replaces separate close button).
14932 closeBtn := zoomCtrlBtn("\xc3\x97") // ×
14933 closeFn := dom.RegisterCallback(func() {
14934 dom.RemoveChild(dom.Body(), scrim)
14935 })
14936 dom.AddEventListener(closeBtn, "click", closeFn)
14937 dom.AppendChild(zoomBar, closeBtn)
14938
14939 dom.AppendChild(scrim, zoomBar)
14940 dom.AddSelfEventListener(scrim, "click", closeFn)
14941 dom.AppendChild(dom.Body(), scrim)
14942 }
14943
14944 // noteClipText formats a single event as a markdown section for clipboard.
14945 // Format: ### name (npub) - timestamp\n\ncontent\n\n---\n\n
14946 func noteClipText(ev *nostr.Event) (s string) {
14947 name := appSt.authorNames[ev.PubKey]
14948 npub := helpers.EncodeNpub(helpers.HexDecode(ev.PubKey))
14949 if name == "" {
14950 if len(npub) > 20 {
14951 name = npub[:12] | "..." | npub[len(npub)-4:]
14952 } else {
14953 name = npub
14954 }
14955 }
14956 ts := formatTime(ev.CreatedAt)
14957 return "### " | name | " (" | npub | ") - " | ts | "\n\n" | ev.Content | "\n\n---\n\n"
14958 }
14959
14960 // collectBranch builds the children map from threadEvents and collects the
14961 // event at startID plus all its descendants (DFS, chronological). Used by
14962 // per-note copy buttons in thread view.
14963 func collectBranch(startID string) (ss []*nostr.Event) {
14964 ch := map[string][]string{}
14965 for id, ev := range appSt.threadEvents {
14966 parentID := getReplyID(ev)
14967 if parentID == "" || parentID == id {
14968 continue
14969 }
14970 if appSt.threadEvents[parentID] == nil && parentID != appSt.threadRootID {
14971 if referencesEvent(ev, appSt.threadRootID) {
14972 parentID = appSt.threadRootID
14973 }
14974 }
14975 ch[parentID] = mxutil.Ensure(ch[parentID], 1)
14976 ch[parentID] = push(ch[parentID], id)
14977 }
14978 for pid := range ch {
14979 sortByCreatedAt(ch[pid])
14980 }
14981 var result []*nostr.Event
14982 var dfs func(id string)
14983 dfs = func(id string) {
14984 ev, ok := appSt.threadEvents[id]
14985 if !ok {
14986 return
14987 }
14988 result = mxutil.Ensure(result, 1)
14989 result = push(result, ev)
14990 for _, childID := range ch[id] {
14991 dfs(childID)
14992 }
14993 }
14994 dfs(startID)
14995 return result
14996 }
14997
14998 // noteCopyBtn returns a 📋 span styled like the other header micro-buttons.
14999 func noteCopyBtn() (e dom.Element) {
15000 btn := dom.CreateElement("span")
15001 dom.SetTextContent(btn, "\xf0\x9f\x93\x8b") // 📋
15002 dom.SetStyle(btn, "fontSize", "10px")
15003 dom.SetStyle(btn, "cursor", "pointer")
15004 dom.SetStyle(btn, "opacity", "0.6")
15005 dom.SetStyle(btn, "marginRight", "4px")
15006 dom.SetStyle(btn, "userSelect", "none")
15007 capturedBtn := btn
15008 dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
15009 dom.SetStyle(capturedBtn, "opacity", "1")
15010 }))
15011 dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
15012 dom.SetStyle(capturedBtn, "opacity", "0.6")
15013 }))
15014 return btn
15015 }
15016
15017 // noteCopyFeedback briefly shows ✓ on the copy button after a successful write.
15018 func noteCopyFeedback(btn dom.Element) {
15019 dom.SetTextContent(btn, "\xe2\x9c\x93") // ✓
15020 dom.SetStyle(btn, "opacity", "1")
15021 capturedBtn := btn
15022 dom.SetTimeout(func() {
15023 dom.SetTextContent(capturedBtn, "\xf0\x9f\x93\x8b")
15024 dom.SetStyle(capturedBtn, "opacity", "0.6")
15025 }, 1200)
15026 }
15027
15028 // prettyNoteJSON returns the event serialised as indented JSON with tabs.
15029 func prettyNoteJSON(ev *nostr.Event) (s string) {
15030 s = "{\n"
15031 s = s | "\t\"id\": \"" | ev.ID | "\",\n"
15032 s = s | "\t\"pubkey\": \"" | ev.PubKey | "\",\n"
15033 s = s | "\t\"created_at\": " | i64toa(ev.CreatedAt) | ",\n"
15034 s = s | "\t\"kind\": " | itoa(int32(ev.Kind)) | ",\n"
15035 s = s | "\t\"tags\": [\n"
15036 for i, tag := range ev.Tags {
15037 s = s | "\t\t["
15038 for j, v := range tag {
15039 if j > 0 {
15040 s = s | ", "
15041 }
15042 s = s | "\"" | jsonEsc(v) | "\""
15043 }
15044 s = s | "]"
15045 if i < len(ev.Tags)-1 {
15046 s = s | ","
15047 }
15048 s = s | "\n"
15049 }
15050 s = s | "\t],\n"
15051 s = s | "\t\"content\": \"" | jsonEsc(ev.Content) | "\",\n"
15052 s = s | "\t\"sig\": \"" | ev.Sig | "\"\n"
15053 s = s | "}"
15054 return s
15055 }
15056
15057 // setMediaSrc sets elem's src to the /proxy/... same-origin path for the URL.
15058 // The browser's HTTP cache (Cache-Control: public, max-age=86400 from the
15059 // relay) serves cached images synchronously without any worker round-trip.
15060 func setMediaSrc(elem dom.Element, url string) {
15061 if len(url) == 0 {
15062 return
15063 }
15064 dom.SetAttribute(elem, "src", mediaProxyPath(url))
15065 }
15066
15067 // mediaProxyPath converts an absolute URL to a same-origin /proxy/ path.
15068 // Same-origin and data: URLs are returned unchanged.
15069 func mediaProxyPath(url string) (s string) {
15070 if len(url) == 0 || url[0] == '/' {
15071 return url
15072 }
15073 if len(url) >= 5 && url[:5] == "data:" {
15074 return url
15075 }
15076 if len(url) >= 8 && url[:8] == "https://" {
15077 return "/proxy/" | url[8:]
15078 }
15079 if len(url) >= 7 && url[:7] == "http://" {
15080 return "/proxy/" | url[7:]
15081 }
15082 return url
15083 }
15084
15085 func isVideoURL(url string) (ok bool) {
15086 u := toLower(url)
15087 return hasSuffix(u, ".mp4") || hasSuffix(u, ".webm") || hasSuffix(u, ".mov") ||
15088 hasSuffix(u, ".ogg") || hasSuffix(u, ".ogv")
15089 }
15090
15091 // youTubeVideoID extracts the YouTube video ID from a URL.
15092 // Returns "" if the URL is not a YouTube link.
15093 // Handles youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID, youtube.com/embed/ID.
15094 func youTubeVideoID(url string) (s string) {
15095 u := toLower(url)
15096 // youtu.be/ID
15097 if hasPrefix(u, "https://youtu.be/") || hasPrefix(u, "http://youtu.be/") {
15098 rest := url[strIndex(url, "youtu.be/")+9:]
15099 return youTubeExtractID(rest)
15100 }
15101 // youtube.com variants
15102 if strIndex(u, "youtube.com/") < 0 {
15103 return ""
15104 }
15105 // /shorts/ID
15106 si := strIndex(u, "/shorts/")
15107 if si >= 0 {
15108 rest := url[si+8:]
15109 return youTubeExtractID(rest)
15110 }
15111 // /embed/ID
15112 ei := strIndex(u, "/embed/")
15113 if ei >= 0 {
15114 rest := url[ei+7:]
15115 return youTubeExtractID(rest)
15116 }
15117 // ?v=ID or &v=ID
15118 vi := strIndex(u, "v=")
15119 if vi >= 0 {
15120 rest := url[vi+2:]
15121 return youTubeExtractID(rest)
15122 }
15123 return ""
15124 }
15125
15126 // youTubeExtractID returns the video ID: the segment up to the first '&', '?', '#', '/' or end.
15127 func youTubeExtractID(s string) (sv string) {
15128 end := len(s)
15129 for i := 0; i < len(s); i++ {
15130 c := s[i]
15131 if c == '&' || c == '?' || c == '#' || c == '/' {
15132 end = i
15133 break
15134 }
15135 }
15136 id := s[:end]
15137 if len(id) < 5 || len(id) > 20 {
15138 return ""
15139 }
15140 return id
15141 }
15142
15143 func escapeHTMLAttr(s string) (sv string) {
15144 out := ""
15145 start := 0
15146 for i := 0; i < len(s); i++ {
15147 var esc string
15148 switch s[i] {
15149 case '"':
15150 esc = """
15151 case '\'':
15152 esc = "'"
15153 case '&':
15154 esc = "&"
15155 case '<':
15156 esc = "<"
15157 case '>':
15158 esc = ">"
15159 default:
15160 continue
15161 }
15162 out = out | s[start:i] | esc
15163 start = i + 1
15164 }
15165 return out | s[start:]
15166 }
15167
15168 func escapeHTML(s string) (sv string) {
15169 out := ""
15170 start := 0
15171 for i := 0; i < len(s); i++ {
15172 var esc string
15173 switch s[i] {
15174 case '&':
15175 esc = "&"
15176 case '<':
15177 esc = "<"
15178 case '>':
15179 esc = ">"
15180 default:
15181 continue
15182 }
15183 out = out | s[start:i] | esc
15184 start = i + 1
15185 }
15186 return out | s[start:]
15187 }
15188
15189 func hasSuffix(s, suffix string) (ok bool) {
15190 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
15191 }
15192
15193
15194 func updateInlineProfileLinks(pk string) {
15195 name := appSt.authorNames[pk]
15196 if name == "" {
15197 return
15198 }
15199 // Find and update all <a data-pk="..."> links for this pubkey.
15200 // Loop with QuerySelector since there's no QuerySelectorAll.
15201 for {
15202 el := dom.QuerySelector("a[data-pk=\"" | pk | "\"]")
15203 if el == 0 {
15204 break
15205 }
15206 dom.SetTextContent(el, name)
15207 // Mark as done so we don't match it again.
15208 dom.SetAttribute(el, "data-pk", "")
15209 }
15210 }
15211
15212 func embedEOSECleanup(subID string) {
15213 ids, ok := appSt.embedSubIDs[subID]
15214 if !ok {
15215 return
15216 }
15217 delete(appSt.embedSubIDs, subID)
15218 for _, hexID := range ids {
15219 if _, still := appSt.embedCallbacks[hexID]; !still {
15220 continue
15221 }
15222 // Check if any other sub is still pending for this ID.
15223 otherPending := false
15224 for _, otherIDs := range appSt.embedSubIDs {
15225 for _, oid := range otherIDs {
15226 if oid == hexID {
15227 otherPending = true
15228 break
15229 }
15230 }
15231 if otherPending {
15232 break
15233 }
15234 }
15235 if otherPending {
15236 continue
15237 }
15238 // No subs left - mark as not found.
15239 delete(appSt.embedRequested, hexID)
15240 elemIDs := appSt.embedCallbacks[hexID]
15241 delete(appSt.embedCallbacks, hexID)
15242 for _, elemID := range elemIDs {
15243 el := dom.GetElementById(elemID)
15244 if el == 0 {
15245 continue
15246 }
15247 dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
15248 dom.SetStyle(el, "opacity", "0.5")
15249 }
15250 }
15251 }
15252
15253 // fillCoordEmbed matches an arriving event to its coord placeholders and fills them.
15254 func fillCoordEmbed(ev *nostr.Event) {
15255 // Build the coord key from the event's kind, pubkey, and d-tag.
15256 d := ""
15257 for _, tag := range ev.Tags {
15258 if len(tag) >= 2 && tag[0] == "d" {
15259 d = tag[1]
15260 break
15261 }
15262 }
15263 coord := helpers.NaddrCoordKey(uint32(ev.Kind), ev.PubKey, d)
15264 elemIDs, ok := appSt.embedCoordCBs[coord]
15265 if !ok {
15266 // Also check old generation.
15267 elemIDs, ok = appSt.embedCoordCBsOld[coord]
15268 if !ok {
15269 return
15270 }
15271 delete(appSt.embedCoordCBsOld, coord)
15272 } else {
15273 delete(appSt.embedCoordCBs, coord)
15274 }
15275
15276 name := ev.PubKey[:8] | "..."
15277 if n, ok2 := appSt.authorNames[ev.PubKey]; ok2 && n != "" {
15278 name = n
15279 }
15280 // For kind 30023, use the title tag; otherwise truncate content.
15281 title := ""
15282 if ev.Kind == uint32(30023) {
15283 for _, tag := range ev.Tags {
15284 if len(tag) >= 2 && tag[0] == "title" {
15285 title = tag[1]
15286 break
15287 }
15288 }
15289 }
15290 text := title
15291 if text == "" {
15292 text = ev.Content
15293 nl := -1
15294 for i := 0; i < len(text); i++ {
15295 if text[i] == '\n' {
15296 nl = i
15297 break
15298 }
15299 }
15300 if nl >= 0 {
15301 text = text[:nl]
15302 }
15303 }
15304
15305 headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
15306 if pic, ok2 := appSt.authorPics[ev.PubKey]; ok2 && pic != "" {
15307 headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
15308 "style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
15309 }
15310 headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
15311
15312 capturedEv := ev
15313 for _, elemID := range elemIDs {
15314 el := dom.GetElementById(elemID)
15315 if el == 0 {
15316 continue
15317 }
15318 clearChildren(el)
15319 dom.SetStyle(el, "opacity", "1")
15320 dom.SetStyle(el, "cursor", "pointer")
15321
15322 hdr := dom.CreateElement("div")
15323 dom.SetInnerHTML(hdr, headerHTML)
15324 dom.AppendChild(el, hdr)
15325
15326 body := dom.CreateElement("div")
15327 dom.SetStyle(body, "fontSize", "13px")
15328 dom.SetStyle(body, "lineHeight", "1.4")
15329 dom.SetInnerHTML(body, renderEmbedText(text))
15330 dom.AppendChild(el, body)
15331
15332 thisEl := el
15333 dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
15334 showNoteThread(capturedEv.ID, capturedEv.ID)
15335 }))
15336 }
15337 }
15338
15339 // embedCoordEOSECleanup marks unfound coord embeds as not found.
15340 func embedCoordEOSECleanup(subID string) {
15341 coord, ok := appSt.embedCoordSubIDs[subID]
15342 if !ok {
15343 return
15344 }
15345 delete(appSt.embedCoordSubIDs, subID)
15346 // Check if any other coord sub is still pending for this coord.
15347 for _, other := range appSt.embedCoordSubIDs {
15348 if other == coord {
15349 return
15350 }
15351 }
15352 elemIDs, ok := appSt.embedCoordCBs[coord]
15353 if !ok {
15354 return
15355 }
15356 delete(appSt.embedCoordCBs, coord)
15357 for _, elemID := range elemIDs {
15358 el := dom.GetElementById(elemID)
15359 if el == 0 {
15360 continue
15361 }
15362 dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
15363 dom.SetStyle(el, "opacity", "0.5")
15364 }
15365 }
15366
15367 func makeCoordEmbedPlaceholder(coord string, kind uint32, pubkey, d string) (s string) {
15368 appSt.embedCounter++
15369 elemID := "emb-" | itoa(appSt.embedCounter)
15370 appSt.embedCoordCBs[coord] = mxutil.Ensure(appSt.embedCoordCBs[coord], 1)
15371 appSt.embedCoordCBs[coord] = push(appSt.embedCoordCBs[coord], elemID)
15372 label := "Loading..."
15373 if kind == uint32(30023) {
15374 label = "Loading article..."
15375 }
15376 return "<div id=\"" | elemID | "\" data-coord=\"" | escapeHTMLAttr(coord) | "\" " |
15377 "style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
15378 "background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
15379 "<em>" | label | "</em></div>"
15380 }
15381
15382 func makeEmbedPlaceholder(hexID string) (s string) {
15383 appSt.embedCounter++
15384 elemID := "emb-" | itoa(appSt.embedCounter)
15385 appSt.embedCallbacks[hexID] = mxutil.Ensure(appSt.embedCallbacks[hexID], 1)
15386 appSt.embedCallbacks[hexID] = push(appSt.embedCallbacks[hexID], elemID)
15387 return "<div id=\"" | elemID | "\" data-eid=\"" | hexID | "\" " |
15388 "style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
15389 "background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
15390 "<em>Loading note...</em></div>"
15391 }
15392
15393 func resolveEmbeds() {
15394 if len(appSt.embedCallbacks) == 0 && len(appSt.embedCoordCBs) == 0 {
15395 return
15396 }
15397 if len(appSt.embedCallbacks) == 0 {
15398 for coord := range appSt.embedCoordCBs {
15399 resolveCoordEmbed(coord)
15400 }
15401 return
15402 }
15403 // Fill any embeds already in cache before requesting.
15404 for hexID := range appSt.embedCallbacks {
15405 if ev, ok := lookupEvent(hexID); ok && ev != nil {
15406 fillEmbed(ev)
15407 }
15408 }
15409 if len(appSt.embedCallbacks) == 0 {
15410 return
15411 }
15412 // Only request IDs we haven't already sent to PROXY.
15413 // If embedRequested is true but the event isn't in cache, clear the
15414 // flag so we re-fetch - the event may have been evicted by rotation.
15415 var ids []string
15416 for hexID := range appSt.embedCallbacks {
15417 if appSt.embedRequested[hexID] {
15418 if _, ok2 := lookupEvent(hexID); ok2 {
15419 continue
15420 }
15421 delete(appSt.embedRequested, hexID)
15422 }
15423 ids = mxutil.Ensure(ids, 1)
15424 ids = push(ids, hexID)
15425 appSt.embedRequested[hexID] = true
15426 }
15427 if len(ids) == 0 {
15428 return
15429 }
15430 filter := "{\"ids\":["
15431 for i, id := range ids {
15432 if i > 0 {
15433 filter = filter | ","
15434 }
15435 filter = filter | jstr(id)
15436 }
15437 filter = filter | "]}"
15438
15439 // REQ checks the local IDB cache.
15440 appSt.embedCounter++
15441 reqID := "emb-r-" | itoa(appSt.embedCounter)
15442 appSt.embedSubIDs[reqID] = ids
15443 routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
15444
15445 // PROXY fetches from relays - combine user relays + nevent relay hints.
15446 seen := map[string]bool{}
15447 var urls []string
15448 for _, u := range readRelayURLs() {
15449 if !seen[u] {
15450 seen[u] = true
15451 urls = mxutil.Ensure(urls, 1)
15452 urls = push(urls, u)
15453 }
15454 }
15455 for _, id := range ids {
15456 hints := appSt.embedRelayHints[id]
15457 if len(hints) == 0 {
15458 hints = appSt.embedRelayHintsOld[id]
15459 delete(appSt.embedRelayHintsOld, id)
15460 } else {
15461 delete(appSt.embedRelayHints, id)
15462 }
15463 for _, u := range hints {
15464 if !seen[u] {
15465 seen[u] = true
15466 urls = mxutil.Ensure(urls, 1)
15467 urls = push(urls, u)
15468 }
15469 }
15470 }
15471 if len(urls) > 0 {
15472 proxyID := "emb-p-" | itoa(appSt.embedCounter)
15473 appSt.embedSubIDs[proxyID] = ids
15474 routeMsg(buildProxyMsg(proxyID, filter, urls))
15475 }
15476
15477 // Coord embeds: one PROXY sub per pending coord.
15478 for coord := range appSt.embedCoordCBs {
15479 resolveCoordEmbed(coord)
15480 }
15481 }
15482
15483 // resolveCoordEmbed fires a PROXY fetch for one naddr coordinate.
15484 func resolveCoordEmbed(coord string) {
15485 // Parse "kind:pubkey:d" - split on first two colons.
15486 ci := -1
15487 for i := 0; i < len(coord); i++ {
15488 if coord[i] == ':' {
15489 ci = i
15490 break
15491 }
15492 }
15493 if ci < 0 {
15494 return
15495 }
15496 kindStr := coord[:ci]
15497 rest := coord[ci+1:]
15498 pi := -1
15499 for i := 0; i < len(rest); i++ {
15500 if rest[i] == ':' {
15501 pi = i
15502 break
15503 }
15504 }
15505 if pi < 0 {
15506 return
15507 }
15508 pubkey := rest[:pi]
15509 d := rest[pi+1:]
15510
15511 filter := "{\"kinds\":[" | kindStr | "],\"authors\":[" | jstr(pubkey) | "],\"#d\":[" | jstr(d) | "]}"
15512
15513 // Relay hints from the naddr TLV.
15514 coordRelays := appSt.embedRelayHints[coord]
15515 if len(coordRelays) > 0 {
15516 delete(appSt.embedRelayHints, coord)
15517 } else {
15518 coordRelays = appSt.embedRelayHintsOld[coord]
15519 delete(appSt.embedRelayHintsOld, coord)
15520 }
15521 var urls []string
15522 seen := map[string]bool{}
15523 for _, u := range readRelayURLs() {
15524 if !seen[u] {
15525 seen[u] = true
15526 urls = mxutil.Ensure(urls, 1)
15527 urls = push(urls, u)
15528 }
15529 }
15530 for _, u := range coordRelays {
15531 if !seen[u] {
15532 seen[u] = true
15533 urls = mxutil.Ensure(urls, 1)
15534 urls = push(urls, u)
15535 }
15536 }
15537
15538 appSt.embedCounter++
15539 if len(urls) > 0 {
15540 proxyID := "emb-c-" | itoa(appSt.embedCounter)
15541 appSt.embedCoordSubIDs[proxyID] = coord
15542 routeMsg(buildProxyMsg(proxyID, filter, urls))
15543 }
15544 reqID := "emb-cr-" | itoa(appSt.embedCounter)
15545 appSt.embedCoordSubIDs[reqID] = coord
15546 routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
15547 }
15548
15549 func renderEmbedText(s string) (sv string) {
15550 s = markdown.Render(s)
15551 s = convertMediaLinks(s)
15552 return s
15553 }
15554
15555 func fillEmbed(ev *nostr.Event) {
15556 elemIDs, okw1 := appSt.embedCallbacks[ev.ID]
15557 if !okw1 {
15558 return
15559 }
15560 delete(appSt.embedCallbacks, ev.ID)
15561 appSt.embedCacheCount++
15562 if appSt.embedCacheCount > rotateEmbedMax {
15563 rotateEmbedCaches()
15564 }
15565
15566 name := ev.PubKey[:8] | "..."
15567 if n, okv3 := appSt.authorNames[ev.PubKey]; okv3 && n != "" {
15568 name = n
15569 }
15570 headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
15571 if pic, okv2 := appSt.authorPics[ev.PubKey]; okv2 && pic != "" {
15572 headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
15573 "style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
15574 }
15575 headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
15576
15577 firstNL := -1
15578 for i := 0; i < len(ev.Content); i++ {
15579 if ev.Content[i] == '\n' {
15580 firstNL = i
15581 break
15582 }
15583 }
15584 truncated := firstNL >= 0 && firstNL < len(ev.Content)-1
15585 text := ev.Content
15586 if truncated {
15587 text = text[:firstNL]
15588 }
15589
15590 embedEvID := ev.ID
15591 embedRootID := getRootID(ev)
15592 if embedRootID == "" {
15593 embedRootID = embedEvID
15594 }
15595
15596 for _, elemID := range elemIDs {
15597 el := dom.GetElementById(elemID)
15598 if el == 0 {
15599 continue
15600 }
15601 clearChildren(el)
15602 dom.SetStyle(el, "opacity", "1")
15603 dom.SetStyle(el, "cursor", "pointer")
15604
15605 hdr := dom.CreateElement("div")
15606 dom.SetInnerHTML(hdr, headerHTML)
15607 dom.AppendChild(el, hdr)
15608
15609 body := dom.CreateElement("div")
15610 dom.SetStyle(body, "fontSize", "13px")
15611 dom.SetStyle(body, "lineHeight", "1.4")
15612 dom.SetInnerHTML(body, renderEmbedText(text))
15613 dom.AppendChild(el, body)
15614
15615 thisEl := el
15616 dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
15617 showNoteThread(embedRootID, embedEvID)
15618 }))
15619 }
15620
15621 if _, okv1 := appSt.authorNames[ev.PubKey]; !okv1 {
15622 if !lookupFetchedK0(ev.PubKey) {
15623 profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
15624 }
15625 }
15626 }
15627
15628 // jsonGetNum extracts a numeric value for a given key from a JSON object.
15629 func jsonGetNum(s, key string) (n int64) {
15630 needle := "\"" | key | "\":"
15631 idx := strIndex(s, needle)
15632 if idx < 0 {
15633 return 0
15634 }
15635 idx += len(needle)
15636 // Skip whitespace.
15637 for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') {
15638 idx++
15639 }
15640 if idx >= len(s) {
15641 return 0
15642 }
15643 for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' {
15644 n = n*10 + int64(s[idx]-'0')
15645 idx++
15646 }
15647 return n
15648 }
15649
15650 // jsonEsc escapes a string for embedding in a JSON value.
15651 // defaultServiceURL derives a service subdomain URL from the current page origin.
15652 // wss://smesh.lol + "invidious" → https://invidious.smesh.lol
15653 func defaultServiceURL(sub string) (s string) {
15654 local := localRelayURL()
15655 var host string
15656 if len(local) >= 6 && local[:6] == "wss://" {
15657 host = local[6:]
15658 } else if len(local) >= 5 && local[:5] == "ws://" {
15659 host = local[5:]
15660 } else {
15661 return ""
15662 }
15663 // Only derive for non-localhost origins.
15664 if host == "localhost" || len(host) > 9 && host[:9] == "127.0.0.1" {
15665 return ""
15666 }
15667 return "https://" | sub | "." | host
15668 }
15669
15670 // defaultBlossomURL derives the blossom server URL from the current page origin.
15671 // wss://smesh.lol → https://smesh.lol/blossom
15672 // --- Search ---
15673
15674 func activateSearchBar(btn dom.Element) {
15675 if !appSt.searchBarActive {
15676 appSt.searchReturnPage = appSt.activePage // remember where to return on manual close
15677 }
15678 appSt.searchBarActive = true
15679 dom.SetStyle(btn, "background", "var(--accent)")
15680 dom.SetStyle(btn, "color", "#fff")
15681 dom.SetStyle(btn, "borderRadius", "4px")
15682 dom.SetStyle(appSt.feedSelectEl, "display", "none")
15683 dom.SetStyle(appSt.pageTitleEl, "display", "none")
15684 dom.SetStyle(appSt.notifBarGroup, "display", "none")
15685 dom.SetStyle(appSt.topBarRight, "display", "none")
15686 dom.SetStyle(appSt.searchInputEl, "display", "block")
15687 dom.SetStyle(appSt.searchInputEl, "flex", "1")
15688 dom.SetStyle(appSt.searchSubmitBtn, "display", "block")
15689 dom.Focus(appSt.searchInputEl)
15690 }
15691
15692 // deactivateSearchBar restores the bar appearance only.
15693 // Callers are responsible for any page navigation.
15694 func deactivateSearchBar(btn dom.Element) {
15695 appSt.searchBarActive = false
15696 dom.SetStyle(btn, "background", "transparent")
15697 dom.SetStyle(btn, "color", "var(--muted)")
15698 dom.SetStyle(btn, "borderRadius", "4px")
15699 dom.SetStyle(appSt.topBarRight, "display", "flex")
15700 dom.SetStyle(appSt.searchInputEl, "display", "none")
15701 dom.SetStyle(appSt.searchInputEl, "flex", "")
15702 dom.SetStyle(appSt.searchSubmitBtn, "display", "none")
15703 }
15704
15705 // closeSearch is called when the user explicitly closes search (clicks 🔍 again).
15706 // Deactivates the bar and returns to the previous page.
15707 func closeSearch(btn dom.Element) {
15708 deactivateSearchBar(btn)
15709 ret := appSt.searchReturnPage
15710 if ret == "" || ret == "search" {
15711 ret = "feed"
15712 }
15713 appSt.searchReturnPage = ""
15714 appSt.activePage = "" // force switchPage to run
15715 switchPage(ret)
15716 }
15717
15718 // executeSearch submits a search query: #tag searches by t-tag, other text
15719 // runs NIP-50 full-text search. Switches to the search page and subscribes.
15720 func executeSearch(q string) {
15721 q = trimStr(q)
15722 if q == "" {
15723 return
15724 }
15725 appSt.searchCurrentQ = q
15726 appSt.searchResults = []*nostr.Event{:0}
15727 appSt.searchSeen = map[string]bool{}
15728
15729 switchPage("search")
15730 // URL reflects the current query.
15731 dom.ReplaceState("/search?q=" | dom.EncodeURIComponent(q))
15732
15733 // Build filter and choose target relays.
15734 var filter string
15735 var searchRelayList []string
15736 if len(q) > 1 && q[0] == '#' {
15737 // Tag search works on all relays; cap at 5 to limit event burst.
15738 tag := q[1:]
15739 filter = "{\"kinds\":[1,1111],\"#t\":[" | jstr(tag) | "],\"limit\":10}"
15740 searchRelayList = appSt.relayURLs
15741 if len(searchRelayList) > 5 {
15742 searchRelayList = searchRelayList[:5]
15743 }
15744 } else {
15745 // Full-text search: only NIP-50 relays, capped at 3 to bound
15746 // the event burst through the relay-proxy WASM.
15747 filter = "{\"kinds\":[1,1111],\"search\":" | jstr(q) | ",\"limit\":10}"
15748 searchRelayList = nip50Relays()
15749 if len(searchRelayList) > 3 {
15750 searchRelayList = searchRelayList[:3]
15751 }
15752 }
15753
15754 // Build filter bar (sticky, above results) then show loading.
15755 clearChildren(appSt.searchContainer)
15756 buildSearchFilterBar()
15757
15758 loading := dom.CreateElement("div")
15759 dom.SetStyle(loading, "color", "var(--muted)")
15760 dom.SetStyle(loading, "fontSize", "14px")
15761 dom.SetStyle(loading, "padding", "16px")
15762 dom.SetStyle(loading, "fontFamily", "'Fira Code', monospace")
15763 if len(searchRelayList) == 0 {
15764 dom.SetTextContent(loading, "no full-text search relays available \xe2\x80\x94 try #hashtag")
15765 dom.AppendChild(appSt.searchContainer, loading)
15766 return
15767 }
15768 relayWord := "relays"
15769 if len(searchRelayList) == 1 {
15770 relayWord = "relay"
15771 }
15772 dom.SetTextContent(loading, "searching " | itoa(len(searchRelayList)) | " " | relayWord | "...")
15773 dom.AppendChild(appSt.searchContainer, loading)
15774
15775 routeMsg("[\"CLOSE\",\"search\"]")
15776 routeMsg(buildProxyMsg("search", filter, searchRelayList))
15777 }
15778
15779 func buildSearchFilterBar() {
15780 // Rebuild the filter bar (recreate buttons so state labels are fresh).
15781 if appSt.searchFilterBarEl != 0 {
15782 dom.RemoveChild(appSt.searchPage, appSt.searchFilterBarEl)
15783 }
15784 bar := dom.CreateElement("div")
15785 appSt.searchFilterBarEl = bar
15786 dom.SetAttribute(bar, "class", "search-filter-bar")
15787 dom.SetStyle(bar, "display", "flex")
15788 dom.SetStyle(bar, "gap", "8px")
15789 dom.SetStyle(bar, "padding", "8px 12px")
15790 dom.SetStyle(bar, "borderBottom", "1px solid var(--border)")
15791 dom.SetStyle(bar, "flexWrap", "wrap")
15792 dom.SetStyle(bar, "alignItems", "center")
15793 dom.SetStyle(bar, "position", "sticky")
15794 dom.SetStyle(bar, "top", "0")
15795 dom.SetStyle(bar, "background", "var(--bg2)")
15796 dom.SetStyle(bar, "zIndex", "10")
15797
15798 followsBtn := searchFilterBtn("follows only", appSt.searchFollowsOnly)
15799 dom.AddEventListener(followsBtn, "click", dom.RegisterCallback(func() {
15800 appSt.searchFollowsOnly = !appSt.searchFollowsOnly
15801 applySearchFilterBtn(followsBtn, appSt.searchFollowsOnly)
15802 renderSearchResults()
15803 }))
15804 dom.AppendChild(bar, followsBtn)
15805
15806 sortBtn := searchFilterBtn("newest first", appSt.searchSortDesc)
15807 dom.SetAttribute(sortBtn, "data-sort", "1")
15808 dom.AddEventListener(sortBtn, "click", dom.RegisterCallback(func() {
15809 appSt.searchSortDesc = !appSt.searchSortDesc
15810 if appSt.searchSortDesc {
15811 dom.SetTextContent(sortBtn, "newest first")
15812 } else {
15813 dom.SetTextContent(sortBtn, "oldest first")
15814 }
15815 applySearchFilterBtn(sortBtn, true)
15816 renderSearchResults()
15817 }))
15818 dom.AppendChild(bar, sortBtn)
15819
15820 nsfwBtn := searchFilterBtn("nsfw", appSt.searchNSFW)
15821 dom.AddEventListener(nsfwBtn, "click", dom.RegisterCallback(func() {
15822 appSt.searchNSFW = !appSt.searchNSFW
15823 applySearchFilterBtn(nsfwBtn, appSt.searchNSFW)
15824 renderSearchResults()
15825 }))
15826 dom.AppendChild(bar, nsfwBtn)
15827
15828 // Insert filter bar BEFORE searchContainer in searchPage.
15829 dom.InsertBefore(appSt.searchPage, bar, appSt.searchContainer)
15830 }
15831
15832 func searchFilterBtn(label string, active bool) (e dom.Element) {
15833 btn := dom.CreateElement("button")
15834 dom.SetTextContent(btn, label)
15835 dom.SetStyle(btn, "padding", "4px 12px")
15836 dom.SetStyle(btn, "borderRadius", "4px")
15837 dom.SetStyle(btn, "fontSize", "12px")
15838 dom.SetStyle(btn, "cursor", "pointer")
15839 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
15840 applySearchFilterBtn(btn, active)
15841 return btn
15842 }
15843
15844 func applySearchFilterBtn(btn dom.Element, active bool) {
15845 if active {
15846 dom.SetStyle(btn, "background", "var(--accent)")
15847 dom.SetStyle(btn, "color", "#fff")
15848 dom.SetStyle(btn, "border", "none")
15849 } else {
15850 dom.SetStyle(btn, "background", "transparent")
15851 dom.SetStyle(btn, "color", "var(--muted)")
15852 dom.SetStyle(btn, "border", "1px solid var(--border)")
15853 }
15854 }
15855
15856 func handleSearchEvent(ev *nostr.Event) {
15857 if ev.Kind != 1 && ev.Kind != 1111 {
15858 return
15859 }
15860 if appSt.searchSeen[ev.ID] {
15861 return
15862 }
15863 appSt.searchSeen[ev.ID] = true
15864 if isMuted(ev.PubKey) {
15865 return
15866 }
15867 if looksLikeJSONSpam(ev.Content) {
15868 return
15869 }
15870 appSt.searchResults = mxutil.Ensure(appSt.searchResults, 1)
15871 appSt.searchResults = push(appSt.searchResults, ev)
15872 setEvent(ev.ID, ev)
15873 }
15874
15875 const searchPageSize = 25 // results rendered per page / scroll load
15876
15877 // filteredSearchResults returns searchResults with current filters applied and sorted.
15878 func filteredSearchResults() (ss []*nostr.Event) {
15879 var filtered []*nostr.Event
15880 for _, ev := range appSt.searchResults {
15881 if appSt.searchFollowsOnly && !isFollowing(ev.PubKey) {
15882 continue
15883 }
15884 if !appSt.searchNSFW {
15885 if nostr.TagsGetFirst(ev.Tags, []byte("content-warning")) != nil {
15886 continue
15887 }
15888 }
15889 filtered = mxutil.Ensure(filtered, 1)
15890 filtered = push(filtered, ev)
15891 }
15892 for i := 0; i < len(filtered); i++ {
15893 for j := i + 1; j < len(filtered); j++ {
15894 var swap bool
15895 if appSt.searchSortDesc {
15896 swap = filtered[j].CreatedAt > filtered[i].CreatedAt
15897 } else {
15898 swap = filtered[j].CreatedAt < filtered[i].CreatedAt
15899 }
15900 if swap {
15901 filtered[i], filtered[j] = filtered[j], filtered[i]
15902 }
15903 }
15904 }
15905 return filtered
15906 }
15907
15908 // renderSearchResults rebuilds the result list from scratch, rendering only
15909 // the first searchPageSize results. Remaining results load on scroll.
15910 func renderSearchResults() {
15911 appSt.searchRendered = 0
15912 clearChildren(appSt.searchContainer)
15913
15914 filtered := filteredSearchResults()
15915 if len(filtered) == 0 {
15916 empty := dom.CreateElement("div")
15917 dom.SetTextContent(empty, "no results")
15918 dom.SetStyle(empty, "color", "var(--muted)")
15919 dom.SetStyle(empty, "fontSize", "14px")
15920 dom.SetStyle(empty, "padding", "16px")
15921 dom.SetStyle(empty, "fontFamily", "'Fira Code', monospace")
15922 dom.AppendChild(appSt.searchContainer, empty)
15923 return
15924 }
15925
15926 end := len(filtered)
15927 if end > searchPageSize {
15928 end = searchPageSize
15929 }
15930 for i := 0; i < end; i++ {
15931 dom.AppendChild(appSt.searchContainer, renderSearchResult(filtered[i]))
15932 }
15933 appSt.searchRendered = end
15934 }
15935
15936 // appendSearchResults renders the next batch of results (called on scroll).
15937 func appendSearchResults() {
15938 filtered := filteredSearchResults()
15939 if appSt.searchRendered >= len(filtered) {
15940 return
15941 }
15942 end := appSt.searchRendered + searchPageSize
15943 if end > len(filtered) {
15944 end = len(filtered)
15945 }
15946 for i := appSt.searchRendered; i < end; i++ {
15947 dom.AppendChild(appSt.searchContainer, renderSearchResult(filtered[i]))
15948 }
15949 appSt.searchRendered = end
15950 }
15951
15952 func renderSearchResult(ev *nostr.Event) (e dom.Element) {
15953 row := dom.CreateElement("div")
15954 dom.SetStyle(row, "display", "flex")
15955 dom.SetStyle(row, "flexDirection", "column")
15956 dom.SetStyle(row, "padding", "10px 12px")
15957 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
15958 dom.SetStyle(row, "cursor", "pointer")
15959 dom.SetStyle(row, "gap", "4px")
15960
15961 // Header: avatar + name + timestamp.
15962 header := dom.CreateElement("div")
15963 dom.SetStyle(header, "display", "flex")
15964 dom.SetStyle(header, "alignItems", "center")
15965 dom.SetStyle(header, "gap", "8px")
15966
15967 avatar := dom.CreateElement("img")
15968 dom.SetAttribute(avatar, "width", "20")
15969 dom.SetAttribute(avatar, "height", "20")
15970 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
15971 dom.SetStyle(avatar, "borderRadius", "50%")
15972 dom.SetStyle(avatar, "objectFit", "cover")
15973 dom.SetStyle(avatar, "flexShrink", "0")
15974 pk := ev.PubKey
15975 if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
15976 setMediaSrc(avatar, pic)
15977 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
15978 } else {
15979 dom.SetStyle(avatar, "display", "none")
15980 }
15981 dom.AppendChild(header, avatar)
15982
15983 name := dom.CreateElement("span")
15984 dom.SetStyle(name, "fontSize", "14px")
15985 dom.SetStyle(name, "fontWeight", "bold")
15986 dom.SetStyle(name, "color", "var(--fg)")
15987 dom.SetStyle(name, "flex", "1")
15988 dom.SetStyle(name, "overflow", "hidden")
15989 dom.SetStyle(name, "textOverflow", "ellipsis")
15990 dom.SetStyle(name, "whiteSpace", "nowrap")
15991 if n, ok := appSt.authorNames[pk]; ok && n != "" {
15992 dom.SetTextContent(name, n)
15993 } else {
15994 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
15995 if len(npub) > 20 {
15996 dom.SetTextContent(name, npub[:12] | "..." | npub[len(npub)-4:])
15997 }
15998 }
15999 dom.AppendChild(header, name)
16000
16001 ts := dom.CreateElement("span")
16002 dom.SetTextContent(ts, formatTime(ev.CreatedAt))
16003 dom.SetStyle(ts, "fontSize", "11px")
16004 dom.SetStyle(ts, "color", "var(--muted)")
16005 dom.SetStyle(ts, "flexShrink", "0")
16006 dom.AppendChild(header, ts)
16007 dom.AppendChild(row, header)
16008
16009 // Two-line content preview (plain text, no markdown rendering).
16010 preview := dom.CreateElement("div")
16011 dom.SetStyle(preview, "fontSize", "13px")
16012 dom.SetStyle(preview, "color", "var(--fg)")
16013 dom.SetStyle(preview, "lineHeight", "1.4")
16014 dom.SetStyle(preview, "overflow", "hidden")
16015 dom.SetStyle(preview, "display", "-webkit-box")
16016 dom.SetStyle(preview, "-webkit-line-clamp", "2")
16017 dom.SetStyle(preview, "-webkit-box-orient", "vertical")
16018 dom.SetStyle(preview, "wordBreak", "break-word")
16019 content := ev.Content
16020 if len(content) > 300 {
16021 content = content[:300] | "..."
16022 }
16023 dom.SetTextContent(preview, content)
16024 dom.AppendChild(row, preview)
16025
16026 // Click opens thread.
16027 evID := ev.ID
16028 evRootID := getRootID(ev)
16029 if evRootID == "" {
16030 evRootID = evID
16031 }
16032 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
16033 showNoteThread(evRootID, evID)
16034 }))
16035
16036 if _, cached := appSt.authorNames[pk]; !cached {
16037 appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
16038 appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], header)
16039 if !lookupFetchedK0(pk) {
16040 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
16041 }
16042 }
16043
16044 return row
16045 }
16046
16047 // extractHashtags replaces #tag tokens with placeholders so markdown-it
16048 // preserves them. Restored after rendering as clickable search spans.
16049 func extractHashtags(s string, slots *[]string) (sv string) {
16050 out := ""
16051 i := 0
16052 for i < len(s) {
16053 if s[i] != '#' {
16054 out = out | string([]byte{s[i]})
16055 i++
16056 continue
16057 }
16058 // Must be preceded by whitespace/start/newline to be a hashtag.
16059 if i > 0 {
16060 prev := s[i-1]
16061 if prev != ' ' && prev != '\n' && prev != '\t' && prev != '\r' {
16062 out = out | "#"
16063 i++
16064 continue
16065 }
16066 }
16067 // Collect tag characters: letters, digits, underscores.
16068 j := i + 1
16069 for j < len(s) {
16070 c := s[j]
16071 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
16072 j++
16073 } else {
16074 break
16075 }
16076 }
16077 if j == i+1 {
16078 // No tag characters after #.
16079 out = out | "#"
16080 i++
16081 continue
16082 }
16083 tag := s[i+1 : j]
16084 idx := len(*slots)
16085 span := "<span data-search-tag=\"" | escapeHTMLAttr(tag) | "\" style=\"color:var(--accent);cursor:pointer\">#" | tag | "</span>"
16086 *slots = push(*slots, span)
16087 out = out | "\x02HASH" | itoa(idx) | "\x02"
16088 i = j
16089 }
16090 return out
16091 }
16092
16093 // attachHashtagLinks wires click handlers on [data-search-tag] spans.
16094 func attachHashtagLinks(rootp2 dom.Element) {
16095 el := dom.QuerySelectorFrom(rootp2, "[data-search-tag]")
16096 for el != 0 {
16097 tag := dom.GetAttribute(el, "data-search-tag")
16098 dom.RemoveAttribute(el, "data-search-tag")
16099 if tag != "" {
16100 capturedTag := tag
16101 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
16102 dom.SetProperty(appSt.searchInputEl, "value", "#" | capturedTag)
16103 executeSearch("#" | capturedTag)
16104 }))
16105 }
16106 el = dom.QuerySelectorFrom(rootp2, "[data-search-tag]")
16107 }
16108 }
16109
16110 // trimStr trims leading and trailing whitespace from s.
16111 func trimStr(s string) (sv string) {
16112 start := 0
16113 for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
16114 start++
16115 }
16116 end := len(s)
16117 for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
16118 end--
16119 }
16120 return s[start:end]
16121 }
16122
16123 // nip50Relays returns the subset of configured relay URLs that advertise NIP-50
16124 // (full-text search) in their NIP-11 document.
16125 func nip50Relays() (ss []string) {
16126 var out []string
16127 for i, url := range appSt.relayURLs {
16128 if i < len(appSt.relayCaps) && hasNIP(appSt.relayCaps[i].supportedNIPs, 50) {
16129 out = mxutil.Ensure(out, 1)
16130 out = push(out, url)
16131 }
16132 }
16133 return out
16134 }
16135
16136 // looksLikeJSONSpam returns true if the content is a raw JSON object or array
16137 // larger than 200 bytes. Catches protocol spam posted as kind 1 notes.
16138 func looksLikeJSONSpam(content string) (ok bool) {
16139 i := 0
16140 for i < len(content) {
16141 c := content[i]
16142 if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
16143 i++
16144 } else {
16145 break
16146 }
16147 }
16148 if i >= len(content) {
16149 return false
16150 }
16151 open := content[i]
16152 if open != '{' && open != '[' {
16153 return false
16154 }
16155 // Scan for matching close bracket (up to 2000 chars).
16156 limit := len(content)
16157 if limit > 2000 {
16158 limit = 2000
16159 }
16160 depth := 0
16161 inStr := false
16162 j := i
16163 for j < limit {
16164 c := content[j]
16165 if inStr {
16166 if c == '\\' && j+1 < limit {
16167 j += 2
16168 continue
16169 }
16170 if c == '"' {
16171 inStr = false
16172 }
16173 } else {
16174 if c == '"' {
16175 inStr = true
16176 } else if c == '{' || c == '[' {
16177 depth++
16178 } else if c == '}' || c == ']' {
16179 depth--
16180 if depth == 0 {
16181 return j-i+1 > 200
16182 }
16183 }
16184 }
16185 j++
16186 }
16187 return false
16188 }
16189
16190 func defaultBlossomURL() (s string) {
16191 local := localRelayURL()
16192 var base string
16193 if len(local) >= 6 && local[:6] == "wss://" {
16194 base = "https://" | local[6:]
16195 } else if len(local) >= 5 && local[:5] == "ws://" {
16196 base = "http://" | local[5:]
16197 } else {
16198 return ""
16199 }
16200 return base | "/blossom"
16201 }
16202
16203 // parseJSONStringArray parses a JSON array of strings like ["a","b","c"].
16204 func parseJSONStringArray(s string) (ss []string) {
16205 var out []string
16206 i := 0
16207 for i < len(s) && s[i] != '[' {
16208 i++
16209 }
16210 if i >= len(s) {
16211 return nil
16212 }
16213 i++ // skip '['
16214 for i < len(s) {
16215 for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\n') {
16216 i++
16217 }
16218 if i >= len(s) || s[i] == ']' {
16219 break
16220 }
16221 if s[i] != '"' {
16222 break
16223 }
16224 i++
16225 start := i
16226 for i < len(s) && s[i] != '"' {
16227 if s[i] == '\\' {
16228 i++
16229 }
16230 i++
16231 }
16232 if i <= len(s) {
16233 out = mxutil.Ensure(out, 1)
16234 out = push(out, s[start:i])
16235 }
16236 if i < len(s) {
16237 i++ // skip closing '"'
16238 }
16239 }
16240 return out
16241 }
16242
16243 func jsonEsc(s string) (sv string) {
16244 s = strReplace(s, "\\", "\\\\")
16245 s = strReplace(s, "\"", "\\\"")
16246 s = strReplace(s, "\n", "\\n")
16247 s = strReplace(s, "\r", "\\r")
16248 s = strReplace(s, "\t", "\\t")
16249 return s
16250 }
16251
16252 // strIndex finds substring in string. Returns -1 if not found.
16253 func strIndex(s, sub string) (n int32) {
16254 sl := len(sub)
16255 for i := 0; i <= len(s)-sl; i++ {
16256 if s[i:i+sl] == sub {
16257 return i
16258 }
16259 }
16260 return -1
16261 }
16262
16263 // --- Helpers ---
16264
16265 // normalizeURL strips trailing slashes and lowercases the scheme+host.
16266 func normalizeURL(u string) (s string) {
16267 for len(u) > 0 && u[len(u)-1] == '/' {
16268 u = u[:len(u)-1]
16269 }
16270 // Lowercase scheme and host (before first / after ://).
16271 if len(u) > 6 && u[:6] == "wss://" {
16272 rest := u[6:]
16273 slash := strIndex(rest, "/")
16274 if slash < 0 {
16275 return u[:6] | toLower(rest)
16276 }
16277 return u[:6] | toLower(rest[:slash]) | rest[slash:]
16278 }
16279 if len(u) > 5 && u[:5] == "ws://" {
16280 rest := u[5:]
16281 slash := strIndex(rest, "/")
16282 if slash < 0 {
16283 return u[:5] | toLower(rest)
16284 }
16285 return u[:5] | toLower(rest[:slash]) | rest[slash:]
16286 }
16287 return u
16288 }
16289
16290 func toLower(s string) (sv string) {
16291 b := []byte{:len(s)}
16292 for i := 0; i < len(s); i++ {
16293 c := s[i]
16294 if c >= 'A' && c <= 'Z' {
16295 c += 32
16296 }
16297 b[i] = c
16298 }
16299 return string(b)
16300 }
16301
16302 func showQRModal(npubStr string) {
16303 svg := qrSVG(npubStr, 5)
16304 if svg == "" {
16305 return
16306 }
16307 scrim := dom.CreateElement("div")
16308 dom.SetStyle(scrim, "position", "fixed")
16309 dom.SetStyle(scrim, "inset", "0")
16310 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
16311 dom.SetStyle(scrim, "display", "flex")
16312 dom.SetStyle(scrim, "alignItems", "center")
16313 dom.SetStyle(scrim, "justifyContent", "center")
16314 dom.SetStyle(scrim, "zIndex", "9999")
16315 dom.SetStyle(scrim, "cursor", "pointer")
16316 dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
16317 dom.RemoveChild(dom.Body(), scrim)
16318 }))
16319
16320 card := dom.CreateElement("div")
16321 dom.SetStyle(card, "background", "white")
16322 dom.SetStyle(card, "borderRadius", "16px")
16323 dom.SetStyle(card, "padding", "24px")
16324 dom.SetStyle(card, "display", "flex")
16325 dom.SetStyle(card, "flexDirection", "column")
16326 dom.SetStyle(card, "alignItems", "center")
16327 dom.SetStyle(card, "cursor", "default")
16328 dom.SetAttribute(card, "onclick", "event.stopPropagation()")
16329 dom.SetInnerHTML(card, svg)
16330
16331 dom.AppendChild(scrim, card)
16332 dom.AppendChild(dom.Body(), scrim)
16333 }
16334
16335 func clearChildren(el dom.Element) {
16336 dom.ReleaseChildren(el)
16337 dom.SetInnerHTML(el, "")
16338 }
16339
16340 func removeChildrenByPK(parent dom.Element, pk string) {
16341 if parent == 0 {
16342 return
16343 }
16344 var toRemove []dom.Element
16345 child := dom.FirstElementChild(parent)
16346 for child != 0 {
16347 if dom.GetProperty(child, "musiquayPK") == pk {
16348 toRemove = mxutil.Ensure(toRemove, 1)
16349 toRemove = push(toRemove, child)
16350 }
16351 child = dom.NextSibling(child)
16352 }
16353 for _, el := range toRemove {
16354 dom.RemoveChild(parent, el)
16355 }
16356 }
16357
16358 func itoa(n int32) (s string) {
16359 if n == 0 {
16360 return "0"
16361 }
16362 neg := false
16363 if n < 0 {
16364 neg = true
16365 n = -n
16366 }
16367 var b [20]byte
16368 i := len(b)
16369 for n > 0 {
16370 i--
16371 b[i] = byte('0' + n%10)
16372 n /= 10
16373 }
16374 if neg {
16375 i--
16376 b[i] = '-'
16377 }
16378 return string(b[i:])
16379 }
16380