main.mx raw
1 package main
2
3 import (
4 "crypto/sha256"
5 "git.smesh.lol/smesh/web/common/helpers"
6 "git.smesh.lol/smesh/web/common/jsbridge/dom"
7 "git.smesh.lol/smesh/web/common/jsbridge/mlsw"
8 "git.smesh.lol/smesh/web/common/jsbridge/feed"
9 "git.smesh.lol/smesh/web/common/jsbridge/localstorage"
10 "git.smesh.lol/smesh/web/common/jsbridge/markdown"
11 "git.smesh.lol/smesh/web/common/jsbridge/notif"
12 "git.smesh.lol/smesh/web/common/jsbridge/profile"
13 "git.smesh.lol/smesh/web/common/jsbridge/relayproxy"
14 "git.smesh.lol/smesh/web/common/jsbridge/signer"
15 "git.smesh.lol/smesh/web/common/nostr"
16 )
17
18 const (
19 lsKeyPubkey = "smesh-pubkey"
20 lsKeyTheme = "smesh-theme"
21 lsKeyRelayPolicy = "smesh-relay-policy"
22 lsKeyRelayBlocklist = "smesh-relay-blocklist"
23 lsKeyRelayList = "smesh-relays"
24 lsKeyBlossomServer = "smesh-blossom-server"
25 lsKeyInvidiousURL = "smesh-invidious-url"
26 lsKeyCampionURL = "smesh-campion-url"
27 lsKeyDevMode = "smesh-dev-mode"
28 lsKeyRelayRoles = "smesh-relay-roles"
29 )
30
31 // Relay policy bitfield - persisted as decimal int in lsKeyRelayPolicy.
32 // All flags default to 0 (no policy enforced). Enforcement happens in later
33 // steps; step 3 only reads and writes the state.
34 const (
35 policyRequireAuth = 1 << 0 // require NIP-42 support
36 policyRequireProtect = 1 << 1 // require NIP-70 support
37 policyBlockPayment = 1 << 2 // block relays with limitation.payment_required
38 policyBlockRestricted = 1 << 3 // block relays with limitation.restricted_writes
39 )
40
41 const activityLogMax = 500
42
43 type logEntry struct {
44 ts int64
45 action string // "boot", "publish", "reply", "react", "rebroadcast", "subscribe", "relay-ok", "relay-err"
46 summary string // one-line title
47 detail string // multi-line detail, can be empty
48 }
49
50 var (
51 pubhex string
52 isDark bool
53 devMode bool
54 blossomServerURL string
55 invidiousURL string
56 campionURL string
57
58 // Profile data from kind 0.
59 profileName string
60 profilePic string
61 profileTs int64
62 contactTs int64 // kind 3 latest timestamp
63 muteTs int64 // kind 10000 latest timestamp
64 relayListTs int64 // kind 10002 latest timestamp
65 inboxTs int64 // kind 10050 latest timestamp
66 settingsTs int64 // kind 30078 latest timestamp
67
68 // DOM refs that need updating after creation.
69 avatarEl dom.Element
70 nameEl dom.Element
71 feedContainer dom.Element
72 feedLoader dom.Element
73 statusEl dom.Element
74 bottomBar dom.Element
75 topBarRight dom.Element
76 topBarLeft dom.Element
77 userBtn dom.Element
78 popoverEl dom.Element
79 popoverSummary dom.Element
80 signerBtnEl dom.Element
81 walletBtnEl dom.Element
82 walletPanel dom.Element
83 walletContent dom.Element
84 walletPanelOpen bool
85 themeBtn dom.Element
86 pageTitleEl dom.Element
87 topBackBtn dom.Element
88 feedPage dom.Element
89 loadMoreBtn dom.Element
90 msgPage dom.Element
91 profilePage dom.Element
92 sidebarCompose dom.Element
93 sidebarFeed dom.Element
94 sidebarMsg dom.Element
95 sidebarNotif dom.Element
96 sidebarSettings dom.Element
97 notifDot dom.Element
98 notifPage dom.Element
99 notifContainer dom.Element
100
101 // Search page state.
102 searchPage dom.Element
103 searchContainer dom.Element
104 searchFilterBarEl dom.Element // sticky bar above results
105 searchInputEl dom.Element
106 searchSubmitBtn dom.Element
107 searchBtnGlobal dom.Element // captured for deactivation from anywhere
108 searchReturnPage string // page to restore when user manually closes search
109 searchBarActive bool
110 searchCurrentQ string
111 searchResults []*nostr.Event
112 searchSeen map[string]bool
113 searchFollowsOnly bool
114 searchSortDesc bool // true = newest first (default)
115 searchNSFW bool
116 searchRendered int32 // how many filtered results are currently rendered
117 notifLoadMore dom.Element
118 notifEmptyEl dom.Element
119 notifSelectEl dom.Element
120 notifMarkBtn dom.Element
121 notifBarGroup dom.Element
122 notifFilter string // "all", "mentions", "reactions", "zaps"
123 notifUnread bool
124 notifReadTs int64 // watermark: events with CreatedAt > this are unread
125 notifBuilt bool // true after first render
126 notifEvents []*nostr.Event
127 notifRowCBs []int32 // callback IDs registered by current notif rows; released on rebuild
128 notifSeen map[string]bool
129 oldestNotifTs int64
130 notifLoading bool
131 notifExhausted bool
132 notifEmptyStrk int32
133 notifMoreGot int32
134 notifMoreTimer int32
135 notifRefTimer int32
136 notifInitLoad bool
137 notifMissing []string // refIDs not in eventCache, fetched after EOSE
138 settingsPage dom.Element
139 composePage dom.Element
140 aboutPage dom.Element
141 aboutLogoEl dom.Element
142 composeTextarea dom.Element
143 composePreview dom.Element
144 composeRelayPopEl dom.Element
145 composeRelayPopOpen bool
146 composeRelayChecks map[string]bool // relay URL -> selected for publish
147 emojiAutoEl dom.Element // emoji autocomplete popup
148 emojiAutoOpen bool
149 emojiAutoStart int32 // position of the opening ':'
150 emojiAutoTA dom.Element // textarea the emoji popup is attached to
151 activePage string
152 profileViewPK string
153
154 // App root - content goes here, not body (snackbar stays outside).
155 root dom.Element
156
157 // Messaging UI state.
158 msgListContainer dom.Element // conversation list view
159 msgThreadContainer dom.Element // thread view (header + messages + compose)
160 msgThreadMessages dom.Element // scrollable message area
161 msgComposeInput dom.Element // textarea
162 msgCurrentConvoID string // peer pubkey (DM) or groupIDHex (group) of open thread, "" when on list
163 msgCurrentKind string // "dm" or "group"
164 msgView string // "list" or "thread"
165 marmotInited bool
166 pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation
167
168 // Relay tracking - parallel slices, grown dynamically.
169 relayURLs []string
170 relayRows []dom.Element // popover row container - used by removeRelayByURL
171 relayDots []dom.Element
172 relayLabels []dom.Element
173 relayBadgeContainers []dom.Element // capability warning badges, rewritten on NIP-11 fetch
174 relayReadBtns []dom.Element // per-relay "r" toggle button
175 relayWriteBtns []dom.Element // per-relay "w" toggle button
176 relayUserPick []bool // true = from user's kind 10002
177 relayRole []string // "both", "read", "write" per relay
178 relayCaps []relayCap // NIP-11 snapshot (populated async after addRelay)
179 relayCapsTs []int64 // unix seconds when each relayCaps entry was fetched
180
181 // Relay policy (loaded once at startup from localStorage).
182 relayPolicyFlags int32 // bitfield of policy* constants
183 relayBlocklist []string // exact-URL blocklist (persisted; kind 10006 publication is future work)
184
185 eventRelays map[string][]string // event ID -> relay URLs that delivered it
186 pendingDeletes map[string]string // delete event ID -> target event ID
187 noteElements map[string]dom.Element // event ID -> note DOM element
188 eventCount int32
189 popoverOpen bool
190 broadcastRow dom.Element
191 broadcastBtnEl dom.Element
192 oldestFeedTs int64 // oldest created_at in feed - for infinite scroll
193 feedLoading bool // true while fetching older events
194 feedExhausted bool // true when EOSE returns no new events
195 feedEmptyStreak int32 // consecutive empty EOSE responses
196
197 // Author name/pic view cache - small, populated from P_RESOLVED and IDB bootstrap.
198 // Profile Worker owns canonical data; Shell keeps this for sync DOM reads.
199 authorNames map[string]string // pubkey hex -> display name
200 authorPics map[string]string // pubkey hex -> avatar URL
201 pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile
202 pendingNotifAvatars map[string][]dom.Element // pubkey hex -> notif avatar imgs awaiting profile
203 seenEvents map[string]bool // event ID -> already rendered
204 // Shell-local fetch dedup hints - not authoritative (Profile Worker owns dedup).
205 // Empty map reads return false so all guards fire, Profile Worker handles actual dedup.
206 // Two-gen rotation: when current exceeds 10000, old is dropped and current becomes old.
207 fetchedK0 map[string]bool
208 fetchedK0Old map[string]bool
209 fetchedK10k map[string]bool
210 fetchedK10kOld map[string]bool
211 // Stub vars for dead profile-fetch functions - referenced in function bodies
212 // that are no longer called; kept to satisfy the compiler until cleanup.
213 fetchQueue []string
214 fetchTimer int32
215 retryRound int32
216 authorSubPK map[string]string
217
218 // User card hover popover.
219 hoverCardEl dom.Element
220 hoverCardParent dom.Element
221 hoverCardPK string
222 hoverCardTimer int32
223 hoverCardDismiss int32
224 hoverCardHover bool
225
226 idbLoaded bool
227
228 // QR modal.
229 logoSVGCache string
230
231 // Profile page tab state.
232 profileTab string
233 profileTabContent dom.Element
234 profileTabBtns map[string]dom.Element
235 // Own follow/mute lists - Shell's working copy for sync filter and action ops.
236 // Profile Worker is authoritative; Shell is updated via P_FOLLOWS/P_MUTES pushes.
237 myFollows []string
238 myMutes []string
239 followSet map[string]bool // O(1) lookup derived from myFollows
240 muteSet map[string]bool // O(1) lookup derived from myMutes
241 // Profile content/follows/mutes/relays for viewed profiles (small view caches).
242 profileContentCache map[string]string
243 profileFollowsCache map[string][]string
244 profileMutesCache map[string][]string
245 profileRelaysCache map[string][]string
246 profileNotesSeen map[string]bool
247 activeProfileNoteSub string
248
249 // Profile page cache - keeps rendered profile DOMs alive.
250 relayInfoEl dom.Element // relay info page wrapper (removed on nav)
251 profileWrappers map[string]dom.Element // pk -> wrapper div
252 profileWrapTabCont map[string]dom.Element // pk -> tab content ref
253 profileWrapTabs map[string]map[string]dom.Element // pk -> tab button map
254 profileWrapTabSel map[string]string // pk -> selected tab
255 profileWrapOrder []string // LRU order (oldest first)
256
257 // History/routing.
258 navPop bool // true during popstate - suppresses pushState
259 routerInited bool // guard against duplicate listener registration
260
261 // Embedded nostr: entity resolution.
262 embedCounter int32
263 embedCallbacks map[string][]string // hex event ID -> DOM element IDs awaiting fill
264 embedRelayHints map[string][]string // hex event ID -> relay hints from nevent TLV
265 embedRequested map[string]bool // hex event IDs already sent to PROXY
266 embedSubIDs map[string][]string // sub ID -> list of hex event IDs requested in that sub
267 embedCoordCBs map[string][]string // "kind:pubkey:d" -> DOM element IDs awaiting fill
268 embedCoordSubIDs map[string]string // sub ID -> coord key
269
270 // @-mention autocomplete.
271 mentionPopup dom.Element // the popup container
272 mentionItems []mentionMatch
273 mentionSelIdx int32 // highlighted index in popup
274 mentionTA dom.Element // textarea the popup is attached to
275 mentionQuery string // current query text (after @)
276 mentionAtPos int32 // character position of the @ in the textarea
277
278 // Thread view state.
279 threadPage dom.Element
280 threadContainer dom.Element
281 threadRootID string
282 threadFocusID string
283 threadEvents map[string]*nostr.Event
284 threadSubCount int32
285 threadOpen bool
286 threadReturnPage string // page to return to when closing thread
287 threadPushedState bool // true if we pushed history for this thread
288 threadRenderTimer int32
289 threadGen int32 // generation counter - reject events from old threads
290 threadActiveSubs []string // sub IDs to close when opening a new thread
291 threadLastRendered int32 // event count at last render - skip if unchanged
292 threadRelays []string // relay URLs used for this thread's fetches
293 threadFollowUp int32 // follow-up round counter (0 = initial, caps at 3)
294 threadFetchedIDs map[string]bool // IDs already queried for child replies
295 contentArea dom.Element // scroll container
296 savedScrollTop string // feed scroll position before thread open
297
298 // Reply preview state.
299 replyCache map[string]string // event ID -> "name: first line"
300 replyAvatarCache map[string]string // event ID -> avatar URL of parent author
301 replyLineCache map[string]string // event ID -> raw first line (no name)
302 replyAuthorMap map[string]string // event ID -> author pubkey hex
303 replyPending map[string][]dom.Element // event ID -> preview divs awaiting fetch
304 replyNeedName map[string][]dom.Element // event ID -> preview divs needing name update
305 replyQueue []string // event IDs to batch-fetch
306 replyHints map[string]string // event ID -> relay hint URL from e-tag
307 replyTimer int32
308 replySweepTimer int32 // debounced sweep for unfound replies
309
310 // Action button state (reactions/reposts).
311 noteRepostCounts map[string]map[string]bool // event ID -> pubkey set
312 noteReactionMap map[string]map[string]map[string]bool // event ID -> emoji -> pubkey set
313 noteReactionEls map[string]dom.Element // event ID -> reaction display row
314 noteRepostEls map[string]dom.Element // event ID -> repost counter span
315 noteZapTotals map[string]int64 // event ID -> total msats received
316 noteZapSeen map[string]map[string]bool // event ID -> set of 9735 receipt IDs counted
317 noteZapEls map[string]dom.Element // event ID -> zap total chip
318 pendingZapRows map[string][]zapRowPending // authorPK -> rows awaiting lud16
319 actionFetchedIDs map[string]bool // event IDs already queued for kind 6/7
320 actionFetchQueue []string // IDs awaiting batch fetch
321 actionFetchTimer int32
322 actionSubCounter int32
323
324 // Compose editor state.
325 editorComposer dom.Element // inline composer div appended to note
326 editorNoteEl dom.Element // the note element containing the composer
327 editorTextarea dom.Element
328 editorPreviewEl dom.Element
329 editorMode string // "reply" or "quote"
330 editorTargetEv *nostr.Event
331 editorOpen bool
332 editorPreviewing bool
333 editorDrafts map[string]string // "reply:evID" or "quote:evID" -> draft text
334
335 // Emoji picker state.
336 emojiOverlay dom.Element
337 emojiBackdrop dom.Element
338 emojiAnchor dom.Element
339 emojiTargetEv *nostr.Event
340 emojiOpen bool
341
342 // Cached events for repost/quote content.
343 eventCache map[string]*nostr.Event
344
345 // Global ready flag - set after showApp finishes all map init + DOM setup.
346 appReady bool
347
348 // Activity log state. activityLog is append-only (newest first), capped at activityLogMax.
349 // Each entry is a single action with a short summary and optional multi-line detail.
350 logPage dom.Element
351 logListEl dom.Element
352 logBtn dom.Element
353 activityLog []*logEntry
354 bootInProgress bool // true while boot is running; controls logBtn highlight
355
356 // Live-update subscriber callbacks keyed by event ID. The relay info modal
357 // registers a callback for its evID on open; the SEEN_ON handler fires the
358 // registered callback (if any) after updating eventRelays. Modal must
359 // unregister on close. Single registration per evID - second registration
360 // replaces the first.
361 seenOnSubs map[string]func()
362
363 // Feed controls.
364 refreshBtn dom.Element
365 refreshSpinning bool
366 feedInitialLoad bool // true until ~3s after first EOSE on "feed"
367 feedInitialLoadTimer int32 // delays flipping feedInitialLoad=false so late events from slower relays still render directly
368 feedSubscribed bool // true once subscribeFeed has been called
369 feedDeferTimer int32 // timer for deferred feed subscription
370 feedSubTimer int32 // throttle timer for subscribeFeed
371 feedBuffer []*nostr.Event // new events buffered after initial load
372 feedHasNew bool // true when feedBuffer is non-empty (pulse indicator)
373 feedMode string // "follows", "relays", or relay URL
374 feedSelectEl dom.Element
375 feedPopoverEl dom.Element
376 feedPopoverOpen bool
377
378 )
379
380 // ── Rotate-map cache bounds ───────────────────────────────────────────────────
381 // Each cache group has two generations: current and old. When current exceeds
382 // its threshold, old is dropped and current becomes old. Lookups check both.
383
384 const (
385 rotateEventMax = 2000
386 rotateAuthorMax = 500
387 rotateEmbedMax = 200
388 rotateReplyMax = 500
389 rotateProfileMax = 10
390 notifEventsMax = 500
391 rotateFetchedMax = 10000
392 )
393
394 var (
395 // Event data "old" generation.
396 eventCacheOld map[string]*nostr.Event
397 seenEventsOld map[string]bool
398 noteElementsOld map[string]dom.Element
399 noteReactionMapOld map[string]map[string]map[string]bool
400 noteRepostCountsOld map[string]map[string]bool
401 noteZapSeenOld map[string]map[string]bool
402 actionFetchedIDsOld map[string]bool
403 eventRelaysOld map[string][]string
404 eventCacheCount int32
405
406 // Author name/pic "old" generation - two-gen rotation so GC can collect stale entries.
407 authorNamesOld map[string]string
408 authorPicsOld map[string]string
409 authorCacheCount int32
410
411 // Embed "old" generation.
412 embedCallbacksOld map[string][]string
413 embedRequestedOld map[string]bool
414 embedSubIDsOld map[string][]string
415 embedCoordCBsOld map[string][]string
416 embedRelayHintsOld map[string][]string
417 embedCacheCount int32
418
419 // Reply "old" generation.
420 replyCacheOld map[string]string
421 replyAvatarCacheOld map[string]string
422 replyLineCacheOld map[string]string
423 replyAuthorMapOld map[string]string
424 replyNeedNameOld map[string][]dom.Element
425 replyHintsOld map[string]string
426 replyCount int32
427 )
428
429 // rotateEventCaches swaps the event data caches and releases dropped DOM handles.
430 func rotateEventCaches() {
431 var toRelease []int32
432 for id, el := range noteElementsOld {
433 if _, ok := noteElements[id]; !ok {
434 toRelease = append(toRelease, int32(el))
435 }
436 }
437 if len(toRelease) > 0 {
438 dom.ReleaseAll(toRelease)
439 }
440 eventCacheOld = eventCache
441 eventCache = map[string]*nostr.Event{}
442 seenEventsOld = seenEvents
443 seenEvents = map[string]bool{}
444 noteElementsOld = noteElements
445 noteElements = map[string]dom.Element{}
446 noteReactionMapOld = noteReactionMap
447 noteReactionMap = map[string]map[string]map[string]bool{}
448 noteRepostCountsOld = noteRepostCounts
449 noteRepostCounts = map[string]map[string]bool{}
450 noteZapSeenOld = noteZapSeen
451 noteZapSeen = map[string]map[string]bool{}
452 actionFetchedIDsOld = actionFetchedIDs
453 actionFetchedIDs = map[string]bool{}
454 eventRelaysOld = eventRelays
455 eventRelays = map[string][]string{}
456 noteReactionEls = map[string]dom.Element{}
457 noteRepostEls = map[string]dom.Element{}
458 noteZapEls = map[string]dom.Element{}
459 eventCacheCount = 0
460 }
461
462 // rotateAuthorCaches rotates the Shell name/pic view cache (two-gen for GC).
463 // Profile Worker owns the canonical data rotation; this only covers Shell's small cache.
464 func rotateAuthorCaches() {
465 authorNamesOld = authorNames
466 authorNames = map[string]string{}
467 authorPicsOld = authorPics
468 authorPics = map[string]string{}
469 authorCacheCount = 0
470 }
471
472 // rotateEmbedCaches swaps the embed caches.
473 func rotateEmbedCaches() {
474 embedCallbacksOld = embedCallbacks
475 embedCallbacks = map[string][]string{}
476 embedRequestedOld = embedRequested
477 embedRequested = map[string]bool{}
478 embedSubIDsOld = embedSubIDs
479 embedSubIDs = map[string][]string{}
480 embedCoordCBsOld = embedCoordCBs
481 embedCoordCBs = map[string][]string{}
482 embedRelayHintsOld = embedRelayHints
483 embedRelayHints = map[string][]string{}
484 embedCacheCount = 0
485 }
486
487 // lookupFetchedK0 checks current and old generation.
488 func lookupFetchedK0(pk string) (ok bool) {
489 if fetchedK0[pk] {
490 return true
491 }
492 return fetchedK0Old[pk]
493 }
494
495 // setFetchedK0 sets the entry and rotates if threshold exceeded.
496 func setFetchedK0(pk string, v bool) {
497 fetchedK0[pk] = v
498 if v && len(fetchedK0) > rotateFetchedMax {
499 fetchedK0Old = fetchedK0
500 fetchedK0 = map[string]bool{}
501 }
502 }
503
504 // lookupFetchedK10k checks current and old generation.
505 func lookupFetchedK10k(pk string) (ok bool) {
506 if fetchedK10k[pk] {
507 return true
508 }
509 return fetchedK10kOld[pk]
510 }
511
512 // setFetchedK10k sets the entry and rotates if threshold exceeded.
513 func setFetchedK10k(pk string, v bool) {
514 fetchedK10k[pk] = v
515 if v && len(fetchedK10k) > rotateFetchedMax {
516 fetchedK10kOld = fetchedK10k
517 fetchedK10k = map[string]bool{}
518 }
519 }
520
521 // rotateReplyCaches swaps the reply caches.
522 func rotateReplyCaches() {
523 replyCacheOld = replyCache
524 replyCache = map[string]string{}
525 replyAvatarCacheOld = replyAvatarCache
526 replyAvatarCache = map[string]string{}
527 replyLineCacheOld = replyLineCache
528 replyLineCache = map[string]string{}
529 replyAuthorMapOld = replyAuthorMap
530 replyAuthorMap = map[string]string{}
531 replyNeedNameOld = replyNeedName
532 replyNeedName = map[string][]dom.Element{}
533 replyHintsOld = replyHints
534 replyHints = map[string]string{}
535 replyCount = 0
536 }
537
538 // lookupAuthorName returns the cached display name, checking both generations.
539 func lookupAuthorName(pk string) (string, bool) {
540 if name, ok := authorNames[pk]; ok {
541 return name, true
542 }
543 name, ok := authorNamesOld[pk]
544 return name, ok
545 }
546
547 // lookupAuthorPic returns the cached avatar URL, checking both generations.
548 func lookupAuthorPic(pk string) (string, bool) {
549 if pic, ok := authorPics[pk]; ok {
550 return pic, true
551 }
552 pic, ok := authorPicsOld[pk]
553 return pic, ok
554 }
555
556 // lookupEvent returns the cached event, checking both generations.
557 func lookupEvent(id string) (*nostr.Event, bool) {
558 if ev, ok := eventCache[id]; ok {
559 return ev, true
560 }
561 ev, ok := eventCacheOld[id]
562 return ev, ok
563 }
564
565 // setEvent stores an event in the cache and rotates if the threshold is reached.
566 func setEvent(id string, ev *nostr.Event) {
567 if _, already := eventCache[id]; !already {
568 eventCacheCount++
569 if eventCacheCount > rotateEventMax {
570 rotateEventCaches()
571 }
572 }
573 eventCache[id] = ev
574 }
575
576 // setAuthorName caches a display name, rotating if the view cache exceeds the threshold.
577 func setAuthorName(pk, name string) {
578 if _, already := authorNames[pk]; !already {
579 authorCacheCount++
580 if authorCacheCount > rotateAuthorMax {
581 rotateAuthorCaches()
582 }
583 }
584 authorNames[pk] = name
585 }
586
587 // setAuthorPic caches an avatar URL.
588 func setAuthorPic(pk, pic string) {
589 authorPics[pk] = pic
590 }
591
592 const orlyRelay = "wss://relay.orly.dev"
593
594 var defaultRelays = []string{
595 orlyRelay,
596 "wss://nostr.wine",
597 }
598
599 func isLocalDev() (ok bool) {
600 h := dom.Hostname()
601 return h == "localhost" || (len(h) > 4 && h[:4] == "127.")
602 }
603
604 func localRelayURL() (s string) {
605 h := dom.Hostname()
606 p := dom.Port()
607 if p == "" || p == "443" || p == "80" {
608 return "wss://" | h
609 }
610 return "ws://" | h | ":" | p
611 }
612
613 func main() {
614 dom.ConsoleLog("starting smesh " | version)
615 dom.FetchText("/__version", func(body string) {
616 if body == "" {
617 return
618 }
619 sv := helpers.JsonGetString(body, "v")
620 if sv == "" {
621 return
622 }
623 sbase := sv
624 for i := 0; i < len(sv); i++ {
625 if sv[i] == '+' {
626 sbase = sv[:i]
627 break
628 }
629 }
630 cbase := version
631 if len(cbase) > 0 && cbase[0] == 'v' {
632 cbase = cbase[1:]
633 }
634 if sbase != cbase {
635 dom.ConsoleLog("version mismatch: local=" | version | " server=" | sv)
636 }
637 })
638 initLang()
639 loadRelayPolicy()
640 // Always add the host relay as first fallback - it's the smesh relay serving this page.
641 localRelay := localRelayURL()
642 defaultRelays = []string{localRelay} | defaultRelays
643 dom.ConsoleLog("local relay: " | localRelay)
644 blossomServerURL = localstorage.GetItem(lsKeyBlossomServer)
645 invidiousURL = localstorage.GetItem(lsKeyInvidiousURL)
646 campionURL = localstorage.GetItem(lsKeyCampionURL)
647 devMode = localstorage.GetItem(lsKeyDevMode) == "1"
648 themePref := localstorage.GetItem(lsKeyTheme)
649 if themePref != "" {
650 isDark = themePref == "dark"
651 } else {
652 isDark = true
653 }
654 htmlEl := dom.QuerySelector("html")
655 if isDark {
656 dom.AddClass(htmlEl, "dark")
657 } else {
658 dom.RemoveClass(htmlEl, "dark")
659 }
660 root = dom.GetElementById("app-root")
661 dom.SetInnerHTML(root, "")
662 dom.SetAttribute(root, "data-version", version)
663 savedPK := localstorage.GetItem(lsKeyPubkey)
664 if savedPK != "" {
665 initSigner()
666 signer.IsInstalled(func(ok bool) {
667 if !ok {
668 localstorage.RemoveItem(lsKeyPubkey)
669 showLogin()
670 return
671 }
672 signer.GetVaultStatus(func(status string) {
673 if status != "unlocked" {
674 localstorage.RemoveItem(lsKeyPubkey)
675 showLogin()
676 return
677 }
678 signer.SwitchIdentity(savedPK, func(ok bool) {
679 if !ok {
680 localstorage.RemoveItem(lsKeyPubkey)
681 showLogin()
682 return
683 }
684 pubhex = savedPK
685 bootstrapEncKey(func() {
686 showApp()
687 })
688 })
689 })
690 })
691 } else {
692 showLogin()
693 }
694 }
695
696 // --- Theme ---
697
698 func toggleTheme() {
699 el := dom.QuerySelector("html")
700 isDark = !isDark
701 if isDark {
702 dom.AddClass(el, "dark")
703 localstorage.SetItem(lsKeyTheme, "dark")
704 } else {
705 dom.RemoveClass(el, "dark")
706 localstorage.SetItem(lsKeyTheme, "light")
707 }
708 updateThemeIcon()
709 }
710
711 // logActivity appends an entry to the activity log. summary is the one-line
712 // title shown in the list; detail is multi-line text shown when the entry is
713 // expanded (can be empty). Newest entries first. Caps at activityLogMax.
714 func logActivity(action, summary, detail string) {
715 e := &logEntry{
716 ts: dom.NowSeconds(),
717 action: action,
718 summary: summary,
719 detail: detail,
720 }
721 activityLog = []*logEntry{e} | activityLog
722 if len(activityLog) > activityLogMax {
723 activityLog = activityLog[:activityLogMax]
724 }
725 if logListEl != 0 {
726 prependLogEntry(e)
727 }
728 }
729
730 // updateLogBtnHighlight sets the log button color: accent while boot is in
731 // progress, muted otherwise.
732 func updateLogBtnHighlight() {
733 if logBtn == 0 {
734 return
735 }
736 if bootInProgress {
737 dom.SetStyle(logBtn, "color", "var(--accent)")
738 } else {
739 dom.SetStyle(logBtn, "color", "var(--muted)")
740 }
741 }
742
743 // bootDone marks bootstrap as complete and transitions the UI from the
744 // activity log page to the feed. Idempotent - extra calls are no-ops.
745 func bootDone() {
746 if !bootInProgress {
747 return
748 }
749 bootInProgress = false
750 logActivity("boot", "bootstrap complete", "")
751 updateLogBtnHighlight()
752 if activePage == "log" {
753 switchPage("feed")
754 }
755 }
756
757 // formatHMS returns HH:MM:SS local time for unix seconds ts.
758 func formatHMS(ts int64) (s string) {
759 off := int64(dom.TimezoneOffsetSeconds())
760 local := ts + off
761 if local < 0 {
762 local = 0
763 }
764 day := local % 86400
765 h := day / 3600
766 m := (day % 3600) / 60
767 sec := day % 60
768 pad := func(v int64) string {
769 if v < 10 {
770 return "0" | i64toa(v)
771 }
772 return i64toa(v)
773 }
774 return pad(h) | ":" | pad(m) | ":" | pad(sec)
775 }
776
777 // prependLogEntry inserts a single log entry row at the top of logListEl.
778 // The row shows "HH:MM:SS [action] summary" and toggles detail on click.
779 func prependLogEntry(e *logEntry) {
780 row := dom.CreateElement("div")
781 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
782 dom.SetStyle(row, "padding", "4px 0")
783
784 header := dom.CreateElement("div")
785 dom.SetStyle(header, "cursor", "pointer")
786 dom.SetStyle(header, "display", "flex")
787 dom.SetStyle(header, "gap", "8px")
788 dom.SetStyle(header, "alignItems", "baseline")
789
790 tsSpan := dom.CreateElement("span")
791 dom.SetTextContent(tsSpan, formatHMS(e.ts))
792 dom.SetStyle(tsSpan, "color", "var(--muted)")
793 dom.SetStyle(tsSpan, "flexShrink", "0")
794 dom.AppendChild(header, tsSpan)
795
796 actSpan := dom.CreateElement("span")
797 dom.SetTextContent(actSpan, "[" | e.action | "]")
798 dom.SetStyle(actSpan, "color", "var(--accent)")
799 dom.SetStyle(actSpan, "flexShrink", "0")
800 dom.AppendChild(header, actSpan)
801
802 sumSpan := dom.CreateElement("span")
803 dom.SetTextContent(sumSpan, e.summary)
804 dom.SetStyle(sumSpan, "color", "var(--fg)")
805 dom.SetStyle(sumSpan, "overflow", "hidden")
806 dom.SetStyle(sumSpan, "textOverflow", "ellipsis")
807 dom.SetStyle(sumSpan, "whiteSpace", "nowrap")
808 dom.AppendChild(header, sumSpan)
809
810 dom.AppendChild(row, header)
811
812 if e.detail != "" {
813 detailEl := dom.CreateElement("pre")
814 dom.SetTextContent(detailEl, e.detail)
815 dom.SetStyle(detailEl, "display", "none")
816 dom.SetStyle(detailEl, "margin", "4px 0 0 0")
817 dom.SetStyle(detailEl, "padding", "6px 8px")
818 dom.SetStyle(detailEl, "background", "var(--bg2)")
819 dom.SetStyle(detailEl, "color", "var(--muted)")
820 dom.SetStyle(detailEl, "fontSize", "11px")
821 dom.SetStyle(detailEl, "whiteSpace", "pre-wrap")
822 dom.SetStyle(detailEl, "wordBreak", "break-all")
823 dom.SetStyle(detailEl, "borderRadius", "3px")
824 dom.AppendChild(row, detailEl)
825 open := false
826 dom.AddEventListener(header, "click", dom.RegisterCallback(func() {
827 open = !open
828 if open {
829 dom.SetStyle(detailEl, "display", "block")
830 } else {
831 dom.SetStyle(detailEl, "display", "none")
832 }
833 }))
834 }
835
836 // Insert at top of list.
837 first := dom.FirstChild(logListEl)
838 if first == 0 {
839 dom.AppendChild(logListEl, row)
840 } else {
841 dom.InsertBefore(logListEl, row, first)
842 }
843 }
844
845 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>`
846 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>`
847
848 func updateThemeIcon() {
849 if themeBtn == 0 {
850 return // not yet created (login screen)
851 }
852 if isDark {
853 dom.SetInnerHTML(themeBtn, svgSun)
854 } else {
855 dom.SetInnerHTML(themeBtn, svgMoon)
856 }
857 }
858
859 // --- Login screen ---
860
861 func isMobile() (ok bool) {
862 ua := dom.UserAgent()
863 n := len(ua)
864 for i := 0; i+7 <= n; i++ {
865 if ua[i:i+7] == "Android" {
866 return true
867 }
868 }
869 for i := 0; i+6 <= n; i++ {
870 if ua[i:i+6] == "Mobile" {
871 return true
872 }
873 }
874 return false
875 }
876
877 func showAboutModal() {
878 switchPage("about")
879 }
880
881 func showLogin() {
882 clearChildren(root)
883
884 // Allow scrolling on login page (body has overflow:hidden for the app).
885 dom.SetStyle(dom.Body(), "overflow", "auto")
886
887 wrap := dom.CreateElement("div")
888 dom.SetStyle(wrap, "display", "flex")
889 dom.SetStyle(wrap, "alignItems", "center")
890 dom.SetStyle(wrap, "flexDirection", "column")
891 dom.SetStyle(wrap, "padding", "48px 16px")
892
893 // Smesh logo.
894 logoDiv := dom.CreateElement("div")
895 dom.SetStyle(logoDiv, "width", "180px")
896 dom.SetStyle(logoDiv, "height", "180px")
897 dom.SetStyle(logoDiv, "marginBottom", "16px")
898 dom.SetStyle(logoDiv, "color", "var(--accent)")
899 dom.FetchText("/smesh-logo.svg", func(svg string) {
900 dom.SetInnerHTML(logoDiv, svg)
901 svgEl := dom.FirstElementChild(logoDiv)
902 if svgEl != 0 {
903 dom.SetAttribute(svgEl, "width", "100%")
904 dom.SetAttribute(svgEl, "height", "100%")
905 }
906 })
907 dom.AppendChild(wrap, logoDiv)
908
909 // Title.
910 h1 := dom.CreateElement("h1")
911 dom.SetTextContent(h1, "S.M.E.S.H.")
912 dom.SetStyle(h1, "color", "var(--accent)")
913 dom.SetStyle(h1, "fontSize", "48px")
914 dom.SetStyle(h1, "marginBottom", "4px")
915 dom.AppendChild(wrap, h1)
916
917 verTag := dom.CreateElement("span")
918 dom.SetTextContent(verTag, version)
919 dom.SetStyle(verTag, "color", "var(--muted)")
920 dom.SetStyle(verTag, "fontSize", "12px")
921 dom.AppendChild(wrap, verTag)
922
923 sub := dom.CreateElement("p")
924 dom.SetStyle(sub, "color", "var(--muted)")
925 dom.SetStyle(sub, "fontSize", "14px")
926 dom.SetStyle(sub, "marginBottom", "32px")
927 dom.SetStyle(sub, "textAlign", "center")
928 dom.SetTextContent(sub, t("subtitle"))
929 dom.AppendChild(wrap, sub)
930
931 // Show login flow.
932 errEl := dom.CreateElement("div")
933 dom.SetStyle(errEl, "color", "#e55")
934 dom.SetStyle(errEl, "fontSize", "13px")
935 dom.SetStyle(errEl, "marginBottom", "12px")
936 dom.SetStyle(errEl, "minHeight", "18px")
937 dom.AppendChild(wrap, errEl)
938
939 btn := dom.CreateElement("button")
940 dom.SetTextContent(btn, t("login"))
941 dom.SetAttribute(btn, "type", "button")
942 dom.SetStyle(btn, "padding", "10px 32px")
943 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
944 dom.SetStyle(btn, "fontSize", "14px")
945 dom.SetStyle(btn, "background", "var(--accent)")
946 dom.SetStyle(btn, "color", "#fff")
947 dom.SetStyle(btn, "border", "none")
948 dom.SetStyle(btn, "borderRadius", "4px")
949 dom.SetStyle(btn, "cursor", "pointer")
950 dom.AppendChild(wrap, btn)
951
952
953 appendLoginFooter(wrap)
954 dom.SetStyle(wrap, "paddingBottom", "48px")
955 dom.AppendChild(root, wrap)
956
957 buildStatusBar(false)
958 initSigner()
959
960 // Auto-login: if signer is ready with exactly one usable identity, skip the login page.
961 signer.IsInstalled(func(ok bool) {
962 if !ok {
963 return
964 }
965 signer.GetVaultStatus(func(status string) {
966 if status != "unlocked" {
967 return
968 }
969 signer.GetPublicKey(func(hex string) {
970 if hex == "" {
971 return
972 }
973 dom.ConsoleLog("[login] auto-login pubkey: " | hex)
974 pubhex = hex
975 localstorage.SetItem(lsKeyPubkey, pubhex)
976 clearChildren(root)
977 bootstrapEncKey(func() {
978 showApp()
979 })
980 })
981 })
982 })
983
984 cb := dom.RegisterCallback(func() {
985 signer.GetVaultStatus(func(status string) {
986 if status == "none" || status == "locked" {
987 toggleSignerPanel()
988 return
989 }
990 dom.SetTextContent(btn, t("requesting"))
991 signer.GetPublicKey(func(hex string) {
992 if hex == "" {
993 dom.SetTextContent(errEl, t("err_no_id"))
994 dom.SetTextContent(btn, t("login"))
995 toggleSignerPanel()
996 return
997 }
998 dom.ConsoleLog("[login] manual-login pubkey: " | hex)
999 pubhex = hex
1000 localstorage.SetItem(lsKeyPubkey, pubhex)
1001 clearChildren(root)
1002 bootstrapEncKey(func() {
1003 showApp()
1004 })
1005 })
1006 })
1007 })
1008 dom.AddEventListener(btn, "click", cb)
1009 }
1010
1011 func appendLoginFooter(wrap dom.Element) {
1012 footer := dom.CreateElement("div")
1013 dom.SetStyle(footer, "display", "flex")
1014 dom.SetStyle(footer, "flexDirection", "column")
1015 dom.SetStyle(footer, "gap", "12px")
1016 dom.SetStyle(footer, "marginTop", "24px")
1017 dom.SetStyle(footer, "alignItems", "center")
1018
1019 // Language chooser.
1020 langBox := dom.CreateElement("div")
1021 dom.SetStyle(langBox, "display", "flex")
1022 dom.SetStyle(langBox, "alignItems", "center")
1023 dom.SetStyle(langBox, "gap", "6px")
1024
1025 langLabel := dom.CreateElement("span")
1026 dom.SetTextContent(langLabel, t("language"))
1027 dom.SetStyle(langLabel, "fontSize", "12px")
1028 dom.SetStyle(langLabel, "color", "var(--muted)")
1029 dom.AppendChild(langBox, langLabel)
1030
1031 langSel := dom.CreateElement("select")
1032 dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
1033 dom.SetStyle(langSel, "fontSize", "12px")
1034 dom.SetStyle(langSel, "background", "var(--bg2)")
1035 dom.SetStyle(langSel, "color", "var(--fg)")
1036 dom.SetStyle(langSel, "border", "1px solid var(--border)")
1037 dom.SetStyle(langSel, "borderRadius", "4px")
1038 dom.SetStyle(langSel, "padding", "4px 8px")
1039 for code, name := range langNames {
1040 opt := dom.CreateElement("option")
1041 dom.SetAttribute(opt, "value", code)
1042 dom.SetTextContent(opt, name)
1043 if code == currentLang {
1044 dom.SetAttribute(opt, "selected", "selected")
1045 }
1046 dom.AppendChild(langSel, opt)
1047 }
1048 dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
1049 val := dom.GetProperty(langSel, "value")
1050 setLang(val)
1051 showLogin() // re-render with new language
1052 }))
1053 dom.AppendChild(langBox, langSel)
1054 dom.AppendChild(footer, langBox)
1055
1056 // Theme toggle.
1057 themeBox := dom.CreateElement("div")
1058 dom.SetStyle(themeBox, "display", "flex")
1059 dom.SetStyle(themeBox, "alignItems", "center")
1060 dom.SetStyle(themeBox, "gap", "6px")
1061
1062 themeLabel := dom.CreateElement("span")
1063 dom.SetTextContent(themeLabel, t("theme"))
1064 dom.SetStyle(themeLabel, "fontSize", "12px")
1065 dom.SetStyle(themeLabel, "color", "var(--muted)")
1066 dom.AppendChild(themeBox, themeLabel)
1067
1068 themeToggle := dom.CreateElement("button")
1069 if isDark {
1070 dom.SetTextContent(themeToggle, t("light"))
1071 } else {
1072 dom.SetTextContent(themeToggle, t("dark"))
1073 }
1074 dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
1075 dom.SetStyle(themeToggle, "fontSize", "12px")
1076 dom.SetStyle(themeToggle, "background", "var(--bg2)")
1077 dom.SetStyle(themeToggle, "color", "var(--fg)")
1078 dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
1079 dom.SetStyle(themeToggle, "borderRadius", "4px")
1080 dom.SetStyle(themeToggle, "padding", "4px 12px")
1081 dom.SetStyle(themeToggle, "cursor", "pointer")
1082 dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
1083 toggleTheme()
1084 if isDark {
1085 dom.SetTextContent(themeToggle, t("light"))
1086 } else {
1087 dom.SetTextContent(themeToggle, t("dark"))
1088 }
1089 }))
1090 dom.AppendChild(themeBox, themeToggle)
1091 dom.AppendChild(footer, themeBox)
1092
1093 // Hard refresh button - clears SW cache and reloads.
1094 optBox := dom.CreateElement("div")
1095 dom.SetStyle(optBox, "display", "flex")
1096 dom.SetStyle(optBox, "alignItems", "center")
1097 dom.SetStyle(optBox, "gap", "4px")
1098 refreshBtn := dom.CreateElement("button")
1099 dom.SetTextContent(refreshBtn, "\u21bb")
1100 dom.SetStyle(refreshBtn, "fontSize", "14px")
1101 dom.SetStyle(refreshBtn, "background", "transparent")
1102 dom.SetStyle(refreshBtn, "border", "1px solid var(--border)")
1103 dom.SetStyle(refreshBtn, "borderRadius", "4px")
1104 dom.SetStyle(refreshBtn, "color", "var(--muted)")
1105 dom.SetStyle(refreshBtn, "cursor", "pointer")
1106 dom.SetStyle(refreshBtn, "padding", "2px 8px")
1107 dom.SetStyle(refreshBtn, "marginLeft", "4px")
1108 dom.AddEventListener(refreshBtn, "click", dom.RegisterCallback(func() {
1109 dom.HardRefresh()
1110 }))
1111 dom.AppendChild(optBox, refreshBtn)
1112
1113 dom.AppendChild(footer, optBox)
1114
1115 // Dev mode toggle - disables translation worker.
1116 devBox := dom.CreateElement("div")
1117 dom.SetStyle(devBox, "display", "flex")
1118 dom.SetStyle(devBox, "alignItems", "center")
1119 dom.SetStyle(devBox, "gap", "6px")
1120
1121 devLabel := dom.CreateElement("span")
1122 dom.SetTextContent(devLabel, "dev mode")
1123 dom.SetStyle(devLabel, "fontSize", "12px")
1124 dom.SetStyle(devLabel, "color", "var(--muted)")
1125 dom.AppendChild(devBox, devLabel)
1126
1127 devToggle := dom.CreateElement("button")
1128 if devMode {
1129 dom.SetTextContent(devToggle, "on")
1130 dom.SetStyle(devToggle, "color", "var(--accent)")
1131 } else {
1132 dom.SetTextContent(devToggle, "off")
1133 dom.SetStyle(devToggle, "color", "var(--muted)")
1134 }
1135 dom.SetStyle(devToggle, "fontFamily", "'Fira Code', monospace")
1136 dom.SetStyle(devToggle, "fontSize", "12px")
1137 dom.SetStyle(devToggle, "background", "var(--bg2)")
1138 dom.SetStyle(devToggle, "border", "1px solid var(--border)")
1139 dom.SetStyle(devToggle, "borderRadius", "4px")
1140 dom.SetStyle(devToggle, "padding", "4px 12px")
1141 dom.SetStyle(devToggle, "cursor", "pointer")
1142 dom.AddEventListener(devToggle, "click", dom.RegisterCallback(func() {
1143 devMode = !devMode
1144 if devMode {
1145 localstorage.SetItem(lsKeyDevMode, "1")
1146 dom.SetTextContent(devToggle, "on")
1147 dom.SetStyle(devToggle, "color", "var(--accent)")
1148 } else {
1149 localstorage.RemoveItem(lsKeyDevMode)
1150 dom.SetTextContent(devToggle, "off")
1151 dom.SetStyle(devToggle, "color", "var(--muted)")
1152 }
1153 }))
1154 dom.AppendChild(devBox, devToggle)
1155 dom.AppendChild(footer, devBox)
1156
1157 dom.AppendChild(wrap, footer)
1158 }
1159
1160 // --- Sidebar ---
1161
1162 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>`
1163 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>`
1164 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>`
1165 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>`
1166 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>`
1167
1168 // Action button SVGs - same stroke style as sidebar icons.
1169 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>`
1170 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>`
1171 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>`
1172 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>`
1173 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>`
1174
1175 func makeSidebarIcon(svgHTML string, active bool) (e dom.Element) {
1176 btn := dom.CreateElement("button")
1177 dom.SetStyle(btn, "width", "36px")
1178 dom.SetStyle(btn, "height", "36px")
1179 dom.SetStyle(btn, "border", "none")
1180 dom.SetStyle(btn, "borderRadius", "6px")
1181 dom.SetStyle(btn, "cursor", "pointer")
1182 dom.SetStyle(btn, "display", "flex")
1183 dom.SetStyle(btn, "alignItems", "center")
1184 dom.SetStyle(btn, "justifyContent", "center")
1185 dom.SetStyle(btn, "padding", "0")
1186 dom.SetStyle(btn, "color", "var(--fg)")
1187 if active {
1188 dom.SetStyle(btn, "background", "var(--accent)")
1189 dom.SetStyle(btn, "color", "#fff")
1190 } else {
1191 dom.SetStyle(btn, "background", "transparent")
1192 }
1193 dom.SetInnerHTML(btn, svgHTML)
1194 return btn
1195 }
1196
1197 // teardownPage sends CLOSE for page-specific subscriptions on page exit.
1198 // stopInlineMedia restores the clickable YouTube thumbnail for any active
1199 // Invidious inline embeds. The wrapper's original click handler (registered by
1200 // attachYTLightboxes) is still in place so tapping the thumbnail re-embeds.
1201 func stopInlineMedia(container dom.Element) {
1202 el := dom.QuerySelectorFrom(container, "[data-inv-ytid]")
1203 for el != 0 {
1204 ytID := dom.GetAttribute(el, "data-inv-ytid")
1205 dom.RemoveAttribute(el, "data-inv-ytid")
1206
1207 // Clear iframe + controls.
1208 dom.ReleaseChildren(el)
1209 dom.SetInnerHTML(el, "")
1210
1211 // Restore the YouTube thumbnail so the user can tap to re-embed.
1212 if ytID != "" {
1213 thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
1214 img := dom.CreateElement("img")
1215 dom.SetAttribute(img, "referrerpolicy", "no-referrer")
1216 dom.SetStyle(img, "display", "block")
1217 dom.SetStyle(img, "maxWidth", "100%")
1218 dom.SetStyle(img, "borderRadius", "8px")
1219 dom.SetStyle(img, "objectFit", "contain")
1220 setMediaSrc(img, thumbURL)
1221 dom.AppendChild(el, img)
1222 }
1223 dom.SetStyle(el, "cursor", "pointer")
1224
1225 el = dom.QuerySelectorFrom(container, "[data-inv-ytid]")
1226 }
1227 }
1228
1229 func teardownPage(page string) {
1230 if page == "feed" {
1231 stopInlineMedia(feedContainer)
1232 stopInlineMedia(threadContainer)
1233 }
1234 if page == "search" {
1235 routeMsg("[\"CLOSE\",\"search\"]")
1236 searchSeen = map[string]bool{}
1237 // Always restore topBarRight and other hidden elements on exit.
1238 if searchBarActive {
1239 deactivateSearchBar(searchBtnGlobal)
1240 }
1241 }
1242 if page == "notifications" {
1243 // Cancel pending timers so their closures don't fire after page exit.
1244 if notifMoreTimer != 0 {
1245 dom.ClearTimeout(notifMoreTimer)
1246 notifMoreTimer = 0
1247 }
1248 if notifRefTimer != 0 {
1249 dom.ClearTimeout(notifRefTimer)
1250 notifRefTimer = 0
1251 }
1252 // Release accumulated notif row callbacks to prevent indefinite
1253 // growth of the Moxie callback table across multiple page visits.
1254 releaseNotifRowCBs()
1255 notifBuilt = false
1256 // Clear avatar pending queue; the DOM elements are gone.
1257 for pk := range pendingNotifAvatars {
1258 delete(pendingNotifAvatars, pk)
1259 }
1260 }
1261 }
1262
1263 func switchPage(name string) {
1264 closeFeedPopover()
1265 // Always close thread if open, even when re-selecting the feed tab.
1266 if threadOpen {
1267 closeNoteThread()
1268 if !navPop {
1269 dom.ReplaceState("/")
1270 }
1271 }
1272 if name == activePage {
1273 return
1274 }
1275 teardownPage(activePage)
1276 closeProfileNoteSub()
1277 if activePage == "profile" {
1278 profileViewPK = ""
1279 }
1280
1281 activePage = name
1282
1283 // Ensure top bar is in normal state.
1284 dom.SetStyle(topBackBtn, "display", "none")
1285 dom.SetStyle(feedSelectEl, "display", "none")
1286 dom.SetStyle(pageTitleEl, "display", "none")
1287 dom.SetStyle(notifBarGroup, "display", "none")
1288 if name == "feed" {
1289 dom.SetStyle(feedSelectEl, "display", "inline")
1290 } else if name == "notifications" {
1291 dom.SetStyle(notifBarGroup, "display", "flex")
1292 } else {
1293 dom.SetTextContent(pageTitleEl, t(name))
1294 dom.SetStyle(pageTitleEl, "display", "inline")
1295 }
1296
1297 // Hide all pages.
1298 dom.SetStyle(feedPage, "display", "none")
1299 dom.SetStyle(msgPage, "display", "none")
1300 dom.SetStyle(profilePage, "display", "none")
1301 dom.SetStyle(settingsPage, "display", "none")
1302 dom.SetStyle(composePage, "display", "none")
1303 dom.SetStyle(aboutPage, "display", "none")
1304 dom.SetStyle(notifPage, "display", "none")
1305 dom.SetStyle(searchPage, "display", "none")
1306 if logPage != 0 {
1307 dom.SetStyle(logPage, "display", "none")
1308 }
1309
1310 // Clear sidebar highlights.
1311 dom.SetStyle(sidebarCompose, "background", "transparent")
1312 dom.SetStyle(sidebarCompose, "color", "var(--fg)")
1313 dom.SetStyle(sidebarFeed, "background", "transparent")
1314 dom.SetStyle(sidebarFeed, "color", "var(--fg)")
1315 dom.SetStyle(sidebarMsg, "background", "transparent")
1316 dom.SetStyle(sidebarMsg, "color", "var(--fg)")
1317 dom.SetStyle(sidebarNotif, "background", "transparent")
1318 dom.SetStyle(sidebarNotif, "color", "var(--fg)")
1319 dom.SetStyle(sidebarSettings, "background", "transparent")
1320 dom.SetStyle(sidebarSettings, "color", "var(--fg)")
1321
1322 switch name {
1323 case "compose":
1324 dom.SetStyle(composePage, "display", "flex")
1325 dom.SetStyle(composePage, "flexDirection", "column")
1326 dom.SetStyle(sidebarCompose, "background", "var(--accent)")
1327 dom.SetStyle(sidebarCompose, "color", "#fff")
1328 if !navPop {
1329 dom.PushState("/compose")
1330 }
1331 case "feed":
1332 dom.SetStyle(feedPage, "display", "block")
1333 dom.SetStyle(sidebarFeed, "background", "var(--accent)")
1334 dom.SetStyle(sidebarFeed, "color", "#fff")
1335 if !navPop {
1336 dom.PushState(feedModeURL())
1337 }
1338 case "messaging":
1339 dom.SetStyle(msgPage, "display", "block")
1340 dom.SetStyle(sidebarMsg, "background", "var(--accent)")
1341 dom.SetStyle(sidebarMsg, "color", "#fff")
1342 initMessaging()
1343 if !navPop {
1344 dom.PushState("/msg")
1345 }
1346 case "notifications":
1347 dom.SetStyle(notifPage, "display", "block")
1348 dom.SetStyle(sidebarNotif, "background", "var(--accent)")
1349 dom.SetStyle(sidebarNotif, "color", "#fff")
1350 notifUnread = false
1351 dom.SetStyle(notifDot, "display", "none")
1352 dom.SetProperty(contentArea, "scrollTop", "0")
1353 if !notifBuilt {
1354 renderNotifPage()
1355 }
1356 if !navPop {
1357 dom.PushState("/notifications")
1358 }
1359 case "settings":
1360 dom.SetStyle(settingsPage, "display", "block")
1361 dom.SetStyle(sidebarSettings, "background", "var(--accent)")
1362 dom.SetStyle(sidebarSettings, "color", "#fff")
1363 renderSettings()
1364 if !navPop {
1365 dom.PushState("/settings")
1366 }
1367 case "profile":
1368 dom.SetStyle(profilePage, "display", "block")
1369 dom.SetStyle(pageTitleEl, "display", "none")
1370 dom.SetStyle(topBackBtn, "display", "inline")
1371 // Profile URL is pushed by showProfile, not here.
1372 case "about":
1373 dom.SetStyle(aboutPage, "display", "block")
1374 if !navPop {
1375 dom.PushState("/about")
1376 }
1377 case "log":
1378 dom.SetStyle(logPage, "display", "block")
1379 dom.SetTextContent(pageTitleEl, "activity log")
1380 dom.SetStyle(pageTitleEl, "display", "inline")
1381 if !navPop {
1382 dom.PushState("/log")
1383 }
1384 case "search":
1385 dom.SetStyle(searchPage, "display", "block")
1386 dom.SetStyle(pageTitleEl, "display", "none")
1387 // Restore search input value without activating the bar (topBarRight
1388 // stays visible). Bar is only activated by explicit user action.
1389 if searchCurrentQ != "" && searchBtnGlobal != 0 {
1390 dom.SetProperty(searchInputEl, "value", searchCurrentQ)
1391 }
1392 if !navPop {
1393 if searchCurrentQ != "" {
1394 dom.PushState("/search?q=" | dom.EncodeURIComponent(searchCurrentQ))
1395 } else {
1396 dom.PushState("/search")
1397 }
1398 }
1399 }
1400 }
1401
1402 // navigateToPath handles URL-based routing for back/forward and initial load.
1403 // fullPath may include a hash fragment, e.g. "/p/npub1...#follows".
1404 func navigateToPath(fullPath string) {
1405 path := fullPath
1406 hash := ""
1407 for i := 0; i < len(fullPath); i++ {
1408 if fullPath[i] == '#' {
1409 path = fullPath[:i]
1410 hash = fullPath[i+1:]
1411 break
1412 }
1413 }
1414
1415 feedPath := false
1416 newFeedMode := ""
1417 if path == "/" || path == "/feed" || path == "" || path == "/feed/follows" {
1418 feedPath = true
1419 newFeedMode = "follows"
1420 } else if path == "/feed/relays" {
1421 feedPath = true
1422 newFeedMode = "relays"
1423 } else if len(path) > 12 && path[:12] == "/feed/relay/" {
1424 feedPath = true
1425 host := path[12:]
1426 // Local addresses use ws://, remote use wss://.
1427 if len(host) > 4 && host[:4] == "127." || len(host) > 10 && host[:10] == "localhost:" {
1428 newFeedMode = "ws://" | host
1429 } else {
1430 newFeedMode = "wss://" | host
1431 }
1432 }
1433 if feedPath {
1434 modeChanged := newFeedMode != feedMode
1435 feedMode = newFeedMode
1436 if feedSelectEl != 0 {
1437 updateFeedBtnText()
1438 }
1439 if threadOpen {
1440 closeNoteThread()
1441 }
1442 switchPage("feed")
1443 if modeChanged {
1444 refreshFeed()
1445 }
1446 } else if len(path) > 3 && path[:3] == "/t/" {
1447 switchPage("feed")
1448 rootID := path[3:]
1449 if len(rootID) == 64 {
1450 focusID := rootID
1451 hash := dom.GetHash()
1452 if len(hash) == 65 && hash[0] == '#' {
1453 focusID = hash[1:]
1454 }
1455 showNoteThread(rootID, focusID)
1456 }
1457 } else if path == "/compose" {
1458 switchPage("compose")
1459 } else if path == "/settings" {
1460 switchPage("settings")
1461 } else if path == "/msg" {
1462 switchPage("messaging")
1463 if msgView == "thread" {
1464 closeThread()
1465 }
1466 } else if len(path) > 5 && path[:5] == "/msg/" {
1467 pk := npubToHex(path[5:])
1468 if pk != "" {
1469 switchPage("messaging")
1470 openThread(pk)
1471 }
1472 } else if path == "/notifications" {
1473 switchPage("notifications")
1474 } else if path == "/search" || (len(path) >= 8 && path[:8] == "/search?") {
1475 // Parse ?q= parameter.
1476 q := ""
1477 qi := strIndex(path, "?q=")
1478 if qi >= 0 {
1479 q = path[qi+3:]
1480 // URL-decode the query (basic: replace %20 etc.) - use raw for now.
1481 }
1482 if q != "" {
1483 executeSearch(q)
1484 } else {
1485 switchPage("search")
1486 }
1487 } else if path == "/about" {
1488 switchPage("about")
1489 } else if path == "/log" {
1490 switchPage("log")
1491 } else if len(path) > 3 && path[:3] == "/p/" {
1492 pk := npubToHex(path[3:])
1493 if pk != "" {
1494 showProfile(pk)
1495 if hash != "" {
1496 selectProfileTab(hash, pk)
1497 }
1498 }
1499 }
1500 }
1501
1502 func npubToHex(npub string) (s string) {
1503 b := helpers.DecodeNpub(npub)
1504 if b == nil {
1505 return ""
1506 }
1507 return helpers.HexEncode(b)
1508 }
1509
1510 func initRouter() {
1511 if routerInited {
1512 return
1513 }
1514 routerInited = true
1515 dom.OnPopState(func(path string) {
1516 navPop = true
1517 navigateToPath(path)
1518 navPop = false
1519 })
1520 dom.InterceptInternalLinks(func(path string) {
1521 navigateToPath(path)
1522 })
1523
1524 // Navigate to initial URL if not root.
1525 path := dom.GetPath()
1526 if path != "/" && path != "" {
1527 navPop = true
1528 navigateToPath(path)
1529 navPop = false
1530 } else {
1531 dom.ReplaceState(feedModeURL())
1532 }
1533 }
1534
1535 func makeProtoBtn(label string) (e dom.Element) {
1536 btn := dom.CreateElement("button")
1537 dom.SetTextContent(btn, label)
1538 dom.SetStyle(btn, "padding", "6px 8px")
1539 dom.SetStyle(btn, "border", "none")
1540 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
1541 dom.SetStyle(btn, "fontSize", "12px")
1542 dom.SetStyle(btn, "cursor", "default")
1543 dom.SetStyle(btn, "background", "transparent")
1544 dom.SetStyle(btn, "color", "var(--fg)")
1545 return btn
1546 }
1547
1548 // --- Main app ---
1549
1550 func showApp() {
1551 dom.SetStyle(dom.Body(), "overflow", "hidden")
1552 appReady = false
1553 bootInProgress = true
1554 activityLog = nil
1555 seenOnSubs = map[string]func(){}
1556 logActivity("boot", "bootstrap start " | version, "pubkey: " | pubhex)
1557 // Preload NWC connections so zap button can offer NWC path without
1558 // requiring a settings-page visit.
1559 nwcRefreshList(nil)
1560 // Init profile view caches and filter sets.
1561 authorNames = map[string]string{}
1562 authorPics = map[string]string{}
1563 pendingNotes = map[string][]dom.Element{}
1564 pendingNotifAvatars = map[string][]dom.Element{}
1565 seenEvents = map[string]bool{}
1566 fetchedK0 = map[string]bool{}
1567 fetchedK0Old = map[string]bool{}
1568 fetchedK10k = map[string]bool{}
1569 fetchedK10kOld = map[string]bool{}
1570 fetchQueue = nil
1571 fetchTimer = 0
1572 retryRound = 0
1573 authorSubPK = map[string]string{}
1574 eventRelays = map[string][]string{}
1575 eventRelaysOld = map[string][]string{}
1576 pendingDeletes = map[string]string{}
1577 noteElements = map[string]dom.Element{}
1578 myFollows = nil
1579 myMutes = nil
1580 followSet = map[string]bool{}
1581 muteSet = map[string]bool{}
1582 profileContentCache = map[string]string{}
1583 profileFollowsCache = map[string][]string{}
1584 profileMutesCache = map[string][]string{}
1585 profileRelaysCache = map[string][]string{}
1586 profileNotesSeen = map[string]bool{}
1587 profileTabBtns = map[string]dom.Element{}
1588 profileWrappers = map[string]dom.Element{}
1589 profileWrapTabCont = map[string]dom.Element{}
1590 profileWrapTabs = map[string]map[string]dom.Element{}
1591 profileWrapTabSel = map[string]string{}
1592 profileWrapOrder = nil
1593 embedCallbacks = map[string][]string{}
1594 embedRelayHints = map[string][]string{}
1595 embedRequested = map[string]bool{}
1596 embedSubIDs = map[string][]string{}
1597 embedCoordCBs = map[string][]string{}
1598 embedCoordSubIDs = map[string]string{}
1599 threadEvents = map[string]*nostr.Event{}
1600 replyCache = map[string]string{}
1601 replyAvatarCache = map[string]string{}
1602 replyLineCache = map[string]string{}
1603 replyAuthorMap = map[string]string{}
1604 replyAuthorMapOld = map[string]string{}
1605 replyPending = map[string][]dom.Element{}
1606 replyHints = map[string]string{}
1607 replyNeedName = map[string][]dom.Element{}
1608 replyNeedNameOld = map[string][]dom.Element{}
1609 noteRepostCounts = map[string]map[string]bool{}
1610 noteReactionMap = map[string]map[string]map[string]bool{}
1611 noteReactionEls = map[string]dom.Element{}
1612 noteRepostEls = map[string]dom.Element{}
1613 noteZapTotals = map[string]int64{}
1614 noteZapSeen = map[string]map[string]bool{}
1615 noteZapEls = map[string]dom.Element{}
1616 pendingZapRows = map[string][]zapRowPending{}
1617 actionFetchedIDs = map[string]bool{}
1618 eventCache = map[string]*nostr.Event{}
1619 // Initialize "old" generation maps so range over nil is safe on first rotate.
1620 eventCacheOld = map[string]*nostr.Event{}
1621 seenEventsOld = map[string]bool{}
1622 noteElementsOld = map[string]dom.Element{}
1623 noteReactionMapOld = map[string]map[string]map[string]bool{}
1624 noteRepostCountsOld = map[string]map[string]bool{}
1625 noteZapSeenOld = map[string]map[string]bool{}
1626 actionFetchedIDsOld = map[string]bool{}
1627 authorNamesOld = map[string]string{}
1628 authorPicsOld = map[string]string{}
1629 embedCallbacksOld = map[string][]string{}
1630 embedRequestedOld = map[string]bool{}
1631 embedSubIDsOld = map[string][]string{}
1632 embedCoordCBsOld = map[string][]string{}
1633 replyCacheOld = map[string]string{}
1634 replyAvatarCacheOld = map[string]string{}
1635 replyLineCacheOld = map[string]string{}
1636 eventCacheCount = 0
1637 authorCacheCount = 0
1638 embedCacheCount = 0
1639 replyCount = 0
1640 editorDrafts = map[string]string{}
1641 notifSeen = map[string]bool{}
1642 notifEvents = []*nostr.Event{:0}
1643 releaseNotifRowCBs()
1644 notifUnread = false
1645 notifBuilt = false
1646 dom.IDBGet("settings", "notif-read-ts", func(val string) {
1647 if val != "" {
1648 notifReadTs = parseI64(val)
1649 }
1650 })
1651 if feedMode == "" {
1652 feedMode = "follows"
1653 }
1654 feedInitialLoad = true
1655 feedBuffer = nil
1656 initEmoji()
1657 initMentionPopup()
1658
1659 // Set up SW + relay-proxy worker communication. Both deliver into the
1660 // same dispatch (onSWMessage); message-type prefixes are unique per
1661 // source so they cannot collide.
1662 if !routerInited {
1663 dom.OnSWMessage(onSWMessage)
1664 relayproxy.OnMessage(onSWMessage)
1665 profile.OnMessage(onSWMessage)
1666 feed.OnMessage(onSWMessage)
1667 mlsw.OnMessage(onSWMessage)
1668 notif.OnMessage(onSWMessage)
1669 }
1670 routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]")
1671 sendEncKeyToSW()
1672 // Sync pubkey to MLS worker.
1673 mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]")
1674
1675 // Load cached profiles from IndexedDB.
1676 // Populate name/pic view cache and send loaded pks to Profile Worker.
1677 var idbLoadedPKs []string
1678 dom.IDBGetAll("profiles", func(key, val string) {
1679 name := helpers.JsonGetString(val, "name")
1680 if len(name) == 0 {
1681 name = helpers.JsonGetString(val, "display_name")
1682 }
1683 pic := helpers.JsonGetString(val, "picture")
1684 if len(name) > 0 {
1685 setAuthorName(key, name)
1686 }
1687 if len(pic) > 0 {
1688 setAuthorPic(key, pic)
1689 }
1690 profileContentCache[key] = val
1691 idbLoadedPKs = append(idbLoadedPKs, key)
1692 }, func() {
1693 idbLoaded = true
1694 // Fill pending note headers rendered before IDB finished.
1695 for pk, headers := range pendingNotes {
1696 name, nameOK := lookupAuthorName(pk)
1697 if !nameOK || len(name) == 0 {
1698 continue
1699 }
1700 pic, _ := lookupAuthorPic(pk)
1701 for _, h := range headers {
1702 updateNoteHeader(h, name, pic)
1703 }
1704 delete(pendingNotes, pk)
1705 }
1706 // Tell Profile Worker which pks are already cached so it skips fetching them.
1707 if len(idbLoadedPKs) > 0 {
1708 profile.Send(`["P_IDB_LOADED",` | buildJSONStrArr(idbLoadedPKs) | `]`)
1709 idbLoadedPKs = nil
1710 }
1711 // Re-render profile page if opened before IDB finished.
1712 if profileViewPK != "" && activePage == "profile" {
1713 renderProfilePage(profileViewPK)
1714 }
1715 })
1716
1717 // === Top bar ===
1718 bar := dom.CreateElement("div")
1719 dom.SetStyle(bar, "display", "flex")
1720 dom.SetStyle(bar, "alignItems", "center")
1721 dom.SetStyle(bar, "padding", "8px")
1722 dom.SetStyle(bar, "height", "48px")
1723 dom.SetStyle(bar, "boxSizing", "border-box")
1724 dom.SetStyle(bar, "background", "var(--bg2)")
1725 dom.SetStyle(bar, "position", "fixed")
1726 dom.SetStyle(bar, "top", "0")
1727 dom.SetStyle(bar, "left", "0")
1728 dom.SetStyle(bar, "right", "0")
1729 dom.SetStyle(bar, "zIndex", "100")
1730
1731 // Left: page title.
1732 left := dom.CreateElement("div")
1733 dom.SetStyle(left, "display", "flex")
1734 dom.SetStyle(left, "alignItems", "center")
1735 dom.SetStyle(left, "flex", "1")
1736 dom.SetStyle(left, "minWidth", "0")
1737
1738 // Logo in top-left.
1739 logo := dom.CreateElement("div")
1740 dom.SetStyle(logo, "width", "32px")
1741 dom.SetStyle(logo, "height", "32px")
1742 dom.SetStyle(logo, "flexShrink", "0")
1743 dom.SetStyle(logo, "color", "var(--accent)")
1744 dom.SetStyle(logo, "marginRight", "8px")
1745 dom.FetchText("/smesh-logo.svg", func(svg string) {
1746 logoSVGCache = svg
1747 dom.SetInnerHTML(logo, svg)
1748 svgEl := dom.FirstElementChild(logo)
1749 if svgEl != 0 {
1750 dom.SetAttribute(svgEl, "width", "100%")
1751 dom.SetAttribute(svgEl, "height", "100%")
1752 }
1753 // Also populate the about page logo if it was built before fetch completed.
1754 if aboutLogoEl != 0 {
1755 dom.SetInnerHTML(aboutLogoEl, svg)
1756 al := dom.FirstElementChild(aboutLogoEl)
1757 if al != 0 {
1758 dom.SetAttribute(al, "width", "100%")
1759 dom.SetAttribute(al, "height", "100%")
1760 }
1761 }
1762 })
1763 dom.SetStyle(logo, "cursor", "pointer")
1764 dom.AddEventListener(logo, "click", dom.RegisterCallback(func() {
1765 showAboutModal()
1766 }))
1767 dom.AppendChild(left, logo)
1768
1769 topBackBtn = dom.CreateElement("span")
1770 dom.SetStyle(topBackBtn, "display", "none")
1771 dom.SetStyle(topBackBtn, "cursor", "pointer")
1772 dom.SetStyle(topBackBtn, "color", "var(--accent)")
1773 dom.SetStyle(topBackBtn, "fontSize", "18px")
1774 dom.SetStyle(topBackBtn, "fontWeight", "bold")
1775 dom.SetTextContent(topBackBtn, t("back"))
1776 dom.AddEventListener(topBackBtn, "click", dom.RegisterCallback(func() {
1777 dom.Back()
1778 }))
1779 dom.AppendChild(left, topBackBtn)
1780
1781 // Feed selector button (opens popup). Absolute-positioned so it sits at
1782 // the horizontal center of the top bar, independent of logo/back width.
1783 feedSelectEl = dom.CreateElement("button")
1784 dom.SetStyle(feedSelectEl, "position", "absolute")
1785 dom.SetStyle(feedSelectEl, "left", "50%")
1786 dom.SetStyle(feedSelectEl, "top", "50%")
1787 dom.SetStyle(feedSelectEl, "transform", "translate(-50%, -50%)")
1788 dom.SetStyle(feedSelectEl, "fontSize", "16px")
1789 dom.SetStyle(feedSelectEl, "fontWeight", "bold")
1790 dom.SetStyle(feedSelectEl, "background", "transparent")
1791 dom.SetStyle(feedSelectEl, "border", "none")
1792 dom.SetStyle(feedSelectEl, "color", "var(--fg)")
1793 dom.SetStyle(feedSelectEl, "cursor", "pointer")
1794 dom.SetStyle(feedSelectEl, "outline", "none")
1795 dom.SetStyle(feedSelectEl, "maxWidth", "200px")
1796 dom.SetStyle(feedSelectEl, "padding", "4px 8px")
1797 dom.SetStyle(feedSelectEl, "borderRadius", "4px")
1798 updateFeedBtnText()
1799 dom.AddEventListener(feedSelectEl, "click", dom.RegisterCallback(func() {
1800 toggleFeedPopover()
1801 }))
1802 dom.AppendChild(bar, feedSelectEl)
1803
1804 notifFilter = "all"
1805
1806 notifBarGroup = dom.CreateElement("div")
1807 dom.SetStyle(notifBarGroup, "position", "absolute")
1808 dom.SetStyle(notifBarGroup, "left", "50%")
1809 dom.SetStyle(notifBarGroup, "top", "50%")
1810 dom.SetStyle(notifBarGroup, "transform", "translate(-50%, -50%)")
1811 dom.SetStyle(notifBarGroup, "display", "none")
1812 dom.SetStyle(notifBarGroup, "alignItems", "center")
1813 dom.SetStyle(notifBarGroup, "gap", "8px")
1814
1815 notifSelectEl = dom.CreateElement("select")
1816 dom.SetStyle(notifSelectEl, "fontSize", "13px")
1817 dom.SetStyle(notifSelectEl, "background", "var(--bg)")
1818 dom.SetStyle(notifSelectEl, "border", "1px solid var(--border)")
1819 dom.SetStyle(notifSelectEl, "borderRadius", "4px")
1820 dom.SetStyle(notifSelectEl, "color", "var(--fg)")
1821 dom.SetStyle(notifSelectEl, "cursor", "pointer")
1822 dom.SetStyle(notifSelectEl, "outline", "none")
1823 dom.SetStyle(notifSelectEl, "padding", "3px 6px")
1824 dom.SetStyle(notifSelectEl, "fontFamily", "'Fira Code', monospace")
1825 for _, opt := range []string{"all", "mentions", "reactions", "zaps"} {
1826 o := dom.CreateElement("option")
1827 dom.SetAttribute(o, "value", opt)
1828 dom.SetTextContent(o, opt)
1829 dom.AppendChild(notifSelectEl, o)
1830 }
1831 dom.AddEventListener(notifSelectEl, "change", dom.RegisterCallback(func() {
1832 notifFilter = dom.GetProperty(notifSelectEl, "value")
1833 rebuildNotifRows()
1834 }))
1835 dom.AppendChild(notifBarGroup, notifSelectEl)
1836
1837 notifMarkBtn = dom.CreateElement("button")
1838 dom.SetTextContent(notifMarkBtn, "read")
1839 dom.SetStyle(notifMarkBtn, "fontSize", "12px")
1840 dom.SetStyle(notifMarkBtn, "padding", "3px 8px")
1841 dom.SetStyle(notifMarkBtn, "border", "1px solid var(--border)")
1842 dom.SetStyle(notifMarkBtn, "borderRadius", "4px")
1843 dom.SetStyle(notifMarkBtn, "background", "transparent")
1844 dom.SetStyle(notifMarkBtn, "color", "var(--fg)")
1845 dom.SetStyle(notifMarkBtn, "cursor", "pointer")
1846 dom.SetStyle(notifMarkBtn, "fontFamily", "'Fira Code', monospace")
1847 dom.AddEventListener(notifMarkBtn, "click", dom.RegisterCallback(func() {
1848 markNotifsRead()
1849 }))
1850 dom.AppendChild(notifBarGroup, notifMarkBtn)
1851 dom.AppendChild(bar, notifBarGroup)
1852
1853 pageTitleEl = dom.CreateElement("span")
1854 dom.SetStyle(pageTitleEl, "fontSize", "18px")
1855 dom.SetStyle(pageTitleEl, "fontWeight", "bold")
1856 dom.SetStyle(pageTitleEl, "display", "none")
1857 dom.AppendChild(left, pageTitleEl)
1858 topBarLeft = left
1859 dom.AppendChild(bar, left)
1860
1861 // Right: wallet + signer (gear) icon buttons.
1862 topBarRight = dom.CreateElement("div")
1863 right := topBarRight
1864 dom.SetStyle(right, "display", "flex")
1865 dom.SetStyle(right, "alignItems", "center")
1866 dom.SetStyle(right, "gap", "8px")
1867 dom.SetStyle(right, "flex", "1")
1868 dom.SetStyle(right, "justifyContent", "flex-end")
1869
1870 walletBtnEl = dom.CreateElement("button")
1871 dom.SetInnerHTML(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>`)
1872 dom.SetAttribute(walletBtnEl, "title", "wallet")
1873 dom.SetStyle(walletBtnEl, "background", "transparent")
1874 dom.SetStyle(walletBtnEl, "border", "none")
1875 dom.SetStyle(walletBtnEl, "color", "var(--muted)")
1876 dom.SetStyle(walletBtnEl, "height", "32px")
1877 dom.SetStyle(walletBtnEl, "width", "32px")
1878 dom.SetStyle(walletBtnEl, "display", "flex")
1879 dom.SetStyle(walletBtnEl, "alignItems", "center")
1880 dom.SetStyle(walletBtnEl, "justifyContent", "center")
1881 dom.SetStyle(walletBtnEl, "cursor", "pointer")
1882 dom.SetStyle(walletBtnEl, "borderRadius", "4px")
1883 dom.AddEventListener(walletBtnEl, "click", dom.RegisterCallback(func() {
1884 toggleWalletPanel()
1885 }))
1886 // Wallet moves to bottom-bar icon group; refreshBtn lives here in top-right.
1887 _ = walletBtnEl
1888
1889 dom.AppendChild(bar, right)
1890
1891 dom.AppendChild(root, bar)
1892
1893 // === Main layout: content only (icons moved to bottom bar) ===
1894 mainLayout := dom.CreateElement("div")
1895 dom.SetStyle(mainLayout, "position", "fixed")
1896 dom.SetStyle(mainLayout, "top", "48px")
1897 dom.SetStyle(mainLayout, "bottom", "36px")
1898 dom.SetStyle(mainLayout, "left", "0")
1899 dom.SetStyle(mainLayout, "right", "0")
1900 dom.SetStyle(mainLayout, "display", "flex")
1901
1902 sidebarCompose = makeSidebarIcon(svgCompose, false)
1903 dom.AddEventListener(sidebarCompose, "click", dom.RegisterCallback(func() {
1904 switchPage("compose")
1905 }))
1906
1907 sidebarFeed = makeSidebarIcon(svgFeed, true)
1908 dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() {
1909 switchPage("feed")
1910 }))
1911
1912 sidebarMsg = makeSidebarIcon(svgChat, false)
1913 dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() {
1914 switchPage("messaging")
1915 }))
1916
1917 sidebarNotif = dom.CreateElement("button")
1918 dom.SetStyle(sidebarNotif, "width", "36px")
1919 dom.SetStyle(sidebarNotif, "height", "36px")
1920 dom.SetStyle(sidebarNotif, "border", "none")
1921 dom.SetStyle(sidebarNotif, "borderRadius", "6px")
1922 dom.SetStyle(sidebarNotif, "cursor", "pointer")
1923 dom.SetStyle(sidebarNotif, "display", "flex")
1924 dom.SetStyle(sidebarNotif, "alignItems", "center")
1925 dom.SetStyle(sidebarNotif, "justifyContent", "center")
1926 dom.SetStyle(sidebarNotif, "padding", "0")
1927 dom.SetStyle(sidebarNotif, "background", "transparent")
1928 dom.SetStyle(sidebarNotif, "color", "var(--fg)")
1929 dom.SetStyle(sidebarNotif, "position", "relative")
1930 dom.SetInnerHTML(sidebarNotif, svgBell)
1931 notifDot = dom.CreateElement("span")
1932 dom.SetStyle(notifDot, "position", "absolute")
1933 dom.SetStyle(notifDot, "top", "4px")
1934 dom.SetStyle(notifDot, "right", "5px")
1935 dom.SetStyle(notifDot, "width", "8px")
1936 dom.SetStyle(notifDot, "height", "8px")
1937 dom.SetStyle(notifDot, "borderRadius", "50%")
1938 dom.SetStyle(notifDot, "background", "var(--accent)")
1939 dom.SetStyle(notifDot, "display", "none")
1940 dom.AppendChild(sidebarNotif, notifDot)
1941 dom.AddEventListener(sidebarNotif, "click", dom.RegisterCallback(func() {
1942 switchPage("notifications")
1943 }))
1944
1945 sidebarSettings = makeSidebarIcon(svgGear, false)
1946 dom.AddEventListener(sidebarSettings, "click", dom.RegisterCallback(func() {
1947 switchPage("settings")
1948 }))
1949
1950 themeBtn = dom.CreateElement("button")
1951 dom.SetStyle(themeBtn, "background", "transparent")
1952 dom.SetStyle(themeBtn, "border", "none")
1953 dom.SetStyle(themeBtn, "width", "32px")
1954 dom.SetStyle(themeBtn, "height", "32px")
1955 dom.SetStyle(themeBtn, "cursor", "pointer")
1956 dom.SetStyle(themeBtn, "padding", "0")
1957 dom.SetStyle(themeBtn, "display", "flex")
1958 dom.SetStyle(themeBtn, "alignItems", "center")
1959 dom.SetStyle(themeBtn, "justifyContent", "center")
1960 dom.SetStyle(themeBtn, "color", "var(--muted)")
1961 updateThemeIcon()
1962 dom.AddEventListener(themeBtn, "click", dom.RegisterCallback(func() {
1963 toggleTheme()
1964 }))
1965
1966 // Content area.
1967 contentArea = dom.CreateElement("div")
1968 dom.SetStyle(contentArea, "flex", "1")
1969 dom.SetStyle(contentArea, "minHeight", "0")
1970 dom.SetStyle(contentArea, "overflowY", "auto")
1971
1972 // Feed page.
1973 feedPage = dom.CreateElement("div")
1974 dom.SetStyle(feedPage, "display", "none")
1975 dom.SetStyle(feedPage, "padding", "16px")
1976 dom.SetStyle(feedPage, "maxWidth", "640px")
1977 dom.SetStyle(feedPage, "margin", "0 auto")
1978 dom.SetStyle(feedPage, "boxSizing", "border-box")
1979
1980 // Loading spinner - shown until first feed event arrives.
1981 feedLoader = dom.CreateElement("div")
1982 dom.SetStyle(feedLoader, "display", "flex")
1983 dom.SetStyle(feedLoader, "flexDirection", "column")
1984 dom.SetStyle(feedLoader, "alignItems", "center")
1985 dom.SetStyle(feedLoader, "justifyContent", "center")
1986 dom.SetStyle(feedLoader, "padding", "64px 0")
1987 loaderImg := dom.CreateElement("div")
1988 dom.SetStyle(loaderImg, "width", "120px")
1989 dom.SetStyle(loaderImg, "height", "120px")
1990 dom.FetchText("/smesh-loader.svg", func(svg string) {
1991 dom.SetInnerHTML(loaderImg, svg)
1992 svgEl := dom.FirstElementChild(loaderImg)
1993 if svgEl != 0 {
1994 dom.SetAttribute(svgEl, "width", "100%")
1995 dom.SetAttribute(svgEl, "height", "100%")
1996 }
1997 })
1998 dom.AppendChild(feedLoader, loaderImg)
1999 loaderText := dom.CreateElement("div")
2000 dom.SetTextContent(loaderText, t("connecting"))
2001 dom.SetStyle(loaderText, "marginTop", "16px")
2002 dom.SetStyle(loaderText, "color", "var(--muted)")
2003 dom.SetStyle(loaderText, "fontSize", "14px")
2004 dom.AppendChild(feedLoader, loaderText)
2005 dom.AppendChild(feedPage, feedLoader)
2006
2007 // Pull-to-refresh indicator (hidden until user pulls at top).
2008 pullIndicator := dom.CreateElement("div")
2009 dom.SetStyle(pullIndicator, "display", "none")
2010 dom.SetStyle(pullIndicator, "justifyContent", "center")
2011 dom.SetStyle(pullIndicator, "alignItems", "center")
2012 dom.SetStyle(pullIndicator, "padding", "12px 0")
2013 dom.SetStyle(pullIndicator, "color", "var(--muted)")
2014 dom.SetStyle(pullIndicator, "fontSize", "13px")
2015 dom.SetTextContent(pullIndicator, "\u21bb refreshing...")
2016 dom.AppendChild(feedPage, pullIndicator)
2017
2018 feedContainer = dom.CreateElement("div")
2019 dom.AppendChild(feedPage, feedContainer)
2020
2021 // "Load more" button - fallback for infinite scroll.
2022 loadMoreBtn = dom.CreateElement("div")
2023 dom.SetStyle(loadMoreBtn, "display", "none")
2024 dom.SetStyle(loadMoreBtn, "textAlign", "center")
2025 dom.SetStyle(loadMoreBtn, "padding", "16px 0")
2026 loadMoreLink := dom.CreateElement("span")
2027 dom.SetTextContent(loadMoreLink, "load more")
2028 dom.SetStyle(loadMoreLink, "cursor", "pointer")
2029 dom.SetStyle(loadMoreLink, "color", "var(--accent)")
2030 dom.SetStyle(loadMoreLink, "fontSize", "14px")
2031 dom.AddEventListener(loadMoreLink, "click", dom.RegisterCallback(func() {
2032 if !feedLoading && !feedExhausted && oldestFeedTs > 0 {
2033 dom.SetStyle(loadMoreBtn, "display", "none")
2034 loadOlderFeed()
2035 }
2036 }))
2037 dom.AppendChild(loadMoreBtn, loadMoreLink)
2038
2039 // Thread view (hidden overlay within feedPage).
2040 threadPage = dom.CreateElement("div")
2041 dom.SetStyle(threadPage, "display", "none")
2042
2043 threadContainer = dom.CreateElement("div")
2044 dom.AppendChild(threadPage, threadContainer)
2045 dom.AppendChild(feedPage, threadPage)
2046
2047 // Load-more after thread so it sits at the bottom in both views.
2048 dom.AppendChild(feedPage, loadMoreBtn)
2049
2050 dom.AppendChild(contentArea, feedPage)
2051
2052 // Pull-to-refresh: wheel up or touch pull at top triggers feed reload.
2053 dom.OnPullRefresh(contentArea, pullIndicator, func() {
2054 if activePage == "feed" && !refreshSpinning {
2055 onRefreshClick()
2056 dom.SetTimeout(func() {
2057 dom.SetStyle(pullIndicator, "display", "none")
2058 }, 1500)
2059 } else if activePage == "notifications" {
2060 dom.SetProperty(contentArea, "scrollTop", "0")
2061 dom.SetTimeout(func() {
2062 dom.SetStyle(pullIndicator, "display", "none")
2063 }, 1500)
2064 }
2065 })
2066
2067 // Infinite scroll - load older events when near bottom.
2068 dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() {
2069 st := dom.GetProperty(contentArea, "scrollTop")
2070 ch := dom.GetProperty(contentArea, "clientHeight")
2071 sh := dom.GetProperty(contentArea, "scrollHeight")
2072 top := parseIntProp(st)
2073 height := parseIntProp(ch)
2074 total := parseIntProp(sh)
2075 nearBottom := top+height >= total-400
2076
2077 if activePage == "feed" {
2078 if top <= 10 && len(feedBuffer) > 0 {
2079 for _, bev := range feedBuffer {
2080 renderNote(bev)
2081 seenEvents[bev.ID] = true
2082 }
2083 feedBuffer = nil
2084 feedHasNew = false
2085 stopRefreshPulse()
2086 }
2087 if feedLoading || feedExhausted || oldestFeedTs == 0 {
2088 return
2089 }
2090 if nearBottom {
2091 loadOlderFeed()
2092 }
2093 } else if activePage == "notifications" {
2094 if notifLoading || notifExhausted || oldestNotifTs == 0 {
2095 return
2096 }
2097 if nearBottom {
2098 loadOlderNotifs()
2099 }
2100 } else if activePage == "search" {
2101 if nearBottom {
2102 appendSearchResults()
2103 }
2104 }
2105 }))
2106
2107 // Messaging page.
2108 msgPage = dom.CreateElement("div")
2109 dom.SetStyle(msgPage, "padding", "16px")
2110 dom.SetStyle(msgPage, "display", "none")
2111 dom.SetStyle(msgPage, "position", "relative")
2112 dom.SetStyle(msgPage, "height", "100%")
2113 dom.SetStyle(msgPage, "boxSizing", "border-box")
2114
2115 // Conversation list view.
2116 msgListContainer = dom.CreateElement("div")
2117 dom.AppendChild(msgPage, msgListContainer)
2118
2119 // Thread view (hidden by default).
2120 msgThreadContainer = dom.CreateElement("div")
2121 dom.SetStyle(msgThreadContainer, "display", "none")
2122 dom.SetStyle(msgThreadContainer, "flexDirection", "column")
2123 dom.SetStyle(msgThreadContainer, "position", "absolute")
2124 dom.SetStyle(msgThreadContainer, "top", "0")
2125 dom.SetStyle(msgThreadContainer, "left", "0")
2126 dom.SetStyle(msgThreadContainer, "right", "0")
2127 dom.SetStyle(msgThreadContainer, "bottom", "0")
2128 dom.SetStyle(msgThreadContainer, "background", "var(--bg)")
2129 dom.AppendChild(msgPage, msgThreadContainer)
2130
2131 msgView = "list"
2132
2133 dom.AppendChild(contentArea, msgPage)
2134
2135 // Profile page.
2136 profilePage = dom.CreateElement("div")
2137 dom.SetStyle(profilePage, "display", "none")
2138 dom.SetStyle(profilePage, "padding", "16px")
2139 dom.SetStyle(profilePage, "maxWidth", "640px")
2140 dom.SetStyle(profilePage, "margin", "0 auto")
2141 dom.SetStyle(profilePage, "boxSizing", "border-box")
2142 dom.AppendChild(contentArea, profilePage)
2143
2144 // Settings page.
2145 settingsPage = dom.CreateElement("div")
2146 dom.SetStyle(settingsPage, "display", "none")
2147 dom.SetStyle(settingsPage, "padding", "16px")
2148 dom.SetStyle(settingsPage, "maxWidth", "640px")
2149 dom.SetStyle(settingsPage, "margin", "0 auto")
2150 dom.SetStyle(settingsPage, "boxSizing", "border-box")
2151 dom.AppendChild(contentArea, settingsPage)
2152
2153 // Compose page.
2154 composePage = dom.CreateElement("div")
2155 dom.SetStyle(composePage, "display", "none")
2156 dom.SetStyle(composePage, "padding", "16px")
2157 dom.SetStyle(composePage, "maxWidth", "640px")
2158 dom.SetStyle(composePage, "margin", "0 auto")
2159 dom.SetStyle(composePage, "minHeight", "100%")
2160 dom.SetStyle(composePage, "boxSizing", "border-box")
2161 buildComposePage()
2162 dom.AppendChild(contentArea, composePage)
2163
2164 // About page.
2165 aboutPage = dom.CreateElement("div")
2166 dom.SetStyle(aboutPage, "display", "none")
2167 dom.SetStyle(aboutPage, "padding", "24px 16px")
2168 dom.SetStyle(aboutPage, "maxWidth", "640px")
2169 dom.SetStyle(aboutPage, "margin", "0 auto")
2170 dom.SetStyle(aboutPage, "boxSizing", "border-box")
2171 dom.SetStyle(aboutPage, "textAlign", "center")
2172
2173 aRefresh := dom.CreateElement("button")
2174 dom.SetTextContent(aRefresh, "hard refresh")
2175 dom.SetStyle(aRefresh, "fontFamily", "'Fira Code', monospace")
2176 dom.SetStyle(aRefresh, "fontSize", "13px")
2177 dom.SetStyle(aRefresh, "background", "transparent")
2178 dom.SetStyle(aRefresh, "border", "1px solid var(--border)")
2179 dom.SetStyle(aRefresh, "borderRadius", "4px")
2180 dom.SetStyle(aRefresh, "color", "var(--muted)")
2181 dom.SetStyle(aRefresh, "cursor", "pointer")
2182 dom.SetStyle(aRefresh, "padding", "6px 16px")
2183 dom.SetStyle(aRefresh, "marginBottom", "16px")
2184 dom.AddEventListener(aRefresh, "click", dom.RegisterCallback(func() {
2185 dom.HardRefresh()
2186 }))
2187 dom.AppendChild(aboutPage, aRefresh)
2188
2189 aboutLogoEl = dom.CreateElement("div")
2190 dom.SetStyle(aboutLogoEl, "width", "180px")
2191 dom.SetStyle(aboutLogoEl, "height", "180px")
2192 dom.SetStyle(aboutLogoEl, "margin", "0 auto 16px auto")
2193 dom.SetStyle(aboutLogoEl, "color", "var(--accent)")
2194 if logoSVGCache != "" {
2195 dom.SetInnerHTML(aboutLogoEl, logoSVGCache)
2196 aLogoSvg := dom.FirstElementChild(aboutLogoEl)
2197 if aLogoSvg != 0 {
2198 dom.SetAttribute(aLogoSvg, "width", "100%")
2199 dom.SetAttribute(aLogoSvg, "height", "100%")
2200 }
2201 }
2202 dom.AppendChild(aboutPage, aboutLogoEl)
2203
2204 aTitle := dom.CreateElement("h2")
2205 dom.SetTextContent(aTitle, "S.M.E.S.H. " | version)
2206 dom.SetStyle(aTitle, "fontSize", "20px")
2207 dom.SetStyle(aTitle, "margin", "0 0 20px 0")
2208 dom.SetStyle(aTitle, "color", "var(--accent)")
2209 dom.AppendChild(aboutPage, aTitle)
2210
2211 aDevLabel := dom.CreateElement("p")
2212 dom.SetTextContent(aDevLabel, t("developed_by"))
2213 dom.SetStyle(aDevLabel, "fontSize", "12px")
2214 dom.SetStyle(aDevLabel, "color", "var(--muted)")
2215 dom.SetStyle(aDevLabel, "marginBottom", "4px")
2216 dom.AppendChild(aboutPage, aDevLabel)
2217
2218 aNpub := dom.CreateElement("p")
2219 dom.SetStyle(aNpub, "fontSize", "11px")
2220 dom.SetStyle(aNpub, "color", "var(--fg)")
2221 dom.SetStyle(aNpub, "wordBreak", "break-all")
2222 dom.SetStyle(aNpub, "marginBottom", "20px")
2223 dom.SetStyle(aNpub, "fontFamily", "'Fira Code', monospace")
2224 dom.SetTextContent(aNpub, "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku")
2225 dom.AppendChild(aboutPage, aNpub)
2226
2227 aTagline := dom.CreateElement("p")
2228 dom.SetStyle(aTagline, "fontSize", "16px")
2229 dom.SetStyle(aTagline, "fontWeight", "bold")
2230 dom.SetStyle(aTagline, "color", "var(--fg)")
2231 dom.SetStyle(aTagline, "marginBottom", "12px")
2232 dom.SetTextContent(aTagline, t("tagline"))
2233 dom.AppendChild(aboutPage, aTagline)
2234
2235 aFunny := dom.CreateElement("p")
2236 dom.SetStyle(aFunny, "fontSize", "13px")
2237 dom.SetStyle(aFunny, "color", "var(--fg)")
2238 dom.SetStyle(aFunny, "marginBottom", "20px")
2239 dom.SetStyle(aFunny, "lineHeight", "1.6")
2240 dom.SetTextContent(aFunny, t("about_donkey"))
2241 dom.AppendChild(aboutPage, aFunny)
2242
2243 aLud := dom.CreateElement("span")
2244 dom.SetStyle(aLud, "fontSize", "13px")
2245 dom.SetStyle(aLud, "color", "var(--accent)")
2246 dom.SetStyle(aLud, "marginBottom", "24px")
2247 dom.SetStyle(aLud, "cursor", "pointer")
2248 dom.SetStyle(aLud, "display", "inline-block")
2249 dom.SetTextContent(aLud, "\xe2\x9a\xa1 leeringidol57@walletofsatoshi.com")
2250 dom.AddEventListener(aLud, "click", dom.RegisterCallback(func() {
2251 showQRModal("leeringidol57@walletofsatoshi.com")
2252 }))
2253 dom.AppendChild(aboutPage, aLud)
2254
2255 feliceLink := dom.CreateElement("a")
2256 dom.SetAttribute(feliceLink, "href", "https://git.smesh.lol/felice/raw/felice.apk")
2257 dom.SetAttribute(feliceLink, "target", "_blank")
2258 dom.SetStyle(feliceLink, "display", "block")
2259 dom.SetStyle(feliceLink, "color", "var(--accent)")
2260 dom.SetStyle(feliceLink, "fontSize", "13px")
2261 dom.SetStyle(feliceLink, "marginBottom", "4px")
2262 dom.SetTextContent(feliceLink, "download felice")
2263 dom.AppendChild(aboutPage, feliceLink)
2264
2265 feliceDesc := dom.CreateElement("p")
2266 dom.SetStyle(feliceDesc, "fontSize", "11px")
2267 dom.SetStyle(feliceDesc, "color", "var(--muted)")
2268 dom.SetStyle(feliceDesc, "lineHeight", "1.5")
2269 dom.SetStyle(feliceDesc, "marginBottom", "24px")
2270 dom.SetStyle(feliceDesc, "maxWidth", "400px")
2271 dom.SetStyle(feliceDesc, "margin", "0 auto 24px auto")
2272 dom.SetTextContent(feliceDesc, "fork of fennec-fdroid to use smesh.lol and smesh signer extension on android without the mozilla security theatre")
2273 dom.AppendChild(aboutPage, feliceDesc)
2274
2275 dom.AppendChild(contentArea, aboutPage)
2276
2277 // Activity log page.
2278 logPage = dom.CreateElement("div")
2279 dom.SetStyle(logPage, "display", "none")
2280 dom.SetStyle(logPage, "padding", "12px")
2281 dom.SetStyle(logPage, "maxWidth", "780px")
2282 dom.SetStyle(logPage, "margin", "0 auto")
2283 dom.SetStyle(logPage, "boxSizing", "border-box")
2284 dom.SetStyle(logPage, "fontFamily", "'Fira Code', monospace")
2285 dom.SetStyle(logPage, "fontSize", "12px")
2286
2287 logListEl = dom.CreateElement("div")
2288 dom.AppendChild(logPage, logListEl)
2289 dom.AppendChild(contentArea, logPage)
2290
2291 // Pre-populate with any entries that accumulated during boot before this DOM was built.
2292 for i := len(activityLog) - 1; i >= 0; i-- {
2293 prependLogEntry(activityLog[i])
2294 }
2295
2296 // Notifications page.
2297 notifPage = dom.CreateElement("div")
2298 dom.SetStyle(notifPage, "display", "none")
2299 dom.SetStyle(notifPage, "padding", "16px")
2300 dom.SetStyle(notifPage, "maxWidth", "640px")
2301 dom.SetStyle(notifPage, "margin", "0 auto")
2302 dom.SetStyle(notifPage, "boxSizing", "border-box")
2303
2304 notifContainer = dom.CreateElement("div")
2305 dom.AppendChild(notifPage, notifContainer)
2306 dom.AppendChild(contentArea, notifPage)
2307
2308 // Search page: flex-column so filter bar stays above scrolling results.
2309 searchPage = dom.CreateElement("div")
2310 dom.SetStyle(searchPage, "display", "none")
2311 dom.SetStyle(searchPage, "maxWidth", "640px")
2312 dom.SetStyle(searchPage, "margin", "0 auto")
2313 dom.SetStyle(searchPage, "width", "100%")
2314 // searchFilterBarEl and searchContainer are appended dynamically by buildSearchFilterBar/executeSearch.
2315 searchContainer = dom.CreateElement("div")
2316 dom.AppendChild(searchPage, searchContainer)
2317 dom.AppendChild(contentArea, searchPage)
2318
2319 dom.AppendChild(mainLayout, contentArea)
2320 dom.AppendChild(root, mainLayout)
2321 activePage = "log"
2322 dom.SetStyle(logPage, "display", "block")
2323
2324 buildStatusBar(true)
2325
2326 // Icon group: fixed 4px gap between icons (same as old sidebar), centered
2327 // on screen via absolute positioning + translateX(-50%).
2328 iconGroup := dom.CreateElement("div")
2329 dom.SetStyle(iconGroup, "position", "absolute")
2330 dom.SetStyle(iconGroup, "left", "50%")
2331 dom.SetStyle(iconGroup, "top", "0")
2332 dom.SetStyle(iconGroup, "bottom", "0")
2333 dom.SetStyle(iconGroup, "transform", "translateX(-50%)")
2334 dom.SetStyle(iconGroup, "display", "flex")
2335 dom.SetStyle(iconGroup, "alignItems", "center")
2336 dom.SetStyle(iconGroup, "gap", "4px")
2337 if int32(userBtn) != 0 {
2338 dom.AppendChild(iconGroup, userBtn)
2339 }
2340 dom.AppendChild(iconGroup, sidebarCompose)
2341 dom.AppendChild(iconGroup, sidebarFeed)
2342 dom.AppendChild(iconGroup, sidebarMsg)
2343 dom.AppendChild(iconGroup, sidebarNotif)
2344 dom.AppendChild(iconGroup, sidebarSettings)
2345 _ = walletBtnEl
2346 _ = themeBtn
2347 dom.AppendChild(bottomBar, iconGroup)
2348 // Activity log button - opens the log page. Highlighted while boot is in progress.
2349 logBtn = dom.CreateElement("button")
2350 dom.SetInnerHTML(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>`)
2351 dom.SetAttribute(logBtn, "title", "activity log")
2352 dom.SetStyle(logBtn, "background", "transparent")
2353 dom.SetStyle(logBtn, "border", "none")
2354 dom.SetStyle(logBtn, "color", "var(--muted)")
2355 dom.SetStyle(logBtn, "width", "32px")
2356 dom.SetStyle(logBtn, "height", "32px")
2357 dom.SetStyle(logBtn, "cursor", "pointer")
2358 dom.SetStyle(logBtn, "padding", "0")
2359 dom.SetStyle(logBtn, "display", "flex")
2360 dom.SetStyle(logBtn, "alignItems", "center")
2361 dom.SetStyle(logBtn, "justifyContent", "center")
2362 dom.AddEventListener(logBtn, "click", dom.RegisterCallback(func() {
2363 switchPage("log")
2364 }))
2365 dom.AppendChild(topBarRight, logBtn)
2366 updateLogBtnHighlight()
2367
2368 // Search button + input row - leftmost item in topBarRight.
2369 searchSortDesc = true
2370 searchSeen = map[string]bool{}
2371
2372 searchBtn := dom.CreateElement("button")
2373 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>`)
2374 dom.SetAttribute(searchBtn, "title", "search")
2375 dom.SetStyle(searchBtn, "background", "transparent")
2376 dom.SetStyle(searchBtn, "border", "none")
2377 dom.SetStyle(searchBtn, "color", "var(--muted)")
2378 dom.SetStyle(searchBtn, "height", "32px")
2379 dom.SetStyle(searchBtn, "width", "32px")
2380 dom.SetStyle(searchBtn, "display", "flex")
2381 dom.SetStyle(searchBtn, "alignItems", "center")
2382 dom.SetStyle(searchBtn, "justifyContent", "center")
2383 dom.SetStyle(searchBtn, "cursor", "pointer")
2384 dom.SetStyle(searchBtn, "borderRadius", "4px")
2385 dom.SetStyle(searchBtn, "flexShrink", "0")
2386
2387 searchInputEl = dom.CreateElement("input")
2388 dom.SetAttribute(searchInputEl, "type", "text")
2389 dom.SetAttribute(searchInputEl, "placeholder", "#tag or text search")
2390 dom.SetStyle(searchInputEl, "display", "none")
2391 dom.SetStyle(searchInputEl, "flex", "1")
2392 dom.SetStyle(searchInputEl, "minWidth", "0")
2393 dom.SetStyle(searchInputEl, "background", "var(--bg)")
2394 dom.SetStyle(searchInputEl, "color", "var(--fg)")
2395 dom.SetStyle(searchInputEl, "border", "1px solid var(--border)")
2396 dom.SetStyle(searchInputEl, "borderRadius", "4px")
2397 dom.SetStyle(searchInputEl, "padding", "4px 8px")
2398 dom.SetStyle(searchInputEl, "fontSize", "14px")
2399 dom.SetStyle(searchInputEl, "fontFamily", "'Fira Code', monospace")
2400
2401 searchSubmitBtn = dom.CreateElement("button")
2402 dom.SetTextContent(searchSubmitBtn, "\xe2\x86\x92") // →
2403 dom.SetStyle(searchSubmitBtn, "display", "none")
2404 dom.SetStyle(searchSubmitBtn, "background", "var(--accent)")
2405 dom.SetStyle(searchSubmitBtn, "color", "#fff")
2406 dom.SetStyle(searchSubmitBtn, "border", "none")
2407 dom.SetStyle(searchSubmitBtn, "borderRadius", "4px")
2408 dom.SetStyle(searchSubmitBtn, "padding", "4px 10px")
2409 dom.SetStyle(searchSubmitBtn, "cursor", "pointer")
2410 dom.SetStyle(searchSubmitBtn, "fontSize", "16px")
2411 dom.SetStyle(searchSubmitBtn, "flexShrink", "0")
2412
2413 // 🔍 toggles search: click to open (highlighted), click again to close.
2414 searchBtnGlobal = searchBtn
2415 capturedSearchBtn := searchBtn
2416 dom.AddEventListener(searchBtn, "click", dom.RegisterCallback(func() {
2417 if searchBarActive {
2418 closeSearch(capturedSearchBtn)
2419 } else {
2420 activateSearchBar(capturedSearchBtn)
2421 }
2422 }))
2423
2424 submitSearch := func() {
2425 q := dom.GetProperty(searchInputEl, "value")
2426 if q != "" {
2427 executeSearch(q)
2428 }
2429 }
2430 capturedSubmit := submitSearch
2431 dom.AddEventListener(searchSubmitBtn, "click", dom.RegisterCallback(capturedSubmit))
2432 dom.OnKeydown(searchInputEl, func(key string, prevent func()) {
2433 if key == "Enter" {
2434 prevent()
2435 capturedSubmit()
2436 } else if key == "Escape" {
2437 closeSearch(capturedSearchBtn)
2438 }
2439 })
2440
2441 // Search lives in topBarLeft so it can replace center content when active.
2442 dom.AppendChild(topBarLeft, searchBtn)
2443 dom.AppendChild(topBarLeft, searchInputEl)
2444 dom.AppendChild(topBarLeft, searchSubmitBtn)
2445
2446 // Load saved relays; fall back to defaults on first run.
2447 if !loadRelayList() {
2448 logActivity("boot", "loadRelayList empty, using defaults", "")
2449 for _, url := range defaultRelays {
2450 addRelay(url, false)
2451 }
2452 }
2453 if len(relayURLs) == 0 {
2454 logActivity("boot", "relayURLs still empty, forcing defaults", "")
2455 for _, url := range defaultRelays {
2456 addRelay(url, false)
2457 }
2458 }
2459 {
2460 relayList := ""
2461 for i, u := range relayURLs {
2462 if i > 0 {
2463 relayList = relayList | "\n"
2464 }
2465 relayList = relayList | u
2466 }
2467 logActivity("boot", "loaded " | itoa(len(relayURLs)) | " relays", relayList)
2468 }
2469
2470 // Tell SW about relays and subscribe.
2471 sendWriteRelays()
2472 subscribeProfile()
2473 logActivity("boot", "subscribed to own profile", "kinds: 0,3,10002,10000,10050 for " | pubhex)
2474 // Feed subscription is deferred until kind 3 (follow list) arrives,
2475 // so we don't subscribe with a global filter then immediately
2476 // resubscribe when the follow list shows up (which clears the feed).
2477 // Fallback: if no kind 3 arrives within 3s, subscribe anyway.
2478 feedDeferTimer = dom.SetTimeout(func() {
2479 feedDeferTimer = 0
2480 if !feedSubscribed {
2481 feedSubscribed = true
2482 logActivity("boot", "feed defer timeout, subscribing anyway", "no kind 3 in 3s")
2483 subscribeFeed()
2484 subscribeNotifications()
2485 }
2486 bootDone()
2487 }, 3000)
2488
2489 // Publish any kind 0 queued during identity derivation (relays are now live).
2490 flushPendingK0()
2491
2492 // Wire up browser history navigation.
2493 initRouter()
2494
2495 // Check for signer extension.
2496 initSigner()
2497
2498 // Scroll-to-top floaty button.
2499 scrollTopBtn := dom.CreateElement("button")
2500 dom.SetStyle(scrollTopBtn, "position", "fixed")
2501 dom.SetStyle(scrollTopBtn, "bottom", "48px")
2502 dom.SetStyle(scrollTopBtn, "right", "12px")
2503 dom.SetStyle(scrollTopBtn, "width", "36px")
2504 dom.SetStyle(scrollTopBtn, "height", "36px")
2505 dom.SetStyle(scrollTopBtn, "borderRadius", "50%")
2506 dom.SetStyle(scrollTopBtn, "background", "var(--bg2)")
2507 dom.SetStyle(scrollTopBtn, "color", "var(--fg)")
2508 dom.SetStyle(scrollTopBtn, "border", "1px solid var(--border)")
2509 dom.SetStyle(scrollTopBtn, "cursor", "pointer")
2510 dom.SetStyle(scrollTopBtn, "display", "none")
2511 dom.SetStyle(scrollTopBtn, "alignItems", "center")
2512 dom.SetStyle(scrollTopBtn, "justifyContent", "center")
2513 dom.SetStyle(scrollTopBtn, "zIndex", "99")
2514 dom.SetStyle(scrollTopBtn, "padding", "0")
2515 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>`)
2516 dom.AddEventListener(scrollTopBtn, "click", dom.RegisterCallback(func() {
2517 dom.SetProperty(contentArea, "scrollTop", "0")
2518 }))
2519 dom.AppendChild(dom.Body(), scrollTopBtn)
2520
2521 dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() {
2522 st := parseIntProp(dom.GetProperty(contentArea, "scrollTop"))
2523 show := st > 300 && (activePage == "feed" || activePage == "notifications" || activePage == "thread")
2524 if show {
2525 dom.SetStyle(scrollTopBtn, "display", "inline-flex")
2526 } else {
2527 dom.SetStyle(scrollTopBtn, "display", "none")
2528 }
2529 }))
2530
2531 appReady = true
2532 }
2533
2534 // buildStatusBar creates the bottom bar, relay popover, and signer popover.
2535 // withUser=true adds the avatar+name section (main app).
2536 func buildStatusBar(withUser bool) {
2537 bottomBar = dom.CreateElement("div")
2538 dom.SetStyle(bottomBar, "position", "fixed")
2539 dom.SetStyle(bottomBar, "bottom", "0")
2540 dom.SetStyle(bottomBar, "left", "0")
2541 dom.SetStyle(bottomBar, "right", "0")
2542 dom.SetStyle(bottomBar, "height", "36px")
2543 dom.SetStyle(bottomBar, "display", "flex")
2544 dom.SetStyle(bottomBar, "alignItems", "center")
2545 dom.SetStyle(bottomBar, "padding", "0 6px")
2546 dom.SetStyle(bottomBar, "gap", "8px")
2547 dom.SetStyle(bottomBar, "background", "var(--bg2)")
2548 dom.SetStyle(bottomBar, "fontSize", "12px")
2549 dom.SetStyle(bottomBar, "color", "var(--fg)")
2550 dom.SetStyle(bottomBar, "borderTop", "none")
2551 dom.SetStyle(bottomBar, "zIndex", "100")
2552
2553 if withUser {
2554 userBtn = dom.CreateElement("button")
2555 dom.SetStyle(userBtn, "display", "flex")
2556 dom.SetStyle(userBtn, "alignItems", "center")
2557 dom.SetStyle(userBtn, "justifyContent", "center")
2558 dom.SetStyle(userBtn, "padding", "0")
2559 dom.SetStyle(userBtn, "background", "transparent")
2560 dom.SetStyle(userBtn, "border", "none")
2561 dom.SetStyle(userBtn, "borderRadius", "6px")
2562 dom.SetStyle(userBtn, "cursor", "pointer")
2563 dom.SetStyle(userBtn, "width", "36px")
2564 dom.SetStyle(userBtn, "height", "36px")
2565
2566 avatarEl = dom.CreateElement("img")
2567 dom.SetAttribute(avatarEl, "referrerpolicy", "no-referrer")
2568 dom.SetAttribute(avatarEl, "width", "24")
2569 dom.SetAttribute(avatarEl, "height", "24")
2570 dom.SetStyle(avatarEl, "borderRadius", "50%")
2571 dom.SetStyle(avatarEl, "objectFit", "cover")
2572 dom.SetStyle(avatarEl, "display", "block")
2573 dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'")
2574 dom.AppendChild(userBtn, avatarEl)
2575
2576 // nameEl kept (exists for other code paths) but detached - username
2577 // no longer appears in the status bar.
2578 nameEl = dom.CreateElement("span")
2579
2580 dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() {
2581 toggleSignerPanel()
2582 }))
2583 // userBtn is appended to iconGroup by showApp.
2584 }
2585
2586 statusEl = dom.CreateElement("button")
2587 dom.SetTextContent(statusEl, t("connecting"))
2588 dom.SetStyle(statusEl, "fontFamily", "'Fira Code', monospace")
2589 dom.SetStyle(statusEl, "fontSize", "12px")
2590 dom.SetStyle(statusEl, "background", "transparent")
2591 dom.SetStyle(statusEl, "border", "none")
2592 dom.SetStyle(statusEl, "color", "var(--muted)")
2593 dom.SetStyle(statusEl, "cursor", "pointer")
2594 dom.SetStyle(statusEl, "padding", "4px 8px")
2595 dom.SetStyle(statusEl, "borderRadius", "4px")
2596 dom.SetStyle(statusEl, "marginLeft", "auto")
2597 dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() {
2598 togglePopover()
2599 }))
2600 // statusEl (relays button) is created but not attached - all relay info is
2601 // already visible on the settings page.
2602 _ = statusEl
2603
2604 dom.AppendChild(root, bottomBar)
2605
2606 // Relay popover (hidden).
2607 popoverEl = dom.CreateElement("div")
2608 dom.SetStyle(popoverEl, "position", "fixed")
2609 dom.SetStyle(popoverEl, "bottom", "37px")
2610 dom.SetStyle(popoverEl, "right", "12px")
2611 dom.SetStyle(popoverEl, "width", "340px")
2612 dom.SetStyle(popoverEl, "maxWidth", "calc(100vw - 24px)")
2613 dom.SetStyle(popoverEl, "background", "var(--bg2)")
2614 dom.SetStyle(popoverEl, "border", "1px solid var(--border)")
2615 dom.SetStyle(popoverEl, "borderRadius", "8px")
2616 dom.SetStyle(popoverEl, "padding", "12px 16px")
2617 dom.SetStyle(popoverEl, "fontSize", "12px")
2618 dom.SetStyle(popoverEl, "display", "none")
2619 dom.SetStyle(popoverEl, "zIndex", "99")
2620
2621 popoverSummary = dom.CreateElement("div")
2622 dom.SetTextContent(popoverSummary, t("connecting"))
2623 dom.SetStyle(popoverSummary, "marginBottom", "8px")
2624 dom.SetStyle(popoverSummary, "color", "var(--muted)")
2625 dom.AppendChild(popoverEl, popoverSummary)
2626
2627 // Broadcast button - at the bottom of the relay popover.
2628 broadcastRow = dom.CreateElement("div")
2629 dom.SetStyle(broadcastRow, "borderTop", "1px solid var(--border)")
2630 dom.SetStyle(broadcastRow, "marginTop", "8px")
2631 dom.SetStyle(broadcastRow, "paddingTop", "8px")
2632 dom.SetStyle(broadcastRow, "display", "none")
2633 broadcastBtnEl = dom.CreateElement("span")
2634 dom.SetTextContent(broadcastBtnEl, "broadcast profile")
2635 dom.SetStyle(broadcastBtnEl, "cursor", "pointer")
2636 dom.SetStyle(broadcastBtnEl, "color", "var(--accent)")
2637 dom.SetStyle(broadcastBtnEl, "fontFamily", "'Fira Code', monospace")
2638 dom.AddEventListener(broadcastBtnEl, "click", dom.RegisterCallback(func() {
2639 if pubhex == "" {
2640 return
2641 }
2642 dom.SetTextContent(broadcastBtnEl, "broadcasting...")
2643 dom.SetStyle(broadcastBtnEl, "color", "var(--muted)")
2644 msg := "[\"BROADCAST\"," | jstr(pubhex) | ",["
2645 for i, url := range relayURLs {
2646 if i > 0 {
2647 msg = msg | ","
2648 }
2649 msg = msg | jstr(url)
2650 }
2651 routeMsg(msg | "]]")
2652 }))
2653 dom.AppendChild(broadcastRow, broadcastBtnEl)
2654 dom.AppendChild(popoverEl, broadcastRow)
2655 if pubhex != "" {
2656 dom.SetStyle(broadcastRow, "display", "block")
2657 }
2658
2659 // popoverEl (relays popup) is created but not attached - relays are on the
2660 // settings page. Downstream code still mutates the detached element harmlessly.
2661 _ = popoverEl
2662
2663 // Signer popover (hidden).
2664 buildSignerPanel()
2665 dom.AppendChild(root, signerPanel)
2666
2667 // Wallet popover (hidden).
2668 buildWalletPanel()
2669 // walletPanel is attached by renderSettings() at the bottom of the settings page.
2670
2671 // Feed selector popover (hidden).
2672 feedPopoverEl = dom.CreateElement("div")
2673 dom.SetStyle(feedPopoverEl, "position", "fixed")
2674 dom.SetStyle(feedPopoverEl, "top", "48px")
2675 dom.SetStyle(feedPopoverEl, "left", "50%")
2676 dom.SetStyle(feedPopoverEl, "transform", "translateX(-50%)")
2677 dom.SetStyle(feedPopoverEl, "width", "220px")
2678 dom.SetStyle(feedPopoverEl, "maxWidth", "calc(100vw - 24px)")
2679 dom.SetStyle(feedPopoverEl, "background", "var(--bg2)")
2680 dom.SetStyle(feedPopoverEl, "border", "1px solid var(--border)")
2681 dom.SetStyle(feedPopoverEl, "borderRadius", "8px")
2682 dom.SetStyle(feedPopoverEl, "padding", "8px")
2683 dom.SetStyle(feedPopoverEl, "fontSize", "14px")
2684 dom.SetStyle(feedPopoverEl, "display", "none")
2685 dom.SetStyle(feedPopoverEl, "zIndex", "99")
2686 dom.AppendChild(root, feedPopoverEl)
2687 }
2688
2689 // relayCap is a parsed NIP-11 Relay Information Document snapshot.
2690 // Values are populated asynchronously after addRelay; fetchedOK=false means
2691 // either the fetch hasn't completed yet or the relay was unreachable.
2692 type relayCap struct {
2693 name string
2694 adminPubkey string // hex, from NIP-11 "pubkey" field (operator identity)
2695 supportedNIPs []int32 // NIP-11 "supported_nips" array
2696 authRequired bool // limitation.auth_required
2697 paymentReq bool // limitation.payment_required
2698 restrictedW bool // limitation.restricted_writes
2699 fetchedOK bool // true once a non-empty NIP-11 document has been parsed
2700 }
2701
2702 // hasNIP reports whether a supported_nips list contains the given NIP number.
2703 func hasNIP(nips []int32, n int32) (ok bool) {
2704 for _, x := range nips {
2705 if x == n {
2706 return true
2707 }
2708 }
2709 return false
2710 }
2711
2712 // makeRelayBadge creates a small warning pill like "!42" or "!70".
2713 func makeRelayBadge(text string) (e dom.Element) {
2714 b := dom.CreateElement("span")
2715 dom.SetTextContent(b, text)
2716 dom.SetStyle(b, "display", "inline-block")
2717 dom.SetStyle(b, "background", "#c44")
2718 dom.SetStyle(b, "color", "#fff")
2719 dom.SetStyle(b, "padding", "1px 5px")
2720 dom.SetStyle(b, "borderRadius", "3px")
2721 dom.SetStyle(b, "fontSize", "10px")
2722 dom.SetStyle(b, "marginLeft", "4px")
2723 dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
2724 return b
2725 }
2726
2727 // updateRelayBadges rewrites the badge container for the relay at index i
2728 // based on the current relayCaps[i]. Missing NIP-42 and NIP-70 support each
2729 // produces a warning pill. Called from the NIP-11 fetch callback.
2730 func updateRelayBadges(i int32) {
2731 if i < 0 || i >= len(relayBadgeContainers) {
2732 return
2733 }
2734 container := relayBadgeContainers[i]
2735 clearChildren(container)
2736 caps := relayCaps[i]
2737 if !hasNIP(caps.supportedNIPs, 42) {
2738 dom.AppendChild(container, makeRelayBadge("!42"))
2739 }
2740 if !hasNIP(caps.supportedNIPs, 70) {
2741 dom.AppendChild(container, makeRelayBadge("!70"))
2742 }
2743 if hasNIP(caps.supportedNIPs, 50) {
2744 badge := makeRelayBadge("search")
2745 dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
2746 dom.SetStyle(badge, "color", "#0a0")
2747 dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
2748 dom.AppendChild(container, badge)
2749 }
2750 }
2751
2752 // parseNIP11Caps parses a NIP-11 Relay Information Document body into a relayCap.
2753 // Returns a zero value (fetchedOK=false) for empty input.
2754 func parseNIP11Caps(body string) (v relayCap) {
2755 var c relayCap
2756 if body == "" {
2757 return c
2758 }
2759 c.fetchedOK = true
2760 c.name = helpers.JsonGetString(body, "name")
2761 c.adminPubkey = helpers.JsonGetString(body, "pubkey")
2762 c.supportedNIPs = helpers.JsonGetIntArray(body, "supported_nips")
2763 lim := helpers.JsonGetValue(body, "limitation")
2764 if lim != "" {
2765 c.authRequired = helpers.JsonGetBool(lim, "auth_required")
2766 c.paymentReq = helpers.JsonGetBool(lim, "payment_required")
2767 c.restrictedW = helpers.JsonGetBool(lim, "restricted_writes")
2768 }
2769 return c
2770 }
2771
2772 // loadRelayPolicy reads the persisted relay policy bitfield and blocklist from
2773 // localStorage. Called once at startup before any relay logic runs.
2774 func loadRelayPolicy() {
2775 if s := localstorage.GetItem(lsKeyRelayPolicy); s != "" {
2776 relayPolicyFlags = parseIntProp(s)
2777 }
2778 if s := localstorage.GetItem(lsKeyRelayBlocklist); s != "" {
2779 relayBlocklist = splitLines(s)
2780 }
2781 }
2782
2783 // saveRelayPolicy persists relayPolicyFlags and relayBlocklist to localStorage.
2784 // Called from the settings UI whenever the user changes a toggle or the
2785 // blocklist is mutated. Does not publish kind 10006 - that is done explicitly
2786 // by the blocklist mutation helpers.
2787 func saveRelayPolicy() {
2788 localstorage.SetItem(lsKeyRelayPolicy, itoa(relayPolicyFlags))
2789 localstorage.SetItem(lsKeyRelayBlocklist, joinLines(relayBlocklist))
2790 }
2791
2792 // saveRelayList persists the current relay URLs and roles to localStorage.
2793 func saveRelayList() {
2794 localstorage.SetItem(lsKeyRelayList, joinLines(relayURLs))
2795 roles := ""
2796 for i, r := range relayRole {
2797 if i > 0 {
2798 roles = roles | "\n"
2799 }
2800 roles = roles | r
2801 }
2802 localstorage.SetItem(lsKeyRelayRoles, roles)
2803 }
2804
2805 // loadRelayList reads persisted relay URLs and roles and adds them.
2806 // Returns true if any were loaded (so defaults can be skipped).
2807 func loadRelayList() (ok bool) {
2808 s := localstorage.GetItem(lsKeyRelayList)
2809 if s == "" {
2810 return false
2811 }
2812 urls := splitLines(s)
2813 roles := splitLines(localstorage.GetItem(lsKeyRelayRoles))
2814 added := 0
2815 for i, u := range urls {
2816 if len(u) == 0 {
2817 continue
2818 }
2819 role := "both"
2820 if i < len(roles) && (roles[i] == "read" || roles[i] == "write") {
2821 role = roles[i]
2822 }
2823 addRelayWithRole(u, false, role)
2824 added++
2825 }
2826 return added > 0
2827 }
2828
2829 // isBlocked reports whether url (already normalized) is in relayBlocklist.
2830 var dnsBlacklist = []string{
2831 "wss://relay.nostr.band",
2832 "wss://relay.nostr.band/",
2833 }
2834
2835 func isBlocked(url string) (ok bool) {
2836 for _, b := range dnsBlacklist {
2837 if b == url {
2838 return true
2839 }
2840 }
2841 for _, b := range relayBlocklist {
2842 if b == url {
2843 return true
2844 }
2845 }
2846 return false
2847 }
2848
2849 // removeRelayByURL tears down the popover row for url and splices it out of
2850 // all parallel relay slices. No-op if url is not present.
2851 func removeRelayByURL(url string) {
2852 url = normalizeURL(url)
2853 for i, u := range relayURLs {
2854 if u != url {
2855 continue
2856 }
2857 // Detach popover row.
2858 dom.RemoveChild(popoverEl, relayRows[i])
2859 // Splice all parallel slices.
2860 relayURLs = relayURLs[:i] | relayURLs[i+1:]
2861 relayRows = relayRows[:i] | relayRows[i+1:]
2862 relayDots = relayDots[:i] | relayDots[i+1:]
2863 relayLabels = relayLabels[:i] | relayLabels[i+1:]
2864 relayBadgeContainers = relayBadgeContainers[:i] | relayBadgeContainers[i+1:]
2865 relayUserPick = relayUserPick[:i] | relayUserPick[i+1:]
2866 relayRole = relayRole[:i] | relayRole[i+1:]
2867 relayReadBtns = relayReadBtns[:i] | relayReadBtns[i+1:]
2868 relayWriteBtns = relayWriteBtns[:i] | relayWriteBtns[i+1:]
2869 relayCaps = relayCaps[:i] | relayCaps[i+1:]
2870 relayCapsTs = relayCapsTs[:i] | relayCapsTs[i+1:]
2871 saveRelayList()
2872 updateStatus()
2873 return
2874 }
2875 }
2876
2877 // addToBlocklist records url in relayBlocklist, tears down any active row for
2878 // it, persists, and publishes kind 10006. Called only from explicit UI action.
2879 func addToBlocklist(url string) {
2880 url = normalizeURL(url)
2881 if isBlocked(url) {
2882 return
2883 }
2884 relayBlocklist = append(relayBlocklist, url)
2885 removeRelayByURL(url)
2886 saveRelayPolicy()
2887 publishKind10006()
2888 }
2889
2890 // removeFromBlocklist unblocks url, persists, and publishes kind 10006. Does
2891 // not auto-re-add the relay - user must add it back explicitly if desired.
2892 func removeFromBlocklist(url string) {
2893 url = normalizeURL(url)
2894 for i, b := range relayBlocklist {
2895 if b == url {
2896 relayBlocklist = relayBlocklist[:i] | relayBlocklist[i+1:]
2897 saveRelayPolicy()
2898 publishKind10006()
2899 return
2900 }
2901 }
2902 }
2903
2904 // publishKind10006 signs and publishes a NIP-51 "Blocked Relays" list. Called
2905 // only on explicit blocklist mutation - never from loadRelayPolicy, so merely
2906 // loading smesh does not re-publish the list.
2907 func publishKind10006() {
2908 if !signer.HasSigner() || pubhex == "" {
2909 return
2910 }
2911 // Build tags: [["relay","wss://..."], ...]
2912 tags := "["
2913 for i, u := range relayBlocklist {
2914 if i > 0 {
2915 tags = tags | ","
2916 }
2917 tags = tags | "[\"relay\"," | jstr(u) | "]"
2918 }
2919 tags = tags | "]"
2920 ts := dom.NowSeconds()
2921 unsigned := "{\"kind\":10006,\"content\":\"\",\"tags\":" | tags |
2922 ",\"created_at\":" | i64toa(ts) |
2923 ",\"pubkey\":\"" | pubhex | "\"}"
2924 signer.SignEvent(unsigned, func(signed string) {
2925 if signed != "" {
2926 routeMsg("[\"EVENT\"," | signed | "]")
2927 dom.ConsoleLog("[blocklist] published kind 10006 with " | itoa(len(relayBlocklist)) | " entries")
2928 }
2929 })
2930 }
2931
2932 // addRelay adds a relay with role "both".
2933 func addRelay(url string, userPick bool) {
2934 addRelayWithRole(url, userPick, "both")
2935 }
2936
2937 // applyRelayRoleBtns sets the active/inactive styling on the r/w buttons.
2938 func applyRelayRoleBtns(rBtn, wBtn dom.Element, role string) {
2939 canRead := role == "both" || role == "read"
2940 canWrite := role == "both" || role == "write"
2941 if canRead {
2942 dom.SetStyle(rBtn, "background", "var(--bg2)")
2943 dom.SetStyle(rBtn, "color", "var(--fg)")
2944 dom.SetStyle(rBtn, "borderColor", "var(--fg)")
2945 } else {
2946 dom.SetStyle(rBtn, "background", "transparent")
2947 dom.SetStyle(rBtn, "color", "var(--muted)")
2948 dom.SetStyle(rBtn, "borderColor", "var(--border)")
2949 }
2950 if canWrite {
2951 dom.SetStyle(wBtn, "background", "var(--bg2)")
2952 dom.SetStyle(wBtn, "color", "var(--fg)")
2953 dom.SetStyle(wBtn, "borderColor", "var(--fg)")
2954 } else {
2955 dom.SetStyle(wBtn, "background", "transparent")
2956 dom.SetStyle(wBtn, "color", "var(--muted)")
2957 dom.SetStyle(wBtn, "borderColor", "var(--border)")
2958 }
2959 }
2960
2961 // addRelayWithRole adds a relay to the list and creates its popover row.
2962 // role is "both" (read+write), "read", or "write".
2963 func addRelayWithRole(url string, userPick bool, role string) {
2964 url = normalizeURL(url)
2965 // Blocklist guard - never attach a blocked relay, regardless of source.
2966 if isBlocked(url) {
2967 return
2968 }
2969 // Dedup.
2970 for i, u := range relayURLs {
2971 if u == url {
2972 if userPick && !relayUserPick[i] {
2973 relayUserPick[i] = true
2974 dom.SetStyle(relayLabels[i], "fontWeight", "bold")
2975 }
2976 return
2977 }
2978 }
2979
2980 relayURLs = append(relayURLs, url)
2981 relayUserPick = append(relayUserPick, userPick)
2982 relayRole = append(relayRole, role)
2983 relayCaps = append(relayCaps, relayCap{})
2984 relayCapsTs = append(relayCapsTs, 0)
2985 saveRelayList()
2986
2987 // Popover row.
2988 row := dom.CreateElement("div")
2989 dom.SetStyle(row, "padding", "3px 0")
2990 dom.SetStyle(row, "display", "flex")
2991 dom.SetStyle(row, "alignItems", "center")
2992 dom.SetStyle(row, "gap", "4px")
2993 relayRows = append(relayRows, row)
2994
2995 dot := dom.CreateElement("span")
2996 dom.SetTextContent(dot, "\u25CF")
2997 dom.SetStyle(dot, "color", "#5b5")
2998 dom.SetStyle(dot, "flexShrink", "0")
2999 relayDots = append(relayDots, dot)
3000 dom.AppendChild(row, dot)
3001
3002 label := dom.CreateElement("span")
3003 dom.SetTextContent(label, url)
3004 dom.SetStyle(label, "flex", "1")
3005 dom.SetStyle(label, "overflow", "hidden")
3006 dom.SetStyle(label, "textOverflow", "ellipsis")
3007 dom.SetStyle(label, "whiteSpace", "nowrap")
3008 if userPick {
3009 dom.SetStyle(label, "fontWeight", "bold")
3010 }
3011 relayLabels = append(relayLabels, label)
3012 dom.AppendChild(row, label)
3013
3014 // Separate read / write toggle buttons.
3015 makeRoleBtn := func(label string) dom.Element {
3016 b := dom.CreateElement("button")
3017 dom.SetTextContent(b, label)
3018 dom.SetStyle(b, "flexShrink", "0")
3019 dom.SetStyle(b, "fontSize", "10px")
3020 dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
3021 dom.SetStyle(b, "padding", "1px 5px")
3022 dom.SetStyle(b, "borderRadius", "3px")
3023 dom.SetStyle(b, "border", "1px solid var(--border)")
3024 dom.SetStyle(b, "cursor", "pointer")
3025 return b
3026 }
3027 rBtn := makeRoleBtn("r")
3028 wBtn := makeRoleBtn("w")
3029 relayReadBtns = append(relayReadBtns, rBtn)
3030 relayWriteBtns = append(relayWriteBtns, wBtn)
3031 applyRelayRoleBtns(rBtn, wBtn, role)
3032
3033 capturedURL := url
3034 dom.AddEventListener(rBtn, "click", dom.RegisterCallback(func() {
3035 for i, u := range relayURLs {
3036 if u != capturedURL {
3037 continue
3038 }
3039 switch relayRole[i] {
3040 case "both":
3041 relayRole[i] = "write"
3042 case "read":
3043 relayRole[i] = "write"
3044 default: // "write" - re-enable read
3045 relayRole[i] = "both"
3046 }
3047 applyRelayRoleBtns(relayReadBtns[i], relayWriteBtns[i], relayRole[i])
3048 saveRelayList()
3049 sendWriteRelays()
3050 break
3051 }
3052 }))
3053 dom.AddEventListener(wBtn, "click", dom.RegisterCallback(func() {
3054 for i, u := range relayURLs {
3055 if u != capturedURL {
3056 continue
3057 }
3058 switch relayRole[i] {
3059 case "both":
3060 relayRole[i] = "read"
3061 case "write":
3062 relayRole[i] = "read"
3063 default: // "read" - re-enable write
3064 relayRole[i] = "both"
3065 }
3066 applyRelayRoleBtns(relayReadBtns[i], relayWriteBtns[i], relayRole[i])
3067 saveRelayList()
3068 sendWriteRelays()
3069 break
3070 }
3071 }))
3072 // Empty badge container - populated when NIP-11 fetch completes.
3073 badgeBox := dom.CreateElement("span")
3074 dom.SetStyle(badgeBox, "flexShrink", "0")
3075 relayBadgeContainers = append(relayBadgeContainers, badgeBox)
3076 dom.AppendChild(row, badgeBox)
3077
3078 // r/w buttons at far right.
3079 dom.SetStyle(rBtn, "marginLeft", "auto")
3080 dom.AppendChild(row, rBtn)
3081 dom.AppendChild(row, wBtn)
3082
3083 dom.InsertBefore(popoverEl, row, broadcastRow)
3084 updateStatus()
3085 if feedSelectEl != 0 {
3086 populateFeedSelect()
3087 }
3088
3089 // Fetch NIP-11 capability document asynchronously. The callback captures
3090 // `url` (a fresh parameter per call, not a loop variable) and looks the
3091 // index up at callback time so it still lands correctly if the list grew.
3092 httpURL := url
3093 if len(httpURL) > 6 && httpURL[:6] == "wss://" {
3094 httpURL = "https://" | httpURL[6:]
3095 } else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
3096 httpURL = "http://" | httpURL[5:]
3097 }
3098 dom.FetchRelayInfo(httpURL, func(body string) {
3099 caps := parseNIP11Caps(body)
3100 for i, u := range relayURLs {
3101 if u == url {
3102 relayCaps[i] = caps
3103 relayCapsTs[i] = dom.NowSeconds()
3104 updateRelayBadges(i)
3105 dom.ConsoleLog("[caps] " | url |
3106 " ok=" | boolStr(caps.fetchedOK) |
3107 " nip42=" | boolStr(hasNIP(caps.supportedNIPs, 42)) |
3108 " nip70=" | boolStr(hasNIP(caps.supportedNIPs, 70)) |
3109 " authReq=" | boolStr(caps.authRequired))
3110 return
3111 }
3112 }
3113 })
3114 }
3115
3116 func togglePopover() {
3117 popoverOpen = !popoverOpen
3118 if popoverOpen {
3119 if signerOpen {
3120 hideSignerPanel()
3121 }
3122 closeFeedPopover()
3123 dom.SetStyle(popoverEl, "display", "block")
3124 dom.SetStyle(statusEl, "background", "var(--accent)")
3125 dom.SetStyle(statusEl, "color", "var(--bg)")
3126 } else {
3127 dom.SetStyle(popoverEl, "display", "none")
3128 dom.SetStyle(statusEl, "background", "transparent")
3129 dom.SetStyle(statusEl, "color", "var(--muted)")
3130 }
3131 }
3132
3133 func subscribeProfile() {
3134 dom.ConsoleLog("[prof] subscribing for pubkey: " | pubhex)
3135 rr := readRelayURLs()
3136 proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(rr)}
3137 copy(proxy, discoveryRelays)
3138 for _, u := range rr {
3139 proxy = appendUnique(proxy, u)
3140 }
3141 routeMsg(buildProxyMsg("prof",
3142 "{\"authors\":[" | jstr(pubhex) | "],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}",
3143 proxy))
3144 routeMsg(buildProxyMsg("prof-settings",
3145 "{\"authors\":[" | jstr(pubhex) | "],\"kinds\":[30078],\"#d\":[\"smesh-settings\"],\"limit\":1}",
3146 proxy))
3147 }
3148
3149 var feedLastSubTs int64 // timestamp of last actual subscription
3150
3151 func subscribeFeed() {
3152 // Throttle: collapse rapid calls into one after 2s of quiet.
3153 // Also enforce 10s cooldown - don't resubscribe if nothing changed.
3154 feedSubscribed = true
3155 if feedSubTimer != 0 {
3156 dom.ClearTimeout(feedSubTimer)
3157 }
3158 feedSubTimer = dom.SetTimeout(func() {
3159 feedSubTimer = 0
3160 now := dom.NowSeconds()
3161 if feedLastSubTs > 0 && now-feedLastSubTs < 10 {
3162 return
3163 }
3164 doSubscribeFeed()
3165 }, 2000)
3166 }
3167
3168 func doSubscribeFeed() {
3169 feedLastSubTs = dom.NowSeconds()
3170 dom.ConsoleLog("[sub] feed mode=" | feedMode | " follows=" | itoa(len(myFollows)))
3171 feedExhausted = false
3172 feedEmptyStreak = 0
3173 feedInitialLoad = true
3174 if feedInitialLoadTimer != 0 {
3175 dom.ClearTimeout(feedInitialLoadTimer)
3176 feedInitialLoadTimer = 0
3177 }
3178 feedBuffer = nil
3179 feedHasNew = false
3180 stopRefreshPulse()
3181 dom.SetStyle(loadMoreBtn, "display", "none")
3182
3183 // Sync state to Feed Worker, then tell it to subscribe.
3184 // Feed Worker owns the relay subscriptions via F_SUB/F_CLOSE.
3185 feed.Send(`["F_SET_MODE",` | jstr(feedMode) | `]`)
3186 feed.Send(`["F_SET_RELAYS",` | readRelayURLsJSON() | `]`)
3187 feed.Send(`["F_SET_PUBKEY",` | jstr(pubhex) | `]`)
3188 if len(myFollows) > 0 {
3189 feed.Send(`["F_SET_FOLLOWS",` | buildJSONStrArr(myFollows) | `]`)
3190 }
3191 if len(myMutes) > 0 {
3192 feed.Send(`["F_SET_MUTES",` | buildJSONStrArr(myMutes) | `]`)
3193 }
3194 feed.Send(`["F_REFRESH"]`)
3195 }
3196
3197 func buildFeedFilter(limit int32) (s string) {
3198 if feedMode == "follows" {
3199 follows := myFollows
3200 if len(follows) > 0 {
3201 // Always include own pubkey so user's own posts appear in the feed.
3202 authors := jstr(pubhex)
3203 for _, pk := range follows {
3204 if pk != pubhex {
3205 authors = authors | "," | jstr(pk)
3206 }
3207 }
3208 return "{\"kinds\":[1,6,7,1111],\"authors\":[" | authors | "],\"limit\":" | itoa(limit) | "}"
3209 }
3210 }
3211 return "{\"kinds\":[1,6,7,1111],\"limit\":" | itoa(limit) | "}"
3212 }
3213
3214 func feedPassesFilter(ev *nostr.Event) (ok bool) {
3215 if feedMode != "follows" {
3216 return true
3217 }
3218 follows := myFollows
3219 if len(follows) == 0 {
3220 return true
3221 }
3222 for _, pk := range follows {
3223 if pk == ev.PubKey {
3224 return true
3225 }
3226 }
3227 // Also allow own events.
3228 return ev.PubKey == pubhex
3229 }
3230
3231 func feedRelays() (ss []string) {
3232 if feedMode != "" && feedMode != "follows" && feedMode != "relays" {
3233 // Single relay mode.
3234 return []string{feedMode}
3235 }
3236 return relayURLs
3237 }
3238
3239 func feedModeURL() (s string) {
3240 switch feedMode {
3241 case "", "follows":
3242 return "/feed/follows"
3243 case "relays":
3244 return "/feed/relays"
3245 }
3246 // Per-relay: strip ws:// or wss:// for the URL path.
3247 host := stripScheme(feedMode)
3248 return "/feed/relay/" | host
3249 }
3250
3251 func stripScheme(url string) (s string) {
3252 if len(url) > 6 && url[:6] == "wss://" {
3253 return url[6:]
3254 }
3255 if len(url) > 5 && url[:5] == "ws://" {
3256 return url[5:]
3257 }
3258 return url
3259 }
3260
3261 var feedMoreGot int32
3262 var feedMoreTimer int32
3263
3264 func loadOlderFeed() {
3265 feedLoading = true
3266 feedMoreGot = 0
3267 feed.Send(`["F_LOAD_MORE"]`)
3268 if feedMoreTimer != 0 {
3269 dom.ClearTimeout(feedMoreTimer)
3270 }
3271 feedMoreTimer = dom.SetTimeout(func() {
3272 feedMoreTimer = 0
3273 if feedLoading {
3274 dom.ConsoleLog("[loadOlderFeed] timeout - forcing feedLoading=false, got=" | itoa(feedMoreGot))
3275 feedLoading = false
3276 if feedMoreGot == 0 {
3277 feedEmptyStreak++
3278 if feedEmptyStreak >= 3 {
3279 feedExhausted = true
3280 }
3281 } else {
3282 feedEmptyStreak = 0
3283 }
3284 if feedExhausted {
3285 dom.SetStyle(loadMoreBtn, "display", "none")
3286 } else if oldestFeedTs > 0 {
3287 dom.SetStyle(loadMoreBtn, "display", "block")
3288 }
3289 updateStatus()
3290 }
3291 }, 10000)
3292 }
3293
3294 func populateFeedSelect() {
3295 updateFeedBtnText()
3296 if feedPopoverEl == 0 {
3297 return
3298 }
3299 clearChildren(feedPopoverEl)
3300 if len(myFollows) > 0 {
3301 addFeedOption("follows", "follows")
3302 }
3303 addFeedOption("relays", "relays")
3304 for _, u := range relayURLs {
3305 addFeedOption(u, stripScheme(u))
3306 }
3307 }
3308
3309 func addFeedOption(value, label string) {
3310 row := dom.CreateElement("div")
3311 dom.SetStyle(row, "padding", "6px 8px")
3312 dom.SetStyle(row, "cursor", "pointer")
3313 dom.SetStyle(row, "borderRadius", "4px")
3314 if value == feedMode {
3315 dom.SetStyle(row, "background", "var(--accent)")
3316 dom.SetStyle(row, "color", "var(--bg)")
3317 }
3318 dom.SetTextContent(row, label)
3319 v := value
3320 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
3321 feedMode = v
3322 updateFeedBtnText()
3323 dom.PushState(feedModeURL())
3324 closeFeedPopover()
3325 refreshFeed()
3326 }))
3327 dom.AppendChild(feedPopoverEl, row)
3328 }
3329
3330 func updateFeedBtnText() {
3331 if feedSelectEl == 0 {
3332 return
3333 }
3334 label := feedMode
3335 if label == "follows" && len(myFollows) == 0 {
3336 label = "all relays"
3337 }
3338 if label != "follows" && label != "relays" && label != "all relays" {
3339 label = stripScheme(label)
3340 }
3341 dom.SetTextContent(feedSelectEl, label | " \u25BE")
3342 }
3343
3344 func toggleFeedPopover() {
3345 if feedPopoverOpen {
3346 closeFeedPopover()
3347 return
3348 }
3349 if signerOpen {
3350 hideSignerPanel()
3351 }
3352 if popoverOpen {
3353 togglePopover()
3354 }
3355 feedPopoverOpen = true
3356 populateFeedSelect()
3357 dom.SetStyle(feedPopoverEl, "display", "block")
3358 dom.SetStyle(feedSelectEl, "background", "var(--accent)")
3359 dom.SetStyle(feedSelectEl, "color", "var(--bg)")
3360 }
3361
3362 func closeFeedPopover() {
3363 if !feedPopoverOpen {
3364 return
3365 }
3366 feedPopoverOpen = false
3367 dom.SetStyle(feedPopoverEl, "display", "none")
3368 dom.SetStyle(feedSelectEl, "background", "transparent")
3369 dom.SetStyle(feedSelectEl, "color", "var(--fg)")
3370 updateFeedBtnText()
3371 }
3372
3373 func onRefreshClick() {
3374 if refreshSpinning {
3375 return
3376 }
3377 // Flush buffered new events into the feed.
3378 if len(feedBuffer) > 0 {
3379 for _, ev := range feedBuffer {
3380 renderNote(ev)
3381 seenEvents[ev.ID] = true
3382 }
3383 feedBuffer = nil
3384 feedHasNew = false
3385 stopRefreshPulse()
3386 }
3387 // Scroll to top.
3388 dom.SetProperty(contentArea, "scrollTop", "0")
3389 // Start spin animation.
3390 startRefreshSpin()
3391 // Cancel throttle and resubscribe immediately.
3392 if feedSubTimer != 0 {
3393 dom.ClearTimeout(feedSubTimer)
3394 feedSubTimer = 0
3395 }
3396 feedSubscribed = true
3397 doSubscribeFeed()
3398 }
3399
3400 func refreshFeed() {
3401 // Clear feed container.
3402 clearChildren(feedContainer)
3403 seenEvents = map[string]bool{}
3404 noteElements = map[string]dom.Element{}
3405 eventCount = 0
3406 oldestFeedTs = 0
3407 feedExhausted = false
3408 feedEmptyStreak = 0
3409 feedBuffer = nil
3410 feedHasNew = false
3411 stopRefreshPulse()
3412 dom.SetStyle(loadMoreBtn, "display", "none")
3413 dom.SetProperty(contentArea, "scrollTop", "0")
3414 // Cancel any pending throttled sub and fire immediately.
3415 if feedSubTimer != 0 {
3416 dom.ClearTimeout(feedSubTimer)
3417 feedSubTimer = 0
3418 }
3419 startRefreshSpin()
3420 feedSubscribed = true
3421 doSubscribeFeed()
3422 }
3423
3424 var refreshPulseTimer int32
3425 var refreshPulseOn bool
3426
3427 func startRefreshPulse() {
3428 if refreshBtn == 0 || refreshPulseTimer != 0 {
3429 return
3430 }
3431 dom.SetStyle(refreshBtn, "color", "var(--accent)")
3432 refreshPulseOn = true
3433 pulseStep()
3434 }
3435
3436 func pulseStep() {
3437 if refreshPulseTimer == -1 || refreshBtn == 0 {
3438 return
3439 }
3440 if refreshPulseOn {
3441 dom.SetStyle(refreshBtn, "opacity", "0.4")
3442 } else {
3443 dom.SetStyle(refreshBtn, "opacity", "1")
3444 }
3445 refreshPulseOn = !refreshPulseOn
3446 refreshPulseTimer = dom.SetTimeout(func() {
3447 pulseStep()
3448 }, 800)
3449 }
3450
3451 func stopRefreshPulse() {
3452 if refreshPulseTimer != 0 {
3453 dom.ClearTimeout(refreshPulseTimer)
3454 }
3455 refreshPulseTimer = -1
3456 dom.SetTimeout(func() {
3457 refreshPulseTimer = 0
3458 }, 0)
3459 if refreshBtn != 0 {
3460 dom.SetStyle(refreshBtn, "color", "var(--muted)")
3461 dom.SetStyle(refreshBtn, "opacity", "1")
3462 }
3463 }
3464
3465 var refreshSpinAngle int32
3466 var refreshSpinTimer int32
3467
3468 func startRefreshSpin() {
3469 refreshSpinning = true
3470 if refreshBtn == 0 {
3471 return
3472 }
3473 refreshSpinAngle = 0
3474 spinStep()
3475 }
3476
3477 func spinStep() {
3478 if !refreshSpinning || refreshBtn == 0 {
3479 return
3480 }
3481 refreshSpinAngle += 30
3482 dom.SetStyle(refreshBtn, "transform", "rotate(" | itoa(refreshSpinAngle) | "deg)")
3483 refreshSpinTimer = dom.SetTimeout(func() {
3484 spinStep()
3485 }, 50)
3486 }
3487
3488 func stopRefreshSpin() {
3489 refreshSpinning = false
3490 if refreshSpinTimer != 0 {
3491 dom.ClearTimeout(refreshSpinTimer)
3492 refreshSpinTimer = 0
3493 }
3494 if refreshBtn != 0 {
3495 dom.SetStyle(refreshBtn, "transform", "rotate(0deg)")
3496 }
3497 }
3498
3499 func trackOldestTs(ev *nostr.Event) {
3500 if oldestFeedTs == 0 || ev.CreatedAt < oldestFeedTs {
3501 oldestFeedTs = ev.CreatedAt
3502 }
3503 }
3504
3505 func parseIntProp(s string) (n int32) {
3506 n = 0
3507 for i := 0; i < len(s); i++ {
3508 if s[i] >= '0' && s[i] <= '9' {
3509 n = n*10 + int32(s[i]-'0')
3510 } else {
3511 break
3512 }
3513 }
3514 return n
3515 }
3516
3517 func boolStr(b bool) (s string) {
3518 if b {
3519 return "true"
3520 }
3521 return "false"
3522 }
3523
3524 func i64toa(n int64) (s string) {
3525 if n == 0 {
3526 return "0"
3527 }
3528 var buf [20]byte
3529 i := len(buf)
3530 for n > 0 {
3531 i--
3532 buf[i] = byte('0' + n%10)
3533 n /= 10
3534 }
3535 return string(buf[i:])
3536 }
3537
3538 func sendWriteRelays() {
3539 msg := "[\"SET_WRITE_RELAYS\",["
3540 first := true
3541 for i, url := range relayURLs {
3542 role := "both"
3543 if i < len(relayRole) {
3544 role = relayRole[i]
3545 }
3546 if role == "read" {
3547 continue
3548 }
3549 if !first {
3550 msg = msg | ","
3551 }
3552 msg = msg | jstr(url)
3553 first = false
3554 }
3555 routeMsg(msg | "]]")
3556 }
3557
3558 // readRelayURLs returns relay URLs usable for reading (role "both" or "read").
3559 func readRelayURLs() (ss []string) {
3560 var out []string
3561 for i, url := range relayURLs {
3562 role := "both"
3563 if i < len(relayRole) {
3564 role = relayRole[i]
3565 }
3566 if role != "write" {
3567 out = append(out, url)
3568 }
3569 }
3570 return out
3571 }
3572
3573 func buildProxyMsg(subID, filterJSON string, urls []string) (s string) {
3574 msg := "[\"PROXY\"," | jstr(subID) | "," | filterJSON | ",["
3575 for i, url := range urls {
3576 if i > 0 {
3577 msg = msg | ","
3578 }
3579 msg = msg | jstr(url)
3580 }
3581 return msg | "]]"
3582 }
3583
3584 func jstr(s string) (sv string) {
3585 return "\"" | jsonEsc(s) | "\""
3586 }
3587
3588 // scheduleTabRetry schedules a retry for any pending profile fetches after
3589 // the follows/mutes tab renders. Independent of retryRound so it works even
3590 // after the feed's retry budget is exhausted.
3591 func scheduleTabRetry() {
3592 dom.SetTimeout(func() {
3593 var missing []string
3594 for pk := range pendingNotes {
3595 if _, ok := authorNames[pk]; !ok {
3596 missing = append(missing, pk)
3597 }
3598 }
3599 if len(missing) == 0 {
3600 return
3601 }
3602 for _, pk := range missing {
3603 setFetchedK0(pk, false)
3604 }
3605 for _, pk := range missing {
3606 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
3607 }
3608 }, 5000)
3609 }
3610
3611 // --- SW message handling ---
3612
3613 func onSWMessage(raw string) {
3614 if raw == "update-available" {
3615 routeMsg("activate-update")
3616 return
3617 }
3618 if raw == "reload" {
3619 dom.LocationReload()
3620 return
3621 }
3622 if !appReady {
3623 return
3624 }
3625 if len(raw) < 5 || raw[0] != '[' {
3626 return
3627 }
3628 typ, pos := nextStr(raw, 1)
3629 switch typ {
3630 case "EVENT":
3631 subID, pos2 := nextStr(raw, pos)
3632 evJSON := extractValue(raw, pos2)
3633 if evJSON == "" {
3634 return
3635 }
3636 ev := nostr.ParseEvent(evJSON)
3637 if ev == nil {
3638 return
3639 }
3640 dispatchEvent(subID, ev)
3641 case "EOSE":
3642 subID, _ := nextStr(raw, pos)
3643 dispatchEOSE(subID)
3644 case "SW_LOG":
3645 msg, _ := nextStr(raw, pos)
3646 dom.ConsoleLog("[SW] " | msg)
3647 case "BROADCAST_DONE":
3648 if broadcastBtnEl != 0 {
3649 dom.SetTextContent(broadcastBtnEl, "broadcast profile \u2713")
3650 dom.SetStyle(broadcastBtnEl, "color", "var(--accent)")
3651 dom.SetTimeout(func() {
3652 dom.SetTextContent(broadcastBtnEl, "broadcast profile")
3653 }, 3000)
3654 }
3655 case "MLS_DM_LIST":
3656 listJSON := extractValue(raw, pos)
3657 renderConversationList(listJSON)
3658 case "MLS_DM_HISTORY":
3659 peer, pos2 := nextStr(raw, pos)
3660 msgsJSON := extractValue(raw, pos2)
3661 renderThreadMessages(peer, msgsJSON)
3662 case "MLS_DM_RECEIVED":
3663 dmJSON := extractValue(raw, pos)
3664 handleDMReceived(dmJSON)
3665 case "MLS_DM_SENT":
3666 // ok/err from MLS worker on send completion
3667 case "MLS_UNREAD_COUNT":
3668 // unread count badge (future step)
3669 case "MLS_DM_HISTORY_CLEARED":
3670 peer, _ := nextStr(raw, pos)
3671 dom.ConsoleLog("[mls] history cleared for " | peer)
3672 case "MLS_STATUS":
3673 text, _ := nextStr(raw, pos)
3674 dom.ConsoleLog("[mls] " | text)
3675 case "OK":
3676 okEvID, pos2 := nextStr(raw, pos)
3677 // Boolean follows: skip comma/space, check for "true".
3678 for pos2 < len(raw) && (raw[pos2] == ',' || raw[pos2] == ' ') {
3679 pos2++
3680 }
3681 isOK := pos2+4 <= len(raw) && raw[pos2:pos2+4] == "true"
3682 if isOK {
3683 if targetID, ok := pendingDeletes[okEvID]; ok {
3684 removeNoteFromDOM(targetID)
3685 delete(pendingDeletes, okEvID)
3686 }
3687 } else {
3688 // Reason follows after bool.
3689 for pos2 < len(raw) && raw[pos2] != '"' {
3690 pos2++
3691 }
3692 reason, _ := nextStr(raw, pos2)
3693 logActivity("relay-err", okEvID[:12] | ": " | reason, "event " | okEvID | "\nreason: " | reason)
3694 }
3695 case "SEEN_ON":
3696 evID, pos2 := nextStr(raw, pos)
3697 rURL, _ := nextStr(raw, pos2)
3698 // Strip trailing slash for dedup.
3699 if len(rURL) > 0 && rURL[len(rURL)-1] == '/' {
3700 rURL = rURL[:len(rURL)-1]
3701 }
3702 if evID != "" && rURL != "" && eventRelays != nil {
3703 existing := eventRelays[evID]
3704 found := false
3705 for _, u := range existing {
3706 if u == rURL {
3707 found = true
3708 break
3709 }
3710 }
3711 if !found {
3712 eventRelays[evID] = append(existing, rURL)
3713 logActivity("seen-on", evID[:12] | " on " | rURL, "event " | evID | "\nrelay " | rURL)
3714 if cb, ok := seenOnSubs[evID]; ok && cb != nil {
3715 cb()
3716 }
3717 }
3718 }
3719 case "RELAY_PROXY_READY":
3720 // Relay-proxy worker restarted (WASM fatal recovery). Re-send init
3721 // state and re-subscribe so the worker is functional again.
3722 if pubhex != "" {
3723 routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]")
3724 }
3725 feedSubscribed = false
3726 resubscribe()
3727 case "NEED_IDENTITY":
3728 if pubhex != "" {
3729 routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]")
3730 }
3731 resubscribe()
3732 case "RESUB":
3733 resubscribe()
3734
3735 case "WORKER_RESTARTED":
3736 // A domain worker crashed and restarted. Re-provision it with current state.
3737 which, _ := nextStr(raw, pos)
3738 if pubhex == "" {
3739 return
3740 }
3741 if which == "/profile-wasm-host.mjs" {
3742 profile.Send("[\"P_SET_PUBKEY\"," | jstr(pubhex) | "]")
3743 profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
3744 } else if which == "/feed-wasm-host.mjs" {
3745 doSubscribeFeed()
3746 } else if which == "/mls-wasm-host.mjs" {
3747 mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]")
3748 } else if which == "/notif-wasm-host.mjs" {
3749 subscribeNotifications()
3750 }
3751
3752 // ── Profile Worker responses ─────────────────────────────────────────────
3753 case "P_RESOLVED":
3754 pk3, pos2a := nextStr(raw, pos)
3755 name3, pos3a := nextStr(raw, pos2a)
3756 pic3, _ := nextStr(raw, pos3a)
3757 // Update view cache.
3758 if len(name3) > 0 {
3759 setAuthorName(pk3, name3)
3760 }
3761 if len(pic3) > 0 {
3762 setAuthorPic(pk3, pic3)
3763 }
3764 // Fill pending note headers.
3765 if len(name3) > 0 {
3766 if headers, ok := pendingNotes[pk3]; ok {
3767 for _, h := range headers {
3768 updateNoteHeader(h, name3, pic3)
3769 }
3770 delete(pendingNotes, pk3)
3771 }
3772 }
3773 if len(pic3) > 0 {
3774 for _, avaEl := range pendingNotifAvatars[pk3] {
3775 setMediaSrc(avaEl, pic3)
3776 }
3777 delete(pendingNotifAvatars, pk3)
3778 }
3779 if pk3 == pubhex {
3780 if len(name3) > 0 { profileName = name3; dom.SetTextContent(nameEl, name3) }
3781 if len(pic3) > 0 { profilePic = pic3; setMediaSrc(avatarEl, pic3); dom.SetStyle(avatarEl, "display", "block") }
3782 }
3783 updateReplyPreviewsForAuthor(pk3)
3784 updateInlineProfileLinks(pk3)
3785
3786 case "P_CONTENT":
3787 pk4, pos2b := nextStr(raw, pos)
3788 contentJSON, _ := nextStr(raw, pos2b)
3789 profileContentCache[pk4] = contentJSON
3790 lud16 := helpers.JsonGetString(contentJSON, "lud16")
3791 lud06 := helpers.JsonGetString(contentJSON, "lud06")
3792 if lud16 != "" || lud06 != "" {
3793 applyZapButtonsForAuthor(pk4)
3794 } else {
3795 delete(pendingZapRows, pk4)
3796 }
3797 // Re-render profile page if it's the viewed profile.
3798 if profileViewPK == pk4 && activePage == "profile" {
3799 renderProfilePage(pk4)
3800 }
3801 // Update own profile name if this is our own kind 0.
3802 if pk4 == pubhex {
3803 name4 := helpers.JsonGetString(contentJSON, "name")
3804 if name4 == "" { name4 = helpers.JsonGetString(contentJSON, "display_name") }
3805 if name4 != "" && profileName != name4 {
3806 profileName = name4
3807 dom.SetTextContent(nameEl, name4)
3808 }
3809 }
3810 // Persist to IDB.
3811 if len(contentJSON) > 0 {
3812 dom.IDBPut("profiles", pk4, contentJSON)
3813 }
3814
3815 case "P_FOLLOWS":
3816 pkF, pos2c := nextStr(raw, pos)
3817 followsJSON := extractValue(raw, pos2c)
3818 pksF := parseStringArrayJSON(followsJSON)
3819 followsChanged := false
3820 if pkF == pubhex {
3821 if len(pksF) != len(myFollows) {
3822 followsChanged = true
3823 }
3824 myFollows = pksF
3825 followSet = buildSet(pksF)
3826 broadcastFollows(pksF)
3827 if feedSelectEl != 0 { populateFeedSelect() }
3828 }
3829 profileFollowsCache[pkF] = pksF
3830 refreshProfileTab(pkF)
3831 if pkF == pubhex && feedSubscribed && feedMode == "follows" && followsChanged {
3832 subscribeFeed()
3833 }
3834
3835 case "P_MUTES":
3836 pkM, pos2d := nextStr(raw, pos)
3837 mutesJSON := extractValue(raw, pos2d)
3838 pksM := parseStringArrayJSON(mutesJSON)
3839 if pkM == pubhex {
3840 myMutes = pksM
3841 muteSet = buildSet(pksM)
3842 broadcastMutes(pksM)
3843 }
3844 profileMutesCache[pkM] = pksM
3845 refreshProfileTab(pkM)
3846
3847 case "P_RELAYS":
3848 pkR, pos2e := nextStr(raw, pos)
3849 relaysJSON := extractValue(raw, pos2e)
3850 profileRelaysCache[pkR] = parseStringArrayJSON(relaysJSON)
3851 if profileViewPK == pkR { refreshProfileTab(pkR) }
3852
3853 // ── Feed Worker responses ─────────────────────────────────────────────────
3854 case "F_RENDER":
3855 evJSON2 := extractValue(raw, pos)
3856 if evJSON2 == "" { return }
3857 fev := nostr.ParseEvent(evJSON2)
3858 if fev == nil { return }
3859 // pos+len(evJSON2)+... find 'now' flag
3860 nowFlag := int64(0)
3861 for i := pos + len(evJSON2); i < len(raw); i++ {
3862 c := raw[i]
3863 if c >= '0' && c <= '9' {
3864 nowFlag = int64(c - '0')
3865 break
3866 }
3867 }
3868 setEvent(fev.ID, fev)
3869 if fev.Kind == 7 {
3870 handleActionEvent(fev)
3871 return
3872 }
3873 trackOldestTs(fev)
3874 if nowFlag == 1 {
3875 if feedLoader != 0 {
3876 dom.RemoveChild(feedPage, feedLoader)
3877 feedLoader = 0
3878 }
3879 renderNote(fev)
3880 } else {
3881 if noteElements[fev.ID] != 0 || seenEvents[fev.ID] {
3882 return
3883 }
3884 feedBuffer = append(feedBuffer, fev)
3885 if !feedHasNew {
3886 feedHasNew = true
3887 startRefreshPulse()
3888 }
3889 }
3890 case "F_EOSE_DONE":
3891 subID2, _ := nextStr(raw, pos)
3892 if subID2 == "feed" {
3893 feedInitialLoad = false
3894 updateStatus()
3895 fetchDeletesForFeed()
3896 }
3897 case "F_STATUS":
3898 // ["F_STATUS", count, exhausted]
3899 cntStr := nextNum(raw, pos)
3900 var cnt int64
3901 for _, c := range cntStr {
3902 if c >= '0' && c <= '9' { cnt = cnt*10 + int64(c-'0') }
3903 }
3904 eventCount = int32(cnt)
3905 dom.ConsoleLog("[feed] events=" | itoa(eventCount))
3906 // Find exhausted flag after count.
3907 exStr := ""
3908 for p2 := pos + len(cntStr); p2 < len(raw); p2++ {
3909 if raw[p2] >= '0' && raw[p2] <= '9' {
3910 exStr = nextNum(raw, p2)
3911 break
3912 }
3913 }
3914 if exStr == "1" {
3915 feedExhausted = true
3916 }
3917 updateStatus()
3918 if feedLoading {
3919 feedLoading = false
3920 }
3921 if feedExhausted {
3922 dom.SetStyle(loadMoreBtn, "display", "none")
3923 } else if oldestFeedTs > 0 {
3924 dom.SetStyle(loadMoreBtn, "display", "block")
3925 }
3926
3927 // ── Notif Worker responses ────────────────────────────────────────────────
3928 case "N_RENDER":
3929 nEvJSON := extractValue(raw, pos)
3930 if nEvJSON == "" { return }
3931 nev := nostr.ParseEvent(nEvJSON)
3932 if nev == nil { return }
3933 notifEvents = append(notifEvents, nev)
3934 if len(notifEvents) > notifEventsMax { notifEvents = notifEvents[len(notifEvents)-notifEventsMax:] }
3935 if notifBuilt && activePage == "notifications" && notifPassesFilter(nev) {
3936 row := renderNotifRow(nev)
3937 // mode: "prepend" = first child, "append" = append
3938 modeStr := ""
3939 for i := pos + len(nEvJSON); i < len(raw); i++ {
3940 if raw[i] == '"' {
3941 j := i + 1
3942 for j < len(raw) && raw[j] != '"' { j++ }
3943 modeStr = raw[i+1:j]
3944 break
3945 }
3946 }
3947 if modeStr == "prepend" {
3948 first := dom.FirstElementChild(notifContainer)
3949 if first != 0 { dom.InsertBefore(notifContainer, row, first) } else { dom.AppendChild(notifContainer, row) }
3950 } else {
3951 dom.AppendChild(notifContainer, row)
3952 }
3953 }
3954 case "N_DOT":
3955 showStr := nextNum(raw, pos)
3956 if showStr == "1" { showNotifDot() }
3957 case "N_STATUS":
3958 // ["N_STATUS", count, exhausted]
3959 nCntStr := nextNum(raw, pos)
3960 nExStr := ""
3961 for p3 := pos + len(nCntStr); p3 < len(raw); p3++ {
3962 if raw[p3] >= '0' && raw[p3] <= '9' {
3963 nExStr = nextNum(raw, p3)
3964 break
3965 }
3966 }
3967 if nExStr == "1" {
3968 notifExhausted = true
3969 }
3970 notifLoading = false
3971 if notifExhausted && notifLoadMore != 0 {
3972 dom.SetStyle(notifLoadMore, "display", "none")
3973 } else if oldestNotifTs > 0 && notifLoadMore != 0 {
3974 dom.SetStyle(notifLoadMore, "display", "block")
3975 }
3976 case "N_EOSE_DONE":
3977 subIDN, _ := nextStr(raw, pos)
3978 if subIDN == "ntf" {
3979 notifInitLoad = false
3980 sortNotifEvents()
3981 if activePage == "notifications" {
3982 if notifBuilt {} else { renderNotifPage() }
3983 }
3984 if oldestNotifTs > 0 && notifLoadMore != 0 { dom.SetStyle(notifLoadMore, "display", "block") }
3985 }
3986 case "N_FETCH_REFS":
3987 // Forward to relay-proxy: fetch the referenced events so notif row names can fill in.
3988 idsJSON := extractValue(raw, pos)
3989 if idsJSON != "" {
3990 routeMsg(`["PROXY","ntf-ref",{"ids":` | idsJSON | `,"limit":` | itoa(len(notifMissing)) | `},` | buildURLsJSON(readRelayURLs()) | `]`)
3991 }
3992 case "N_STORE_READ_TS":
3993 tsStr2 := nextNum(raw, pos)
3994 var ts2 int64
3995 for _, c := range tsStr2 { if c >= '0' && c <= '9' { ts2 = ts2*10 + int64(c-'0') } }
3996 if ts2 > 0 { dom.IDBPut("settings", "notif-read-ts", i64toa(ts2)) }
3997
3998 // ── DM Worker responses ───────────────────────────────────────────────────
3999 case "D_LIST_RESP":
4000 listJSON2 := extractValue(raw, pos)
4001 renderConversationList(listJSON2)
4002 case "D_HISTORY_RESP":
4003 peerD, pos3 := nextStr(raw, pos)
4004 msgsD := extractValue(raw, pos3)
4005 renderThreadMessages(peerD, msgsD)
4006 case "D_RECEIVED":
4007 dmJ := extractValue(raw, pos)
4008 handleDMReceived(dmJ)
4009 case "D_SENT":
4010 _, pos4 := nextStr(raw, pos) // peer
4011 okStr, _ := nextStr(raw, pos4)
4012 if okStr == "1" || okStr == "true" {
4013 if len(pendingTsEls) > 0 {
4014 dom.SetTextContent(pendingTsEls[0], formatTime(dom.NowSeconds()))
4015 pendingTsEls = pendingTsEls[1:]
4016 }
4017 }
4018
4019 // ── Profile Worker retry request ──────────────────────────────────────────
4020 case "P_RETRY_REQ":
4021 // Profile Worker is asking for pending pubkeys that still lack a name.
4022 // Collect from pendingNotes and send back as P_RETRY_PROVIDE.
4023 var pending []string
4024 for pk := range pendingNotes {
4025 if _, ok := authorNames[pk]; !ok {
4026 pending = append(pending, pk)
4027 }
4028 }
4029 if len(pending) > 0 {
4030 profile.Send(`["P_RETRY_PROVIDE",` | buildJSONStrArr(pending) | `]`)
4031 }
4032 }
4033 }
4034
4035 func resubscribe() {
4036 sendWriteRelays()
4037 subscribeProfile()
4038 subscribeFeed()
4039 subscribeNotifications()
4040 // Sync domain workers with current state.
4041 if pubhex != "" {
4042 profile.Send("[\"P_SET_PUBKEY\"," | jstr(pubhex) | "]")
4043 profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
4044 mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]")
4045 }
4046 if activePage == "messaging" {
4047 initMessaging()
4048 }
4049 if activePage == "profile" && profileTab == "notes" && profileViewPK != "" {
4050 renderProfileNotes(profileViewPK)
4051 }
4052 }
4053
4054 func dispatchEvent(subID string, ev *nostr.Event) {
4055 if subID == "prof" || subID == "prof-settings" {
4056 handleProfileEvent(ev)
4057 } else if subID == "feed" || subID == "feed-more" {
4058 // Route to Feed Worker for dedup/filter/buffer management.
4059 feed.Send(`["F_EVENT",` | jstr(subID) | `,` | ev.ToJSON() | `]`)
4060 } else if len(subID) > 3 && subID[:3] == "ap-" {
4061 // Profile Worker owns all profile data; forward the event for processing.
4062 // P_RESOLVED/P_CONTENT/P_FOLLOWS/P_MUTES/P_RELAYS come back and Shell handles DOM.
4063 profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
4064 } else if len(subID) > 3 && subID[:3] == "pn-" {
4065 if profileNotesSeen[ev.ID] {
4066 return
4067 }
4068 profileNotesSeen[ev.ID] = true
4069 // Dedup against the broad feed sub: relays send the same event on
4070 // every matching sub on a connection, so a profile-author's new
4071 // note also arrives on "feed". Marking it here prevents it from
4072 // being double-rendered into feedContainer later in the session.
4073 seenEvents[ev.ID] = true
4074 renderProfileNote(ev)
4075 } else if len(subID) > 6 && subID[:6] == "emb-c-" {
4076 setEvent(ev.ID, ev)
4077 fillCoordEmbed(ev)
4078 } else if len(subID) > 7 && subID[:7] == "emb-cr-" {
4079 setEvent(ev.ID, ev)
4080 fillCoordEmbed(ev)
4081 } else if len(subID) > 4 && subID[:4] == "emb-" {
4082 setEvent(ev.ID, ev)
4083 fillEmbed(ev)
4084 } else if len(subID) > 3 && subID[:3] == "rp-" {
4085 handleReplyPreviewEvent(ev)
4086 } else if len(subID) > 4 && subID[:4] == "thr-" {
4087 handleThreadEvent(threadGen, ev)
4088 } else if len(subID) > 4 && subID[:4] == "act-" {
4089 handleActionEvent(ev)
4090 } else if subID == "ntf" {
4091 handleNotifEvent(ev)
4092 } else if subID == "ntf-ref" {
4093 handleNotifRefEvent(ev)
4094 } else if subID == "ntf-more" {
4095 handleNotifMoreEvent(ev)
4096 } else if subID == "search" {
4097 handleSearchEvent(ev)
4098 } else if subID == "del-scan" {
4099 if ev.Kind == 5 {
4100 for _, tag := range ev.Tags.GetAll("e") {
4101 targetID := tag.Value()
4102 if targetID != "" {
4103 removeNoteFromDOM(targetID)
4104 }
4105 }
4106 }
4107 }
4108 }
4109
4110 func dispatchEOSE(subID string) {
4111 // Route to domain workers first.
4112 if subID == "feed" || subID == "feed-more" {
4113 feed.Send(`["F_EOSE",` | jstr(subID) | `]`)
4114 }
4115 if subID == "ntf" || subID == "ntf-more" || subID == "ntf-ref" {
4116 notif.Send(`["N_EOSE",` | jstr(subID) | `]`)
4117 }
4118 if (len(subID) > 3 && subID[:3] == "ap-") || (len(subID) > 9 && subID[:9] == "ap-batch-") {
4119 profile.Send(`["P_EOSE",` | jstr(subID) | `]`)
4120 }
4121
4122 if subID == "feed-more" {
4123 dom.ConsoleLog("[EOSE] feed-more got=" | itoa(feedMoreGot))
4124 if feedMoreTimer != 0 {
4125 dom.ClearTimeout(feedMoreTimer)
4126 feedMoreTimer = 0
4127 }
4128 feedLoading = false
4129 if feedMoreGot == 0 {
4130 feedEmptyStreak++
4131 if feedEmptyStreak >= 3 {
4132 feedExhausted = true
4133 }
4134 } else {
4135 feedEmptyStreak = 0
4136 }
4137 if !feedExhausted && oldestFeedTs > 0 {
4138 dom.SetStyle(loadMoreBtn, "display", "block")
4139 }
4140 updateStatus()
4141 } else if subID == "feed" {
4142 // Don't flip feedInitialLoad immediately. The SW sends EOSE on the
4143 // FIRST relay's response (often the empty local relay), but slower
4144 // remote relays keep streaming events for several seconds. Holding the
4145 // initial-load window open lets those events render directly instead
4146 // of being trapped in feedBuffer until the user clicks refresh.
4147 if feedInitialLoadTimer != 0 {
4148 dom.ClearTimeout(feedInitialLoadTimer)
4149 }
4150 feedInitialLoadTimer = dom.SetTimeout(func() {
4151 feedInitialLoadTimer = 0
4152 feedInitialLoad = false
4153 }, 3000)
4154 if refreshSpinning {
4155 stopRefreshSpin()
4156 }
4157 if feedLoader != 0 {
4158 dom.RemoveChild(feedPage, feedLoader)
4159 feedLoader = 0
4160 }
4161 if oldestFeedTs > 0 {
4162 dom.SetStyle(loadMoreBtn, "display", "block")
4163 }
4164 updateStatus()
4165 retryMissingProfiles()
4166 fetchDeletesForFeed()
4167 } else if subID == "ntf" {
4168 dom.ConsoleLog("[EOSE] ntf events=" | itoa(len(notifEvents)))
4169 notifInitLoad = false
4170 sortNotifEvents()
4171 if activePage == "notifications" {
4172 if notifBuilt {
4173 // Events were rendered incrementally during load; skip the
4174 // clear+rebuild flash. notifEvents is sorted for future renders
4175 // (filter changes, load-more). The "load more" button appears below.
4176 } else {
4177 renderNotifPage()
4178 }
4179 }
4180 if oldestNotifTs > 0 && notifLoadMore != 0 {
4181 dom.SetStyle(notifLoadMore, "display", "block")
4182 }
4183 fetchNotifRefs()
4184 } else if subID == "ntf-ref" {
4185 if notifBuilt && activePage == "notifications" {
4186 rebuildNotifRows()
4187 }
4188 } else if subID == "ntf-more" {
4189 dom.ConsoleLog("[EOSE] ntf-more got=" | itoa(notifMoreGot) | " streak=" | itoa(notifEmptyStrk))
4190 if notifMoreTimer != 0 {
4191 dom.ClearTimeout(notifMoreTimer)
4192 notifMoreTimer = 0
4193 }
4194 notifLoading = false
4195 if notifMoreGot == 0 {
4196 notifEmptyStrk++
4197 if notifEmptyStrk >= 3 {
4198 notifExhausted = true
4199 }
4200 } else {
4201 notifEmptyStrk = 0
4202 }
4203 if !notifExhausted && oldestNotifTs > 0 && notifLoadMore != 0 {
4204 dom.SetStyle(notifLoadMore, "display", "block")
4205 }
4206 fetchNotifRefs()
4207 } else if subID == "search" {
4208 dom.ConsoleLog("[EOSE] search results=" | itoa(len(searchResults)))
4209 renderSearchResults()
4210 } else if subID == "del-scan" {
4211 routeMsg("[\"CLOSE\",\"del-scan\"]")
4212 } else if len(subID) > 9 && subID[:9] == "ap-batch-" {
4213 // Delay CLOSE; Profile Worker handles retry logic via P_EOSE (sent above).
4214 closeID := subID
4215 dom.SetTimeout(func() {
4216 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4217 }, 15000)
4218 } else if len(subID) > 3 && subID[:3] == "ap-" {
4219 // Delay CLOSE; Profile Worker handles relay-hint retry via P_EOSE (sent above).
4220 closeID := subID
4221 dom.SetTimeout(func() {
4222 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4223 }, 15000)
4224 } else if len(subID) > 6 && subID[:6] == "emb-c-" {
4225 closeID := subID
4226 dom.SetTimeout(func() {
4227 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4228 embedCoordEOSECleanup(closeID)
4229 }, 5000)
4230 } else if len(subID) > 7 && subID[:7] == "emb-cr-" {
4231 closeID := subID
4232 dom.SetTimeout(func() {
4233 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4234 embedCoordEOSECleanup(closeID)
4235 }, 5000)
4236 } else if len(subID) > 4 && subID[:4] == "emb-" {
4237 closeID := subID
4238 dom.SetTimeout(func() {
4239 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4240 embedEOSECleanup(closeID)
4241 }, 5000)
4242 } else if len(subID) > 3 && subID[:3] == "rp-" {
4243 closeID := subID
4244 dom.SetTimeout(func() {
4245 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4246 }, 5000)
4247 } else if len(subID) > 4 && subID[:4] == "thr-" {
4248 closeID := subID
4249 dom.SetTimeout(func() {
4250 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4251 }, 5000)
4252 if threadOpen {
4253 handleThreadEOSE()
4254 }
4255 } else if len(subID) > 4 && subID[:4] == "act-" {
4256 closeID := subID
4257 dom.SetTimeout(func() {
4258 routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4259 }, 10000)
4260 }
4261 }
4262
4263 // nextNum extracts a bare number from s starting at pos, returning it as a string.
4264 func nextNum(s string, pos int32) (sv string) {
4265 for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') {
4266 pos++
4267 }
4268 start := pos
4269 for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
4270 pos++
4271 }
4272 return s[start:pos]
4273 }
4274
4275 // nextStr extracts the next quoted string from s starting at pos.
4276 func nextStr(s string, pos int32) (string, int32) {
4277 for pos < len(s) && s[pos] != '"' {
4278 pos++
4279 }
4280 if pos >= len(s) {
4281 return "", pos
4282 }
4283 pos++
4284 var buf []byte
4285 hasEsc := false
4286 start := pos
4287 for pos < len(s) {
4288 if s[pos] == '\\' && pos+1 < len(s) {
4289 hasEsc = true
4290 buf = buf | s[start:pos]
4291 pos++
4292 switch s[pos] {
4293 case '"', '\\', '/':
4294 buf = append(buf, s[pos])
4295 case 'n':
4296 buf = append(buf, '\n')
4297 case 't':
4298 buf = append(buf, '\t')
4299 case 'r':
4300 buf = append(buf, '\r')
4301 default:
4302 buf = append(buf, '\\', s[pos])
4303 }
4304 pos++
4305 start = pos
4306 continue
4307 }
4308 if s[pos] == '"' {
4309 break
4310 }
4311 pos++
4312 }
4313 if pos >= len(s) {
4314 return "", pos
4315 }
4316 var val string
4317 if hasEsc {
4318 buf = buf | s[start:pos]
4319 val = string(buf)
4320 } else {
4321 val = s[start:pos]
4322 }
4323 pos++
4324 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
4325 pos++
4326 }
4327 return val, pos
4328 }
4329
4330 // extractValue extracts a JSON object/array value starting at pos.
4331 func extractValue(s string, pos int32) (sv string) {
4332 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
4333 pos++
4334 }
4335 if pos >= len(s) {
4336 return ""
4337 }
4338 if s[pos] != '{' && s[pos] != '[' {
4339 return ""
4340 }
4341 start := pos
4342 depth := 0
4343 for pos < len(s) {
4344 c := s[pos]
4345 if c == '{' || c == '[' {
4346 depth++
4347 }
4348 if c == '}' || c == ']' {
4349 depth--
4350 if depth == 0 {
4351 return s[start : pos+1]
4352 }
4353 }
4354 if c == '"' {
4355 pos++
4356 for pos < len(s) && s[pos] != '"' {
4357 if s[pos] == '\\' {
4358 pos++
4359 }
4360 pos++
4361 }
4362 }
4363 pos++
4364 }
4365 return s[start:]
4366 }
4367
4368 // parseStringArrayJSON parses a JSON array of strings like ["a","b","c"].
4369 func parseStringArrayJSON(json string) (ss []string) {
4370 var out []string
4371 i := 0
4372 for i < len(json) && json[i] != '[' { i++ }
4373 if i >= len(json) { return nil }
4374 i++
4375 for {
4376 for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ }
4377 if i >= len(json) || json[i] == ']' { break }
4378 if json[i] != '"' { break }
4379 i++
4380 start := i
4381 for i < len(json) && json[i] != '"' {
4382 if json[i] == '\\' { i++ }
4383 i++
4384 }
4385 if i >= len(json) { break }
4386 out = append(out, json[start:i])
4387 i++
4388 }
4389 return out
4390 }
4391
4392 func handleProfileEvent(ev *nostr.Event) {
4393 // Forward to Profile Worker for data storage in all cases.
4394 profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
4395 // Shell handles DOM-immediate effects for the logged-in user's own events.
4396 switch ev.Kind {
4397 case 0:
4398 if ev.CreatedAt <= profileTs {
4399 return
4400 }
4401 profileTs = ev.CreatedAt
4402 // P_CONTENT from Profile Worker will handle profile page re-render + IDB.
4403 // Update own header directly here for immediate feedback.
4404 name := helpers.JsonGetString(ev.Content, "name")
4405 if len(name) == 0 {
4406 name = helpers.JsonGetString(ev.Content, "display_name")
4407 }
4408 pic := helpers.JsonGetString(ev.Content, "picture")
4409 if len(name) > 0 {
4410 profileName = name
4411 setAuthorName(pubhex, name)
4412 dom.SetTextContent(nameEl, name)
4413 }
4414 if len(pic) > 0 {
4415 profilePic = pic
4416 setAuthorPic(pubhex, pic)
4417 setMediaSrc(avatarEl, pic)
4418 dom.SetStyle(avatarEl, "display", "block")
4419 }
4420 case 3:
4421 if ev.CreatedAt <= contactTs {
4422 return
4423 }
4424 contactTs = ev.CreatedAt
4425 var pks []string
4426 for _, tag := range ev.Tags.GetAll("p") {
4427 if v := tag.Value(); v != "" {
4428 pks = append(pks, v)
4429 }
4430 }
4431 prev := myFollows
4432 myFollows = pks
4433 followSet = buildSet(pks)
4434 broadcastFollows(pks)
4435 for _, pk := range pks {
4436 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
4437 }
4438 refreshProfileTab(pubhex)
4439 if feedSelectEl != 0 {
4440 populateFeedSelect()
4441 }
4442 if feedMode == "follows" {
4443 if feedDeferTimer != 0 {
4444 dom.ClearTimeout(feedDeferTimer)
4445 feedDeferTimer = 0
4446 }
4447 if !feedSubscribed {
4448 feedSubscribed = true
4449 logActivity("boot", "kind 3 received, subscribing feed", itoa(len(pks)) | " follows")
4450 subscribeFeed()
4451 subscribeNotifications()
4452 bootDone()
4453 } else if len(pks) != len(prev) {
4454 subscribeFeed()
4455 }
4456 }
4457 case 10000:
4458 if ev.CreatedAt <= muteTs {
4459 return
4460 }
4461 muteTs = ev.CreatedAt
4462 var pks []string
4463 for _, tag := range ev.Tags.GetAll("p") {
4464 if v := tag.Value(); v != "" {
4465 pks = append(pks, v)
4466 }
4467 }
4468 myMutes = pks
4469 muteSet = buildSet(pks)
4470 broadcastMutes(pks)
4471 refreshProfileTab(pubhex)
4472 case 10002:
4473 if ev.CreatedAt <= relayListTs {
4474 return
4475 }
4476 relayListTs = ev.CreatedAt
4477 // Profile Worker handles relay frequency tracking via P_EVENT.
4478 for _, tag := range ev.Tags.GetAll("r") {
4479 url := tag.Value()
4480 if url != "" {
4481 addRelay(url, true)
4482 }
4483 }
4484 sendWriteRelays()
4485 // Relay list changed - resubscribe to include new relays.
4486 // subscribeFeed has its own 2s throttle, so direct call is fine.
4487 if feedSubscribed {
4488 subscribeFeed()
4489 subscribeNotifications()
4490 }
4491 case 10050:
4492 if ev.CreatedAt <= inboxTs {
4493 return
4494 }
4495 inboxTs = ev.CreatedAt
4496 // DM inbox relay list - stored for future use.
4497 _ = ev.Tags.GetAll("relay")
4498 case 30078:
4499 dtag := ev.Tags.GetFirst("d")
4500 if dtag == nil || dtag.Value() != "smesh-settings" {
4501 return
4502 }
4503 if ev.CreatedAt <= settingsTs {
4504 return
4505 }
4506 settingsTs = ev.CreatedAt
4507 applyAppSettings(ev.Content)
4508 }
4509 }
4510
4511 func applyAppSettings(content string) {
4512 theme := helpers.JsonGetString(content, "theme")
4513 if theme == "light" && isDark {
4514 toggleTheme()
4515 } else if theme == "dark" && !isDark {
4516 toggleTheme()
4517 }
4518 blossom := helpers.JsonGetString(content, "blossom")
4519 if blossom != "" {
4520 blossomServerURL = blossom
4521 localstorage.SetItem(lsKeyBlossomServer, blossom)
4522 } else {
4523 blossomServerURL = ""
4524 localstorage.RemoveItem(lsKeyBlossomServer)
4525 }
4526 clientTag := helpers.JsonGetString(content, "clientTag")
4527 if clientTag == "0" {
4528 localstorage.SetItem("smesh-client-tag", "0")
4529 } else {
4530 localstorage.RemoveItem("smesh-client-tag")
4531 }
4532 lang := helpers.JsonGetString(content, "lang")
4533 if lang != "" && lang != currentLang {
4534 setLang(lang)
4535 }
4536 inv := helpers.JsonGetString(content, "invidious")
4537 invidiousURL = inv
4538 if inv != "" {
4539 localstorage.SetItem(lsKeyInvidiousURL, inv)
4540 } else {
4541 localstorage.RemoveItem(lsKeyInvidiousURL)
4542 }
4543 camp := helpers.JsonGetString(content, "campion")
4544 campionURL = camp
4545 if camp != "" {
4546 localstorage.SetItem(lsKeyCampionURL, camp)
4547 } else {
4548 localstorage.RemoveItem(lsKeyCampionURL)
4549 }
4550 // Notification read watermark - sync across devices.
4551 nrts := helpers.JsonGetValue(content, "notifReadTs")
4552 if nrts != "" {
4553 ts := parseI64(nrts)
4554 if ts > notifReadTs {
4555 notifReadTs = ts
4556 dom.IDBPut("settings", "notif-read-ts", i64toa(ts))
4557 }
4558 }
4559 // Relay blocklist - merge in entries from the settings event.
4560 blocklistRaw := helpers.JsonGetValue(content, "relayBlocklist")
4561 if blocklistRaw != "" {
4562 imported := parseJSONStringArray(blocklistRaw)
4563 for _, u := range imported {
4564 u = normalizeURL(u)
4565 if u != "" && !isBlocked(u) {
4566 relayBlocklist = append(relayBlocklist, u)
4567 }
4568 }
4569 if len(imported) > 0 {
4570 saveRelayPolicy()
4571 }
4572 }
4573 }
4574
4575 func saveAppSettings() {
4576 if pubhex == "" {
4577 return
4578 }
4579 theme := "dark"
4580 if !isDark {
4581 theme = "light"
4582 }
4583 clientTag := "1"
4584 if localstorage.GetItem("smesh-client-tag") == "0" {
4585 clientTag = "0"
4586 }
4587 blocklistJSON := "["
4588 for i, u := range relayBlocklist {
4589 if i > 0 {
4590 blocklistJSON = blocklistJSON | ","
4591 }
4592 blocklistJSON = blocklistJSON | jstr(u)
4593 }
4594 blocklistJSON = blocklistJSON | "]"
4595 content := "{" |
4596 "\"theme\":" | jstr(theme) | "," |
4597 "\"blossom\":" | jstr(blossomServerURL) | "," |
4598 "\"clientTag\":" | jstr(clientTag) | "," |
4599 "\"lang\":" | jstr(currentLang) | "," |
4600 "\"relayBlocklist\":" | blocklistJSON | "," |
4601 "\"invidious\":" | jstr(invidiousURL) | "," |
4602 "\"campion\":" | jstr(campionURL) | "," |
4603 "\"notifReadTs\":" | i64toa(notifReadTs) |
4604 "}"
4605 ts := dom.NowSeconds()
4606 tags := "[[\"d\",\"smesh-settings\"]]"
4607 unsigned := "{\"kind\":30078,\"content\":" | jstr(content) |
4608 ",\"tags\":" | tags |
4609 ",\"created_at\":" | i64toa(ts) |
4610 ",\"pubkey\":\"" | pubhex | "\"}"
4611 signer.SignEvent(unsigned, func(signed string) {
4612 if signed == "" {
4613 return
4614 }
4615 settingsTs = ts
4616 routeMsg("[\"EVENT\"," | signed | "]")
4617 })
4618 }
4619
4620 func updateStatus() {
4621 dom.SetTextContent(statusEl, t("relays"))
4622 dom.SetTextContent(popoverSummary,
4623 itoa(len(relayURLs)) | " relays | " | itoa(eventCount) | " events")
4624 }
4625
4626 // --- Feed rendering ---
4627
4628 func renderNote(ev *nostr.Event) {
4629 if noteElements[ev.ID] != 0 || seenEvents[ev.ID] {
4630 return
4631 }
4632
4633 repostPK := ""
4634 repostID := ""
4635 repostTs := int64(0)
4636 if ev.Kind == 6 {
4637 inner := nostr.ParseEvent(ev.Content)
4638 if inner == nil {
4639 return
4640 }
4641 repostPK = ev.PubKey
4642 repostID = ev.ID
4643 repostTs = ev.CreatedAt
4644 setEvent(inner.ID, inner)
4645 ev = inner
4646 }
4647
4648 note := dom.CreateElement("div")
4649 if repostID != "" {
4650 noteElements[repostID] = note
4651 } else {
4652 noteElements[ev.ID] = note
4653 }
4654 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
4655 dom.SetStyle(note, "padding", "12px 0")
4656 sortTs := ev.CreatedAt
4657 if repostTs > 0 {
4658 sortTs = repostTs
4659 }
4660 dom.SetProperty(note, "smeshTs", i64toa(sortTs))
4661
4662 if repostPK != "" {
4663 rpBanner := dom.CreateElement("div")
4664 dom.SetStyle(rpBanner, "display", "flex")
4665 dom.SetStyle(rpBanner, "alignItems", "center")
4666 dom.SetStyle(rpBanner, "gap", "6px")
4667 dom.SetStyle(rpBanner, "fontSize", "12px")
4668 dom.SetStyle(rpBanner, "color", "var(--muted)")
4669 dom.SetStyle(rpBanner, "marginBottom", "6px")
4670 dom.SetStyle(rpBanner, "cursor", "pointer")
4671 dom.SetInnerHTML(rpBanner, svgRepost)
4672 rpName := dom.CreateElement("span")
4673 if name, ok := authorNames[repostPK]; ok && name != "" {
4674 dom.SetTextContent(rpName, name | " " | t("reposted"))
4675 } else {
4676 npub := helpers.EncodeNpub(helpers.HexDecode(repostPK))
4677 short := npub
4678 if len(npub) > 20 {
4679 short = npub[:12] | "..." | npub[len(npub)-4:]
4680 }
4681 dom.SetTextContent(rpName, short | " " | t("reposted"))
4682 }
4683 dom.AppendChild(rpBanner, rpName)
4684 bannerPK := repostPK
4685 dom.AddEventListener(rpBanner, "click", dom.RegisterCallback(func() {
4686 showProfile(bannerPK)
4687 }))
4688 dom.AppendChild(note, rpBanner)
4689 }
4690
4691 // Header row: author link (left) + timestamp (right).
4692 header := dom.CreateElement("div")
4693 dom.SetStyle(header, "display", "flex")
4694 dom.SetStyle(header, "alignItems", "center")
4695 dom.SetStyle(header, "marginBottom", "4px")
4696 dom.SetStyle(header, "maxWidth", "65ch")
4697
4698 // Author link - only covers avatar + name.
4699 authorLink := dom.CreateElement("div")
4700 dom.SetStyle(authorLink, "display", "flex")
4701 dom.SetStyle(authorLink, "alignItems", "center")
4702 dom.SetStyle(authorLink, "gap", "8px")
4703 dom.SetStyle(authorLink, "cursor", "pointer")
4704 headerPK := ev.PubKey
4705 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
4706 showProfile(headerPK)
4707 }))
4708
4709 avatar := dom.CreateElement("img")
4710 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
4711 dom.SetAttribute(avatar, "width", "24")
4712 dom.SetAttribute(avatar, "height", "24")
4713 dom.SetStyle(avatar, "borderRadius", "50%")
4714 dom.SetStyle(avatar, "objectFit", "cover")
4715 dom.SetStyle(avatar, "flexShrink", "0")
4716
4717 nameSpan := dom.CreateElement("span")
4718 dom.SetStyle(nameSpan, "fontSize", "18px")
4719 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4720 dom.SetStyle(nameSpan, "fontWeight", "bold")
4721 dom.SetStyle(nameSpan, "color", "var(--fg)")
4722
4723 pk := ev.PubKey
4724 if pic, ok := authorPics[pk]; ok && pic != "" {
4725 setMediaSrc(avatar, pic)
4726 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
4727 } else {
4728 dom.SetStyle(avatar, "display", "none")
4729 }
4730 if name, ok := authorNames[pk]; ok && name != "" {
4731 dom.SetTextContent(nameSpan, name)
4732 } else {
4733 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
4734 if len(npub) > 20 {
4735 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
4736 }
4737 }
4738
4739 dom.AppendChild(authorLink, avatar)
4740 dom.AppendChild(authorLink, nameSpan)
4741 dom.AppendChild(header, authorLink)
4742
4743 appendClientTag(header, ev)
4744
4745 // Right side: delete + relay widget + timestamp - right-aligned.
4746 rightGroup := dom.CreateElement("div")
4747 dom.SetStyle(rightGroup, "display", "flex")
4748 dom.SetStyle(rightGroup, "alignItems", "center")
4749 dom.SetStyle(rightGroup, "gap", "4px")
4750 dom.SetStyle(rightGroup, "marginLeft", "auto")
4751
4752 // Delete button - own notes only.
4753 if pk == pubhex {
4754 delBtn := dom.CreateElement("span")
4755 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>`)
4756 dom.SetStyle(delBtn, "cursor", "pointer")
4757 dom.SetStyle(delBtn, "color", "var(--muted)")
4758 dom.SetStyle(delBtn, "opacity", "0.5")
4759 dom.SetStyle(delBtn, "display", "flex")
4760 dom.SetStyle(delBtn, "alignItems", "center")
4761 delEvID := ev.ID
4762 dom.AddEventListener(delBtn, "click", dom.RegisterCallback(func() {
4763 publishDelete(delEvID)
4764 }))
4765 dom.AppendChild(rightGroup, delBtn)
4766 }
4767
4768 rw := relayWidget(ev.ID)
4769 dom.AppendChild(rightGroup, rw)
4770 if ev.CreatedAt > 0 {
4771 tsEl := dom.CreateElement("span")
4772 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
4773 dom.SetStyle(tsEl, "fontSize", "11px")
4774 dom.SetStyle(tsEl, "color", "var(--muted)")
4775 dom.SetStyle(tsEl, "cursor", "pointer")
4776 dom.SetStyle(tsEl, "flexShrink", "0")
4777 evID := ev.ID
4778 evRootID := getRootID(ev)
4779 if evRootID == "" {
4780 evRootID = evID
4781 }
4782 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
4783 showNoteThread(evRootID, evID)
4784 }))
4785 dom.AppendChild(rightGroup, tsEl)
4786 }
4787
4788 dom.AppendChild(header, rightGroup)
4789 dom.AppendChild(note, header)
4790
4791 // Track author link for update when profile arrives.
4792 if _, cached := authorNames[pk]; !cached {
4793 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
4794 if !lookupFetchedK0(pk) {
4795 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
4796 }
4797 }
4798
4799 // Reply preview button.
4800 addReplyPreview(note, ev)
4801
4802 // Content.
4803 content := dom.CreateElement("div")
4804 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4805 dom.SetStyle(content, "fontSize", "14px")
4806 dom.SetStyle(content, "lineHeight", "1.5")
4807 dom.SetStyle(content, "wordBreak", "break-word")
4808 dom.SetStyle(content, "maxWidth", "65ch")
4809 dom.SetStyle(content, "overflow", "hidden")
4810 dom.SetStyle(content, "maxHeight", "18em")
4811 setHTMLWithMedia(content, renderMarkdown(ev.Content))
4812 dom.AppendChild(note, content)
4813
4814 more := makeShowMore(content)
4815 dom.AppendChild(note, buildActionRow(ev, note, content, more))
4816
4817 // Insert in timestamp order (newest first).
4818 insertNoteByTs(note, sortTs)
4819 attachShowMore(content, more)
4820 resolveEmbeds()
4821 }
4822
4823 func insertNoteByTs(note dom.Element, ts int64) {
4824 // Walk children to find insertion point - notes are newest-first.
4825 child := dom.FirstElementChild(feedContainer)
4826 for child != 0 {
4827 childTs := parseI64(dom.GetProperty(child, "smeshTs"))
4828 if ts > childTs {
4829 dom.InsertBefore(feedContainer, note, child)
4830 return
4831 }
4832 child = dom.NextSibling(child)
4833 }
4834 dom.AppendChild(feedContainer, note)
4835 }
4836
4837 func parseI64(s string) (n int64) {
4838 for i := 0; i < len(s); i++ {
4839 if s[i] >= '0' && s[i] <= '9' {
4840 n = n*10 + int64(s[i]-'0')
4841 } else {
4842 break
4843 }
4844 }
4845 return n
4846 }
4847
4848 func appendNote(ev *nostr.Event) {
4849 if noteElements[ev.ID] != 0 {
4850 return
4851 }
4852 note, postInsert := buildNoteElement(ev)
4853 dom.AppendChild(feedContainer, note)
4854 postInsert()
4855 resolveEmbeds()
4856 }
4857
4858 func buildNoteElement(ev *nostr.Event) (dom.Element, func()) {
4859 note := dom.CreateElement("div")
4860 noteElements[ev.ID] = note
4861 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
4862 dom.SetStyle(note, "padding", "12px 0")
4863 dom.SetProperty(note, "smeshTs", i64toa(ev.CreatedAt))
4864 dom.SetProperty(note, "smeshPK", ev.PubKey)
4865
4866 header := dom.CreateElement("div")
4867 dom.SetStyle(header, "display", "flex")
4868 dom.SetStyle(header, "alignItems", "center")
4869 dom.SetStyle(header, "marginBottom", "4px")
4870 dom.SetStyle(header, "maxWidth", "65ch")
4871
4872 authorLink := dom.CreateElement("div")
4873 dom.SetStyle(authorLink, "display", "flex")
4874 dom.SetStyle(authorLink, "alignItems", "center")
4875 dom.SetStyle(authorLink, "gap", "8px")
4876 dom.SetStyle(authorLink, "cursor", "pointer")
4877 headerPK := ev.PubKey
4878 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
4879 showProfile(headerPK)
4880 }))
4881
4882 avatar := dom.CreateElement("img")
4883 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
4884 dom.SetAttribute(avatar, "width", "24")
4885 dom.SetAttribute(avatar, "height", "24")
4886 dom.SetStyle(avatar, "borderRadius", "50%")
4887 dom.SetStyle(avatar, "objectFit", "cover")
4888 dom.SetStyle(avatar, "flexShrink", "0")
4889
4890 nameSpan := dom.CreateElement("span")
4891 dom.SetStyle(nameSpan, "fontSize", "18px")
4892 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4893 dom.SetStyle(nameSpan, "fontWeight", "bold")
4894 dom.SetStyle(nameSpan, "color", "var(--fg)")
4895
4896 pk := ev.PubKey
4897 if pic, ok := authorPics[pk]; ok && pic != "" {
4898 setMediaSrc(avatar, pic)
4899 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
4900 } else {
4901 dom.SetStyle(avatar, "display", "none")
4902 }
4903 if name, ok := authorNames[pk]; ok && name != "" {
4904 dom.SetTextContent(nameSpan, name)
4905 } else {
4906 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
4907 if len(npub) > 20 {
4908 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
4909 }
4910 }
4911
4912 dom.AppendChild(authorLink, avatar)
4913 dom.AppendChild(authorLink, nameSpan)
4914 attachHoverCard(authorLink, pk)
4915 dom.AppendChild(header, authorLink)
4916
4917 appendClientTag(header, ev)
4918
4919 // JSON toggle: contentEl and moreEl are set below after those elements are
4920 // created. The click handler fires long after this function returns, so the
4921 // closure sees the final values.
4922 var contentEl dom.Element
4923 var moreEl dom.Element
4924 jsonActive := false
4925 var jsonEl dom.Element
4926
4927 // Copy button - to the left of </>; marginLeft:auto pushes both to the right.
4928 cpBtn := noteCopyBtn()
4929 capturedCpEv := ev
4930 dom.AddEventListener(cpBtn, "click", dom.RegisterCallback(func() {
4931 capturedCpBtn := cpBtn
4932 text := noteClipText(capturedCpEv)
4933 dom.WritePrimarySelection(text)
4934 dom.WriteClipboard(text, func(ok bool) {
4935 if ok {
4936 noteCopyFeedback(capturedCpBtn)
4937 }
4938 })
4939 }))
4940 dom.SetStyle(cpBtn, "marginLeft", "auto")
4941 dom.AppendChild(header, cpBtn)
4942
4943 jsonBtn := dom.CreateElement("span")
4944 dom.SetTextContent(jsonBtn, "</>")
4945 dom.SetStyle(jsonBtn, "fontSize", "10px")
4946 dom.SetStyle(jsonBtn, "fontFamily", "'Fira Code', monospace")
4947 dom.SetStyle(jsonBtn, "cursor", "pointer")
4948 dom.SetStyle(jsonBtn, "opacity", "0.6")
4949 dom.SetStyle(jsonBtn, "marginRight", "8px")
4950 dom.SetStyle(jsonBtn, "userSelect", "none")
4951 dom.SetStyle(jsonBtn, "color", "var(--muted)")
4952 capturedJBtn := jsonBtn
4953 capturedEv := ev
4954 capturedNote := note
4955 dom.AddEventListener(jsonBtn, "click", dom.RegisterCallback(func() {
4956 if jsonActive {
4957 dom.SetStyle(contentEl, "display", "")
4958 dom.SetStyle(moreEl, "display", "none")
4959 dom.SetStyle(jsonEl, "display", "none")
4960 dom.SetStyle(capturedJBtn, "opacity", "0.6")
4961 dom.SetStyle(capturedJBtn, "color", "var(--muted)")
4962 jsonActive = false
4963 } else {
4964 if jsonEl == 0 {
4965 jsonEl = dom.CreateElement("pre")
4966 dom.SetStyle(jsonEl, "fontFamily", "'Fira Code', monospace")
4967 dom.SetStyle(jsonEl, "fontSize", "12px")
4968 dom.SetStyle(jsonEl, "lineHeight", "1.5")
4969 dom.SetStyle(jsonEl, "wordBreak", "break-word")
4970 dom.SetStyle(jsonEl, "whiteSpace", "pre-wrap")
4971 dom.SetStyle(jsonEl, "maxWidth", "65ch")
4972 dom.SetStyle(jsonEl, "margin", "0")
4973 dom.SetStyle(jsonEl, "color", "var(--fg)")
4974 dom.SetTextContent(jsonEl, prettyNoteJSON(capturedEv))
4975 dom.InsertBefore(capturedNote, jsonEl, moreEl)
4976 }
4977 dom.SetStyle(contentEl, "display", "none")
4978 dom.SetStyle(moreEl, "display", "none")
4979 dom.SetStyle(jsonEl, "display", "block")
4980 dom.SetStyle(capturedJBtn, "opacity", "1")
4981 dom.SetStyle(capturedJBtn, "color", "var(--accent)")
4982 jsonActive = true
4983 }
4984 }))
4985 dom.AppendChild(header, jsonBtn)
4986
4987 rw2 := relayWidget(ev.ID)
4988 dom.AppendChild(header, rw2)
4989 if ev.CreatedAt > 0 {
4990 tsEl := dom.CreateElement("span")
4991 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
4992 dom.SetStyle(tsEl, "fontSize", "11px")
4993 dom.SetStyle(tsEl, "color", "var(--muted)")
4994 dom.SetStyle(tsEl, "cursor", "pointer")
4995 dom.SetStyle(tsEl, "flexShrink", "0")
4996 evID := ev.ID
4997 evRootID := getRootID(ev)
4998 if evRootID == "" {
4999 evRootID = evID
5000 }
5001 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
5002 showNoteThread(evRootID, evID)
5003 }))
5004 dom.AppendChild(header, tsEl)
5005 }
5006
5007 dom.AppendChild(note, header)
5008
5009 if _, cached := authorNames[pk]; !cached {
5010 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
5011 if !lookupFetchedK0(pk) {
5012 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
5013 }
5014 }
5015
5016 addReplyPreview(note, ev)
5017
5018 content := dom.CreateElement("div")
5019 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
5020 dom.SetStyle(content, "fontSize", "14px")
5021 dom.SetStyle(content, "lineHeight", "1.5")
5022 dom.SetStyle(content, "wordBreak", "break-word")
5023 dom.SetStyle(content, "maxWidth", "65ch")
5024 dom.SetStyle(content, "overflow", "hidden")
5025 dom.SetStyle(content, "maxHeight", "18em")
5026 setHTMLWithMedia(content, renderMarkdown(ev.Content))
5027 dom.AppendChild(note, content)
5028 contentEl = content
5029
5030 more := makeShowMore(content)
5031 moreEl = more
5032 dom.AppendChild(note, buildActionRow(ev, note, content, more))
5033
5034 return note, func() { attachShowMore(content, more) }
5035 }
5036
5037 var profileSubCounter int32
5038
5039 // topRelays returns the n most frequently seen relay URLs from kind 10002 events.
5040 // topRelays moved to Profile Worker. Shell no longer has relay frequency data.
5041 // Returns nil so callers fall through to relayURLs + discoveryRelays only.
5042 func topRelays(_ int32) (ss []string) { return nil }
5043
5044 // recordRelayFreq moved to Profile Worker. Shell no longer tracks relay frequency.
5045
5046 // discoveryRelays are well-known relays that aggregate profile metadata.
5047 // Prioritized first in _proxy lists since they have the highest hit rate.
5048 var discoveryRelays = []string{
5049 "wss://purplepag.es",
5050 "wss://relay.damus.io",
5051 "wss://nos.lol",
5052 }
5053
5054 // buildProxy builds a _proxy relay list for a pubkey.
5055 // Discovery relays first, then author-specific relays if known.
5056 // buildProxy builds a relay list for querying a specific author.
5057 // authorRelays and relayFreq now live in Profile Worker; Shell uses
5058 // profileRelaysCache for the relay hints it has received via P_RELAYS.
5059 func buildProxy(pk string) (ss []string) {
5060 out := []string{:len(discoveryRelays)}
5061 copy(out, discoveryRelays)
5062 for _, u := range readRelayURLs() {
5063 out = appendUnique(out, u)
5064 }
5065 if rels, ok := profileRelaysCache[pk]; ok {
5066 for _, r := range rels {
5067 out = appendUnique(out, r)
5068 }
5069 }
5070 return out
5071 }
5072
5073 func appendUnique(list []string, val string) (ss []string) {
5074 if isBlocked(val) {
5075 return list
5076 }
5077 for _, v := range list {
5078 if v == val {
5079 return list
5080 }
5081 }
5082 return append(list, val)
5083 }
5084
5085 // --- Thread view & reply preview ---
5086
5087 // getReplyID returns the event ID this note is replying to, or "".
5088 // Checks for NIP-10 "reply" marker first, falls back to positional (last e-tag).
5089 func getReplyID(ev *nostr.Event) (s string) {
5090 id, _ := getReplyIDHint(ev)
5091 return id
5092 }
5093
5094 func getReplyIDHint(ev *nostr.Event) (string, string) {
5095 var etags []nostr.Tag
5096 for _, t := range ev.Tags {
5097 if len(t) >= 2 && t[0] == "e" {
5098 etags = append(etags, t)
5099 }
5100 }
5101 if len(etags) == 0 {
5102 return "", ""
5103 }
5104 // Marker-based: look for "reply".
5105 for _, t := range etags {
5106 if len(t) >= 4 && t[3] == "reply" {
5107 hint := ""
5108 if len(t) >= 3 && len(t[2]) > 0 {
5109 hint = t[2]
5110 }
5111 return t[1], hint
5112 }
5113 }
5114 // Positional fallback: last e-tag is the reply target (if >1 e-tags).
5115 if len(etags) > 1 {
5116 last := etags[len(etags)-1]
5117 hint := ""
5118 if len(last) >= 3 && len(last[2]) > 0 {
5119 hint = last[2]
5120 }
5121 return last[1], hint
5122 }
5123 // Single e-tag with no marker: it's both root and reply.
5124 hint := ""
5125 if len(etags[0]) >= 3 && len(etags[0][2]) > 0 {
5126 hint = etags[0][2]
5127 }
5128 return etags[0][1], hint
5129 }
5130
5131 // getRootID returns the thread root event ID, or "".
5132 func getRootID(ev *nostr.Event) (s string) {
5133 for _, t := range ev.Tags {
5134 if len(t) >= 4 && t[0] == "e" && t[3] == "root" {
5135 return t[1]
5136 }
5137 }
5138 // Positional: first e-tag.
5139 for _, t := range ev.Tags {
5140 if len(t) >= 2 && t[0] == "e" {
5141 return t[1]
5142 }
5143 }
5144 return ""
5145 }
5146
5147 // referencesEvent returns true if ev has any e-tag pointing to the given ID.
5148 func referencesEvent(ev *nostr.Event, id string) (ok bool) {
5149 for _, t := range ev.Tags {
5150 if len(t) >= 2 && t[0] == "e" && t[1] == id {
5151 return true
5152 }
5153 }
5154 return false
5155 }
5156
5157 func firstLine(s string) (sv string) {
5158 for i := 0; i < len(s); i++ {
5159 if s[i] == '\n' {
5160 if i > 80 {
5161 return s[:80] | "..."
5162 }
5163 return s[:i]
5164 }
5165 }
5166 if len(s) > 80 {
5167 return s[:80] | "..."
5168 }
5169 return s
5170 }
5171
5172 // appendClientTag adds "via <client>" to a note header if the event has a
5173 // "client" tag. Linkifies http(s) URLs and strips the scheme for display.
5174 func appendClientTag(header dom.Element, ev *nostr.Event) {
5175 clientTag := ev.Tags.GetFirst("client")
5176 if clientTag == nil {
5177 return
5178 }
5179 val := clientTag.Value()
5180 if val == "" {
5181 return
5182 }
5183
5184 wrap := dom.CreateElement("span")
5185 dom.SetStyle(wrap, "fontStyle", "italic")
5186 dom.SetStyle(wrap, "fontSize", "11px")
5187 dom.SetStyle(wrap, "color", "var(--muted)")
5188 dom.SetStyle(wrap, "marginLeft", "6px")
5189 dom.SetStyle(wrap, "flexShrink", "0")
5190
5191 via := dom.CreateElement("span")
5192 dom.SetTextContent(via, "via ")
5193 dom.AppendChild(wrap, via)
5194
5195 display := val
5196 isURL := false
5197 if len(val) >= 8 && val[:8] == "https://" {
5198 isURL = true
5199 display = val[8:]
5200 } else if len(val) >= 7 && val[:7] == "http://" {
5201 isURL = true
5202 display = val[7:]
5203 }
5204
5205 if isURL {
5206 link := dom.CreateElement("a")
5207 dom.SetAttribute(link, "href", val)
5208 dom.SetAttribute(link, "target", "_blank")
5209 dom.SetAttribute(link, "rel", "noopener noreferrer")
5210 dom.SetTextContent(link, display)
5211 dom.SetStyle(link, "color", "var(--muted)")
5212 dom.SetStyle(link, "textDecoration", "underline")
5213 dom.AppendChild(wrap, link)
5214 } else {
5215 text := dom.CreateElement("span")
5216 dom.SetTextContent(text, display)
5217 dom.AppendChild(wrap, text)
5218 }
5219
5220 dom.AppendChild(header, wrap)
5221 }
5222
5223 func addReplyPreview(note dom.Element, ev *nostr.Event) {
5224 parentID, hint := getReplyIDHint(ev)
5225 if parentID == "" {
5226 return
5227 }
5228 if hint != "" && replyHints != nil {
5229 replyHints[parentID] = hint
5230 }
5231
5232 preview := dom.CreateElement("div")
5233 dom.SetStyle(preview, "display", "flex")
5234 dom.SetStyle(preview, "alignItems", "center")
5235 dom.SetStyle(preview, "gap", "4px")
5236 dom.SetStyle(preview, "fontSize", "14px")
5237 dom.SetStyle(preview, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
5238 dom.SetStyle(preview, "lineHeight", "1.5")
5239 dom.SetStyle(preview, "color", "var(--muted)")
5240 dom.SetStyle(preview, "borderLeft", "2px solid var(--accent)")
5241 dom.SetStyle(preview, "paddingLeft", "8px")
5242 dom.SetStyle(preview, "marginBottom", "4px")
5243 dom.SetStyle(preview, "cursor", "pointer")
5244 dom.SetStyle(preview, "overflow", "hidden")
5245 dom.SetStyle(preview, "maxWidth", "65ch")
5246
5247 prevAvatar := dom.CreateElement("img")
5248 dom.SetAttribute(prevAvatar, "referrerpolicy", "no-referrer")
5249 dom.SetAttribute(prevAvatar, "width", "14")
5250 dom.SetAttribute(prevAvatar, "height", "14")
5251 dom.SetStyle(prevAvatar, "borderRadius", "50%")
5252 dom.SetStyle(prevAvatar, "objectFit", "cover")
5253 dom.SetStyle(prevAvatar, "flexShrink", "0")
5254 dom.SetStyle(prevAvatar, "display", "none")
5255 dom.AppendChild(preview, prevAvatar)
5256
5257 prevText := dom.CreateElement("span")
5258 dom.SetStyle(prevText, "overflow", "hidden")
5259 dom.SetStyle(prevText, "whiteSpace", "nowrap")
5260 dom.SetStyle(prevText, "textOverflow", "ellipsis")
5261 dom.AppendChild(preview, prevText)
5262
5263 text, cached := replyCache[parentID]
5264 if !cached {
5265 text, cached = replyCacheOld[parentID]
5266 }
5267 if cached {
5268 dom.SetTextContent(prevText, text)
5269 pic, _ := replyAvatarCache[parentID]
5270 if pic == "" {
5271 pic, _ = replyAvatarCacheOld[parentID]
5272 }
5273 if pic != "" {
5274 setMediaSrc(prevAvatar, pic)
5275 dom.SetAttribute(prevAvatar, "onerror", "this.style.display='none'")
5276 dom.SetStyle(prevAvatar, "display", "block")
5277 }
5278 } else {
5279 dom.SetTextContent(prevText, t("replying_to"))
5280 replyPending[parentID] = append(replyPending[parentID], preview)
5281 queueReplyFetch(parentID)
5282 }
5283
5284 rootID := getRootID(ev)
5285 if rootID == "" {
5286 rootID = parentID
5287 }
5288 focusID := parentID
5289 dom.AddEventListener(preview, "click", dom.RegisterCallback(func() {
5290 showNoteThread(rootID, focusID)
5291 }))
5292
5293 dom.AppendChild(note, preview)
5294 }
5295
5296 func queueReplyFetch(id string) {
5297 replyQueue = append(replyQueue, id)
5298 if replyTimer != 0 {
5299 dom.ClearTimeout(replyTimer)
5300 }
5301 replyTimer = dom.SetTimeout(func() {
5302 replyTimer = 0
5303 flushReplyQueue()
5304 }, 300)
5305 }
5306
5307 func flushReplyQueue() {
5308 if len(replyQueue) == 0 {
5309 return
5310 }
5311 filter := "{\"ids\":["
5312 var hints []string
5313 for i, id := range replyQueue {
5314 if i > 0 {
5315 filter = filter | ","
5316 }
5317 filter = filter | jstr(id)
5318 if h := replyHints[id]; h != "" {
5319 hints = append(hints, h)
5320 delete(replyHints, id)
5321 } else if h := replyHintsOld[id]; h != "" {
5322 hints = append(hints, h)
5323 delete(replyHintsOld, id)
5324 }
5325 }
5326 filter = filter | "]}"
5327 replyQueue = nil
5328
5329 // Local REQ checks IDB cache first.
5330 threadSubCount++
5331 reqID := "rp-" | itoa(threadSubCount)
5332 routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
5333
5334 // PROXY fetches from remote relays - use hints + discovery + feed + top relays.
5335 var urls []string
5336 seen := map[string]bool{}
5337 for _, h := range hints {
5338 if !seen[h] {
5339 seen[h] = true
5340 urls = append(urls, h)
5341 }
5342 }
5343 for _, u := range discoveryRelays {
5344 if !seen[u] {
5345 seen[u] = true
5346 urls = append(urls, u)
5347 }
5348 }
5349 for _, u := range relayURLs {
5350 if !seen[u] {
5351 seen[u] = true
5352 urls = append(urls, u)
5353 }
5354 }
5355 for _, u := range topRelays(8) {
5356 if !seen[u] {
5357 seen[u] = true
5358 urls = append(urls, u)
5359 }
5360 }
5361 threadSubCount++
5362 proxyID := "rp-" | itoa(threadSubCount)
5363 routeMsg(buildProxyMsg(proxyID, filter, urls))
5364
5365 // Reset sweep timer: 20s after last batch sent, mark remaining as not found.
5366 if replySweepTimer != 0 {
5367 dom.ClearTimeout(replySweepTimer)
5368 }
5369 replySweepTimer = dom.SetTimeout(func() {
5370 replySweepTimer = 0
5371 sweepUnfoundReplies()
5372 }, 20000)
5373 }
5374
5375 func handleReplyPreviewEvent(ev *nostr.Event) {
5376 line := firstLine(ev.Content)
5377 replyLineCache[ev.ID] = line
5378 replyAuthorMap[ev.ID] = ev.PubKey
5379
5380 name := ev.PubKey[:8] | "..."
5381 nameResolved := false
5382 if n, ok := authorNames[ev.PubKey]; ok && n != "" {
5383 name = n
5384 nameResolved = true
5385 }
5386 text := name | ": " | line
5387 replyCache[ev.ID] = text
5388 replyCount++
5389 if replyCount > rotateReplyMax {
5390 rotateReplyCaches()
5391 }
5392
5393 pic := ""
5394 if p, ok := authorPics[ev.PubKey]; ok && p != "" {
5395 pic = p
5396 }
5397 replyAvatarCache[ev.ID] = pic
5398
5399 // Trigger profile fetch if not cached.
5400 if !nameResolved {
5401 if !lookupFetchedK0(ev.PubKey) {
5402 profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
5403 }
5404 }
5405
5406 if divs, ok := replyPending[ev.ID]; ok {
5407 for _, d := range divs {
5408 fillReplyPreviewDiv(d, text, pic)
5409 }
5410 // Track for late name update if unresolved.
5411 if !nameResolved {
5412 replyNeedName[ev.ID] = replyNeedName[ev.ID] | divs
5413 }
5414 delete(replyPending, ev.ID)
5415 }
5416 }
5417
5418 func fillReplyPreviewDiv(d dom.Element, text, pic string) {
5419 img := dom.FirstChild(d)
5420 if img == 0 {
5421 return
5422 }
5423 span := dom.NextSibling(img)
5424 if span != 0 {
5425 dom.SetTextContent(span, text)
5426 }
5427 if pic != "" {
5428 setMediaSrc(img, pic)
5429 dom.SetAttribute(img, "onerror", "this.style.display='none'")
5430 dom.SetStyle(img, "display", "block")
5431 }
5432 }
5433
5434 func sweepUnfoundReplies() {
5435 if len(replyPending) == 0 {
5436 return
5437 }
5438 notFound := t("reply_not_found")
5439 for id, divs := range replyPending {
5440 for _, d := range divs {
5441 span := dom.NextSibling(dom.FirstChild(d))
5442 if span != 0 {
5443 dom.SetTextContent(span, notFound)
5444 }
5445 }
5446 delete(replyPending, id)
5447 }
5448 }
5449
5450 // updateReplyPreviewsForAuthor is called when a kind 0 profile arrives.
5451 // It updates any reply preview divs that were rendered with a hex pubkey stub.
5452 func updateReplyPreviewsForAuthor(pk string) {
5453 name, _ := authorNames[pk]
5454 pic, _ := authorPics[pk]
5455 if name == "" {
5456 return
5457 }
5458
5459 for eid, apk := range replyAuthorMap {
5460 if apk != pk {
5461 continue
5462 }
5463 line := replyLineCache[eid]
5464 text := name | ": " | line
5465 replyCache[eid] = text
5466 replyAvatarCache[eid] = pic
5467
5468 if divs, ok := replyNeedName[eid]; ok {
5469 for _, d := range divs {
5470 fillReplyPreviewDiv(d, text, pic)
5471 }
5472 delete(replyNeedName, eid)
5473 }
5474 }
5475 }
5476
5477 func showNoteThread(rootID, focusID string) {
5478 // Close old thread subs immediately to prevent stale events leaking in.
5479 for _, sid := range threadActiveSubs {
5480 routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
5481 }
5482 threadActiveSubs = nil
5483 threadGen++
5484
5485 threadRootID = rootID
5486 threadFocusID = focusID
5487 threadEvents = map[string]*nostr.Event{}
5488 threadLastRendered = 0
5489 threadOpen = true
5490 threadFollowUp = 0
5491 threadFetchedIDs = map[string]bool{rootID: true}
5492 threadReturnPage = activePage
5493
5494 // Save scroll position.
5495 savedScrollTop = dom.GetProperty(contentArea, "scrollTop")
5496
5497 // Deactivate search bar so top bar returns to normal while in thread.
5498 if searchBarActive {
5499 deactivateSearchBar(searchBtnGlobal)
5500 }
5501 // Stop any inline media (Invidious iframes) playing in the feed.
5502 stopInlineMedia(feedContainer)
5503
5504 // Ensure feedPage is visible - threadPage lives inside it.
5505 if activePage != "feed" {
5506 dom.SetStyle(profilePage, "display", "none")
5507 dom.SetStyle(msgPage, "display", "none")
5508 dom.SetStyle(settingsPage, "display", "none")
5509 dom.SetStyle(notifPage, "display", "none")
5510 dom.SetStyle(aboutPage, "display", "none")
5511 dom.SetStyle(composePage, "display", "none")
5512 dom.SetStyle(searchPage, "display", "none")
5513 dom.SetStyle(feedPage, "display", "block")
5514 }
5515
5516 // Defocus all sidebar buttons - thread view is its own context.
5517 dom.SetStyle(sidebarFeed, "background", "transparent")
5518 dom.SetStyle(sidebarFeed, "color", "var(--fg)")
5519 dom.SetStyle(sidebarCompose, "background", "transparent")
5520 dom.SetStyle(sidebarCompose, "color", "var(--fg)")
5521 dom.SetStyle(sidebarMsg, "background", "transparent")
5522 dom.SetStyle(sidebarMsg, "color", "var(--fg)")
5523 dom.SetStyle(sidebarNotif, "background", "transparent")
5524 dom.SetStyle(sidebarNotif, "color", "var(--fg)")
5525 dom.SetStyle(sidebarSettings, "background", "transparent")
5526 dom.SetStyle(sidebarSettings, "color", "var(--fg)")
5527
5528 // Switch UI.
5529 dom.SetStyle(feedContainer, "display", "none")
5530 dom.SetStyle(loadMoreBtn, "display", "none")
5531 dom.SetStyle(threadPage, "display", "block")
5532 dom.SetProperty(contentArea, "scrollTop", "0")
5533
5534 // Show back button in top bar, hide everything else.
5535 closeFeedPopover()
5536 dom.SetStyle(topBackBtn, "display", "inline")
5537 dom.SetStyle(pageTitleEl, "display", "none")
5538 dom.SetStyle(feedSelectEl, "display", "none")
5539 dom.SetStyle(notifBarGroup, "display", "none")
5540
5541 if !navPop {
5542 url := "/t/" | rootID
5543 if focusID != "" && focusID != rootID {
5544 url = url | "#" | focusID
5545 }
5546 dom.PushState(url)
5547 threadPushedState = true
5548 } else {
5549 threadPushedState = false
5550 }
5551
5552 clearChildren(threadContainer)
5553
5554 // Loading indicator.
5555 loading := dom.CreateElement("div")
5556 dom.SetTextContent(loading, t("loading_thread"))
5557 dom.SetStyle(loading, "color", "var(--muted)")
5558 dom.SetStyle(loading, "fontSize", "14px")
5559 dom.SetStyle(loading, "padding", "16px 0")
5560 dom.AppendChild(threadContainer, loading)
5561
5562 // Build wide relay set for thread fetch.
5563 var thrRelays []string
5564 thrSeen := map[string]bool{}
5565 for _, u := range relayURLs {
5566 if !thrSeen[u] {
5567 thrSeen[u] = true
5568 thrRelays = append(thrRelays, u)
5569 }
5570 }
5571 for _, u := range topRelays(8) {
5572 if !thrSeen[u] {
5573 thrSeen[u] = true
5574 thrRelays = append(thrRelays, u)
5575 }
5576 }
5577
5578 threadRelays = thrRelays
5579
5580 // Local REQ for cached events.
5581 threadSubCount++
5582 s1 := "thr-" | itoa(threadSubCount)
5583 threadActiveSubs = append(threadActiveSubs, s1)
5584 routeMsg("[\"REQ\"," | jstr(s1) | ",{\"ids\":[" | jstr(rootID) | "]}]")
5585
5586 threadSubCount++
5587 s2 := "thr-" | itoa(threadSubCount)
5588 threadActiveSubs = append(threadActiveSubs, s2)
5589 routeMsg("[\"REQ\"," | jstr(s2) | ",{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}]")
5590
5591 // PROXY for remote relays.
5592 threadSubCount++
5593 s3 := "thr-" | itoa(threadSubCount)
5594 threadActiveSubs = append(threadActiveSubs, s3)
5595 routeMsg(buildProxyMsg(s3, "{\"ids\":[" | jstr(rootID) | "]}", thrRelays))
5596
5597 threadSubCount++
5598 s4 := "thr-" | itoa(threadSubCount)
5599 threadActiveSubs = append(threadActiveSubs, s4)
5600 routeMsg(buildProxyMsg(s4, "{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}", thrRelays))
5601
5602 // Also fetch the focus event directly if different from root.
5603 if focusID != rootID {
5604 threadSubCount++
5605 s5 := "thr-" | itoa(threadSubCount)
5606 threadActiveSubs = append(threadActiveSubs, s5)
5607 routeMsg(buildProxyMsg(s5, "{\"ids\":[" | jstr(focusID) | "]}", thrRelays))
5608 }
5609 }
5610
5611 func closeNoteThread() {
5612 // Close active subs.
5613 for _, sid := range threadActiveSubs {
5614 routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
5615 }
5616 threadActiveSubs = nil
5617
5618 threadOpen = false
5619 threadRootID = ""
5620 threadFocusID = ""
5621 threadRelays = nil
5622 threadFetchedIDs = nil
5623 stopInlineMedia(threadContainer)
5624 dom.SetStyle(threadPage, "display", "none")
5625
5626 // Always restore feedContainer - showNoteThread hides it.
5627 dom.SetStyle(feedContainer, "display", "block")
5628 dom.SetStyle(topBackBtn, "display", "none")
5629
5630 // Return to the page we came from.
5631 ret := threadReturnPage
5632 threadReturnPage = ""
5633 if ret != "" && ret != "feed" {
5634 // Force activePage to "" so switchPage doesn't early-return.
5635 activePage = ""
5636 switchPage(ret)
5637 // If returning to search, re-activate the bar with the current query
5638 // so the user sees the input and can go back or search again.
5639 if ret == "search" && searchBtnGlobal != 0 && !searchBarActive {
5640 activateSearchBar(searchBtnGlobal)
5641 if searchCurrentQ != "" {
5642 dom.SetProperty(searchInputEl, "value", searchCurrentQ)
5643 }
5644 }
5645 } else {
5646 dom.SetStyle(feedSelectEl, "display", "inline")
5647 if !feedExhausted && oldestFeedTs > 0 {
5648 dom.SetStyle(loadMoreBtn, "display", "block")
5649 }
5650 }
5651
5652 // Restore scroll position.
5653 if savedScrollTop != "" {
5654 dom.SetProperty(contentArea, "scrollTop", savedScrollTop)
5655 savedScrollTop = ""
5656 }
5657 }
5658
5659 func handleThreadEvent(gen int32, ev *nostr.Event) {
5660 if gen != threadGen {
5661 return // stale event from a previous thread
5662 }
5663 threadEvents[ev.ID] = ev
5664 // Debounced render - 200ms after last event, show what we have.
5665 if threadRenderTimer != 0 {
5666 dom.ClearTimeout(threadRenderTimer)
5667 }
5668 threadRenderTimer = dom.SetTimeout(func() {
5669 threadRenderTimer = 0
5670 if threadOpen {
5671 renderThreadTree()
5672 }
5673 }, 200)
5674 }
5675
5676 func handleThreadEOSE() {
5677 // Final render pass.
5678 if threadRenderTimer != 0 {
5679 dom.ClearTimeout(threadRenderTimer)
5680 threadRenderTimer = 0
5681 }
5682 renderThreadTree()
5683 threadFetchReplies()
5684 }
5685
5686 func threadFetchReplies() {
5687 if threadFollowUp >= 3 || !threadOpen {
5688 return
5689 }
5690 // Collect event IDs not yet queried for children.
5691 var ids []string
5692 for id := range threadEvents {
5693 if !threadFetchedIDs[id] {
5694 ids = append(ids, id)
5695 threadFetchedIDs[id] = true
5696 }
5697 }
5698 if len(ids) == 0 {
5699 return
5700 }
5701 threadFollowUp++
5702
5703 // Build JSON array of IDs for the #e filter.
5704 eArr := ""
5705 for i, id := range ids {
5706 if i > 0 {
5707 eArr = eArr | ","
5708 }
5709 eArr = eArr | jstr(id)
5710 }
5711 filter := "{\"#e\":[" | eArr | "],\"kinds\":[1]}"
5712
5713 // Local REQ.
5714 threadSubCount++
5715 s1 := "thr-" | itoa(threadSubCount)
5716 threadActiveSubs = append(threadActiveSubs, s1)
5717 routeMsg("[\"REQ\"," | jstr(s1) | "," | filter | "]")
5718
5719 // PROXY to remote relays.
5720 if len(threadRelays) > 0 {
5721 threadSubCount++
5722 s2 := "thr-" | itoa(threadSubCount)
5723 threadActiveSubs = append(threadActiveSubs, s2)
5724 routeMsg(buildProxyMsg(s2, filter, threadRelays))
5725 }
5726 }
5727
5728 func renderThreadTree() {
5729 n := len(threadEvents)
5730 if n == threadLastRendered {
5731 return // no new events since last render
5732 }
5733 threadLastRendered = n
5734
5735 clearChildren(threadContainer)
5736
5737 if n == 0 {
5738 empty := dom.CreateElement("div")
5739 dom.SetTextContent(empty, t("thread_empty"))
5740 dom.SetStyle(empty, "color", "var(--muted)")
5741 dom.SetStyle(empty, "padding", "16px 0")
5742 dom.AppendChild(threadContainer, empty)
5743 return
5744 }
5745
5746 // Build parent->children map. If direct parent isn't in the thread,
5747 // re-parent under root (the event is in-thread if it references root).
5748 children := map[string][]string{}
5749 for id, ev := range threadEvents {
5750 parentID := getReplyID(ev)
5751 if parentID == "" || parentID == id {
5752 continue
5753 }
5754 if threadEvents[parentID] == nil && parentID != threadRootID {
5755 // Direct parent not fetched - attach to root if event belongs to thread.
5756 if referencesEvent(ev, threadRootID) {
5757 parentID = threadRootID
5758 }
5759 }
5760 children[parentID] = append(children[parentID], id)
5761 }
5762
5763 // Sort children chronologically.
5764 for pid := range children {
5765 ids := children[pid]
5766 sortByCreatedAt(ids)
5767 children[pid] = ids
5768 }
5769
5770 // Find root - event with no parent in this thread, or threadRootID.
5771 rootID := threadRootID
5772 if _, ok := threadEvents[rootID]; !ok {
5773 // Root not fetched; find event with no in-thread parent.
5774 for id, ev := range threadEvents {
5775 parentID := getReplyID(ev)
5776 if parentID == "" || threadEvents[parentID] == nil {
5777 rootID = id
5778 break
5779 }
5780 }
5781 }
5782
5783 // Render tree via DFS, tracking what was rendered.
5784 rendered := map[string]bool{}
5785 var renderAt func(id string, depth int32)
5786 renderAt = func(id string, depth int32) {
5787 ev, ok := threadEvents[id]
5788 if !ok {
5789 return
5790 }
5791 rendered[id] = true
5792 if isMuted(ev.PubKey) || repliesToMuted(ev) {
5793 return // skip muted user and entire sub-branch
5794 }
5795 if looksLikeJSONSpam(ev.Content) {
5796 return
5797 }
5798 renderThreadNote(ev, depth, id == threadFocusID)
5799 for _, childID := range children[id] {
5800 renderAt(childID, depth+1)
5801 }
5802 }
5803
5804 // If root is present, start there. Otherwise render all as flat.
5805 if _, ok := threadEvents[rootID]; ok {
5806 renderAt(rootID, 0)
5807 // Render any remaining orphans (events not reached by DFS).
5808 for id := range threadEvents {
5809 if !rendered[id] {
5810 renderAt(id, 1)
5811 }
5812 }
5813 } else {
5814 for id := range threadEvents {
5815 renderAt(id, 0)
5816 }
5817 }
5818
5819 // Scroll focused note into view (centered vertically).
5820 if threadFocusID != "" {
5821 dom.SetTimeout(func() {
5822 el := dom.GetElementById("thread-focus")
5823 if el != 0 {
5824 top := parseIntProp(dom.GetProperty(el, "offsetTop"))
5825 vh := parseIntProp(dom.GetProperty(contentArea, "clientHeight"))
5826 scroll := top - vh/2
5827 if scroll < 0 {
5828 scroll = 0
5829 }
5830 dom.SetProperty(contentArea, "scrollTop", itoa(scroll))
5831 }
5832 }, 50)
5833 }
5834 }
5835
5836 func sortByCreatedAt(ids []string) {
5837 for i := 0; i < len(ids); i++ {
5838 for j := i + 1; j < len(ids); j++ {
5839 ei := threadEvents[ids[i]]
5840 ej := threadEvents[ids[j]]
5841 if ei != nil && ej != nil && ei.CreatedAt > ej.CreatedAt {
5842 ids[i], ids[j] = ids[j], ids[i]
5843 }
5844 }
5845 }
5846 }
5847
5848 func renderThreadNote(ev *nostr.Event, depth int32, focused bool) {
5849 note := dom.CreateElement("div")
5850 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
5851 dom.SetStyle(note, "padding", "8px 0")
5852 dom.SetStyle(note, "marginLeft", itoa(depth*8) | "px")
5853
5854 if focused {
5855 dom.SetStyle(note, "borderLeft", "3px solid var(--accent)")
5856 dom.SetStyle(note, "paddingLeft", "8px")
5857 dom.SetStyle(note, "background", "var(--bg2)")
5858 dom.SetStyle(note, "borderRadius", "4px")
5859 dom.SetAttribute(note, "id", "thread-focus")
5860 }
5861
5862 // Header.
5863 header := dom.CreateElement("div")
5864 dom.SetStyle(header, "display", "flex")
5865 dom.SetStyle(header, "alignItems", "center")
5866 dom.SetStyle(header, "marginBottom", "4px")
5867 dom.SetStyle(header, "maxWidth", "65ch")
5868
5869 authorLink := dom.CreateElement("div")
5870 dom.SetStyle(authorLink, "display", "flex")
5871 dom.SetStyle(authorLink, "alignItems", "center")
5872 dom.SetStyle(authorLink, "gap", "6px")
5873 dom.SetStyle(authorLink, "cursor", "pointer")
5874 headerPK := ev.PubKey
5875 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
5876 showProfile(headerPK)
5877 }))
5878
5879 avatar := dom.CreateElement("img")
5880 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
5881 dom.SetAttribute(avatar, "width", "20")
5882 dom.SetAttribute(avatar, "height", "20")
5883 dom.SetStyle(avatar, "borderRadius", "50%")
5884 dom.SetStyle(avatar, "objectFit", "cover")
5885 dom.SetStyle(avatar, "flexShrink", "0")
5886
5887 nameSpan := dom.CreateElement("span")
5888 dom.SetStyle(nameSpan, "fontSize", "14px")
5889 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
5890 dom.SetStyle(nameSpan, "fontWeight", "bold")
5891 dom.SetStyle(nameSpan, "color", "var(--fg)")
5892
5893 pk := ev.PubKey
5894 if pic, ok := authorPics[pk]; ok && pic != "" {
5895 setMediaSrc(avatar, pic)
5896 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
5897 } else {
5898 dom.SetStyle(avatar, "display", "none")
5899 }
5900 if name, ok := authorNames[pk]; ok && name != "" {
5901 dom.SetTextContent(nameSpan, name)
5902 } else {
5903 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
5904 if len(npub) > 20 {
5905 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
5906 }
5907 }
5908
5909 dom.AppendChild(authorLink, avatar)
5910 dom.AppendChild(authorLink, nameSpan)
5911 attachHoverCard(authorLink, pk)
5912 dom.AppendChild(header, authorLink)
5913
5914 appendClientTag(header, ev)
5915
5916 // Copy button - copies this note and all replies beneath it.
5917 tCpBtn := noteCopyBtn()
5918 capturedTCpEv := ev
5919 capturedTCpBtn := tCpBtn
5920 dom.AddEventListener(tCpBtn, "click", dom.RegisterCallback(func() {
5921 evs := collectBranch(capturedTCpEv.ID)
5922 text := ""
5923 for _, e := range evs {
5924 text = text | noteClipText(e)
5925 }
5926 dom.WritePrimarySelection(text)
5927 dom.WriteClipboard(text, func(ok bool) {
5928 if ok {
5929 noteCopyFeedback(capturedTCpBtn)
5930 }
5931 })
5932 }))
5933 dom.SetStyle(tCpBtn, "marginLeft", "auto")
5934 dom.AppendChild(header, tCpBtn)
5935
5936 // JSON toggle </>
5937 var tContentEl dom.Element
5938 var tMoreEl dom.Element
5939 tJsonActive := false
5940 var tJsonEl dom.Element
5941 tJsonBtn := dom.CreateElement("span")
5942 dom.SetTextContent(tJsonBtn, "</>")
5943 dom.SetStyle(tJsonBtn, "fontSize", "10px")
5944 dom.SetStyle(tJsonBtn, "fontFamily", "'Fira Code', monospace")
5945 dom.SetStyle(tJsonBtn, "cursor", "pointer")
5946 dom.SetStyle(tJsonBtn, "opacity", "0.6")
5947 dom.SetStyle(tJsonBtn, "marginRight", "8px")
5948 dom.SetStyle(tJsonBtn, "userSelect", "none")
5949 dom.SetStyle(tJsonBtn, "color", "var(--muted)")
5950 capturedTJBtn := tJsonBtn
5951 capturedTEv := ev
5952 capturedTNote := note
5953 dom.AddEventListener(tJsonBtn, "click", dom.RegisterCallback(func() {
5954 if tJsonActive {
5955 dom.SetStyle(tContentEl, "display", "")
5956 dom.SetStyle(tMoreEl, "display", "none")
5957 dom.SetStyle(tJsonEl, "display", "none")
5958 dom.SetStyle(capturedTJBtn, "opacity", "0.6")
5959 dom.SetStyle(capturedTJBtn, "color", "var(--muted)")
5960 tJsonActive = false
5961 } else {
5962 if tJsonEl == 0 {
5963 tJsonEl = dom.CreateElement("pre")
5964 dom.SetStyle(tJsonEl, "fontFamily", "'Fira Code', monospace")
5965 dom.SetStyle(tJsonEl, "fontSize", "12px")
5966 dom.SetStyle(tJsonEl, "lineHeight", "1.5")
5967 dom.SetStyle(tJsonEl, "wordBreak", "break-word")
5968 dom.SetStyle(tJsonEl, "whiteSpace", "pre-wrap")
5969 dom.SetStyle(tJsonEl, "maxWidth", "65ch")
5970 dom.SetStyle(tJsonEl, "margin", "0")
5971 dom.SetStyle(tJsonEl, "color", "var(--fg)")
5972 dom.SetTextContent(tJsonEl, prettyNoteJSON(capturedTEv))
5973 dom.InsertBefore(capturedTNote, tJsonEl, tMoreEl)
5974 }
5975 dom.SetStyle(tContentEl, "display", "none")
5976 dom.SetStyle(tMoreEl, "display", "none")
5977 dom.SetStyle(tJsonEl, "display", "block")
5978 dom.SetStyle(capturedTJBtn, "opacity", "1")
5979 dom.SetStyle(capturedTJBtn, "color", "var(--accent)")
5980 tJsonActive = true
5981 }
5982 }))
5983 dom.AppendChild(header, tJsonBtn)
5984
5985 // Relay widget + timestamp.
5986 rw3 := relayWidget(ev.ID)
5987 dom.AppendChild(header, rw3)
5988 if ev.CreatedAt > 0 {
5989 tsEl := dom.CreateElement("span")
5990 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
5991 dom.SetStyle(tsEl, "fontSize", "11px")
5992 dom.SetStyle(tsEl, "color", "var(--muted)")
5993 dom.AppendChild(header, tsEl)
5994 }
5995
5996 dom.AppendChild(note, header)
5997
5998 if _, cached := authorNames[pk]; !cached {
5999 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
6000 if !lookupFetchedK0(pk) {
6001 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
6002 }
6003 }
6004
6005 // Content.
6006 content := dom.CreateElement("div")
6007 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6008 dom.SetStyle(content, "fontSize", "13px")
6009 dom.SetStyle(content, "lineHeight", "1.5")
6010 dom.SetStyle(content, "wordBreak", "break-word")
6011 dom.SetStyle(content, "maxWidth", "65ch")
6012 setHTMLWithMedia(content, renderMarkdown(ev.Content))
6013 dom.AppendChild(note, content)
6014 tContentEl = content
6015
6016 more := makeShowMore(content)
6017 tMoreEl = more
6018 dom.AppendChild(note, buildActionRow(ev, note, content, more))
6019
6020 dom.AppendChild(threadContainer, note)
6021 resolveEmbeds()
6022 }
6023
6024 // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY.
6025 // Still used internally; call sites use profile.Send(P_RESOLVE_FORCE) instead.
6026 func fetchAuthorProfile(pk string) {
6027 if lookupFetchedK0(pk) {
6028 return
6029 }
6030 setFetchedK0(pk, true)
6031
6032 profileSubCounter++
6033 subID := "ap-" | itoa(profileSubCounter)
6034 authorSubPK[subID] = pk
6035
6036 proxyRelays := buildProxy(pk)
6037 routeMsg(buildProxyMsg(subID,
6038 "{\"authors\":[" | jstr(pk) | "],\"kinds\":[0,3,10002,10000],\"limit\":6}",
6039 proxyRelays))
6040 }
6041
6042 // queueProfileFetch is now a stub; call sites use profile.Send instead.
6043 func queueProfileFetch(pk string) {
6044 if lookupFetchedK0(pk) {
6045 return
6046 }
6047 setFetchedK0(pk, true)
6048 fetchQueue = append(fetchQueue, pk)
6049 if fetchTimer != 0 {
6050 dom.ClearTimeout(fetchTimer)
6051 }
6052 fetchTimer = dom.SetTimeout(func() {
6053 fetchTimer = 0
6054 flushFetchQueue()
6055 }, 300)
6056 }
6057
6058 // flushFetchQueue sends all queued pubkeys as chunked batch PROXY requests.
6059 func flushFetchQueue() {
6060 if len(fetchQueue) == 0 {
6061 return
6062 }
6063 queue := fetchQueue
6064 fetchQueue = nil
6065
6066 proxy := []string{:len(discoveryRelays)}
6067 copy(proxy, discoveryRelays)
6068 for _, u := range relayURLs {
6069 proxy = appendUnique(proxy, u)
6070 }
6071 for _, pk := range queue {
6072 if rels, ok := profileRelaysCache[pk]; ok {
6073 for _, r := range rels {
6074 proxy = appendUnique(proxy, r)
6075 }
6076 }
6077 }
6078 top := topRelays(4)
6079 for _, r := range top {
6080 proxy = appendUnique(proxy, r)
6081 }
6082
6083 const batchSize = 100
6084 for i := 0; i < len(queue); i += batchSize {
6085 end := i + batchSize
6086 if end > len(queue) {
6087 end = len(queue)
6088 }
6089 chunk := queue[i:end]
6090 authors := "["
6091 for j, pk := range chunk {
6092 if j > 0 {
6093 authors = authors | ","
6094 }
6095 authors = authors | jstr(pk)
6096 }
6097 authors = authors | "]"
6098 profileSubCounter++
6099 subID := "ap-batch-q-" | itoa(profileSubCounter)
6100 routeMsg(buildProxyMsg(subID,
6101 "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
6102 proxy))
6103 // Also query feed relays directly - they have the kind 1 notes
6104 // so they almost certainly have kind 0 for the same authors.
6105 // Uses the SW's existing WebSocket connections, bypasses server proxy.
6106 profileSubCounter++
6107 routeMsg(buildProxyMsg("ap-d-" | itoa(profileSubCounter),
6108 "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
6109 relayURLs))
6110 }
6111 }
6112
6113 // retryMissingProfiles batches pubkeys that still lack a name into chunked
6114 // PROXY requests through the orly relay. Fetches all metadata kinds so
6115 // relay lists from kind 10002 enable second-hop discovery.
6116 func retryMissingProfiles() {
6117 var missing []string
6118 for pk := range pendingNotes {
6119 if _, ok := authorNames[pk]; !ok {
6120 missing = append(missing, pk)
6121 }
6122 }
6123 if len(missing) == 0 {
6124 return
6125 }
6126
6127 // Reset fetchedK0 for still-missing profiles so individual re-fetches
6128 // can fire if new relay info appears from other profiles' kind 10002.
6129 for _, pk := range missing {
6130 setFetchedK0(pk, false)
6131 }
6132
6133 // Discovery relays first, then user relays + discovered relays.
6134 proxy := []string{:len(discoveryRelays)}
6135 copy(proxy, discoveryRelays)
6136 for _, u := range relayURLs {
6137 proxy = appendUnique(proxy, u)
6138 }
6139 top := topRelays(8)
6140 for _, u := range top {
6141 proxy = appendUnique(proxy, u)
6142 }
6143
6144 const batchSize = 100
6145 batchNum := 0
6146 for i := 0; i < len(missing); i += batchSize {
6147 end := i + batchSize
6148 if end > len(missing) {
6149 end = len(missing)
6150 }
6151 chunk := missing[i:end]
6152 authors := "["
6153 for j, pk := range chunk {
6154 if j > 0 {
6155 authors = authors | ","
6156 }
6157 authors = authors | jstr(pk)
6158 }
6159 authors = authors | "]"
6160 subID := "ap-batch-" | itoa(retryRound) | "-" | itoa(batchNum)
6161 batchNum++
6162 routeMsg(buildProxyMsg(subID,
6163 "{\"authors\":" | authors | ",\"kinds\":[0,10002],\"limit\":" | itoa(len(chunk)*2) | "}",
6164 proxy))
6165 // Direct query to feed relays.
6166 profileSubCounter++
6167 routeMsg(buildProxyMsg("ap-d-" | itoa(profileSubCounter),
6168 "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
6169 relayURLs))
6170 }
6171 retryRound++
6172 }
6173
6174 // applyAuthorProfile is no longer called by Shell - Profile Worker owns event
6175 // processing. P_RESOLVED/P_CONTENT handlers in onSWMessage do the DOM updates.
6176
6177 // updateNoteHeader fills in avatar+name on a note's author header div.
6178 func updateNoteHeader(header dom.Element, name, pic string) {
6179 // First child is <img>, second is <span>.
6180 img := dom.FirstChild(header)
6181 if img == 0 {
6182 return
6183 }
6184 span := dom.NextSibling(img)
6185 if len(pic) > 0 {
6186 setMediaSrc(img, pic)
6187 dom.SetAttribute(img, "onerror", "this.style.display='none'")
6188 dom.SetStyle(img, "display", "")
6189 }
6190 if len(name) > 0 {
6191 dom.SetTextContent(span, name)
6192 }
6193 }
6194
6195 // --- Profile page ---
6196
6197 func showEditProfileModal() {
6198 content := profileContentCache[pubhex]
6199 curName := helpers.JsonGetString(content, "name")
6200 curAbout := helpers.JsonGetString(content, "about")
6201 curPic := helpers.JsonGetString(content, "picture")
6202 curBanner := helpers.JsonGetString(content, "banner")
6203 curNip05 := helpers.JsonGetString(content, "nip05")
6204 curWebsite := helpers.JsonGetString(content, "website")
6205 curLud16 := helpers.JsonGetString(content, "lud16")
6206
6207 overlay := dom.CreateElement("div")
6208 dom.SetStyle(overlay, "position", "fixed")
6209 dom.SetStyle(overlay, "top", "0")
6210 dom.SetStyle(overlay, "left", "0")
6211 dom.SetStyle(overlay, "right", "0")
6212 dom.SetStyle(overlay, "bottom", "0")
6213 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.6)")
6214 dom.SetStyle(overlay, "display", "flex")
6215 dom.SetStyle(overlay, "alignItems", "center")
6216 dom.SetStyle(overlay, "justifyContent", "center")
6217 dom.SetStyle(overlay, "zIndex", "2000")
6218
6219 modal := dom.CreateElement("div")
6220 dom.SetStyle(modal, "background", "var(--bg)")
6221 dom.SetStyle(modal, "border", "1px solid var(--border)")
6222 dom.SetStyle(modal, "borderRadius", "8px")
6223 dom.SetStyle(modal, "padding", "20px")
6224 dom.SetStyle(modal, "width", "400px")
6225 dom.SetStyle(modal, "maxWidth", "calc(100vw - 32px)")
6226 dom.SetStyle(modal, "maxHeight", "calc(100vh - 64px)")
6227 dom.SetStyle(modal, "overflowY", "auto")
6228
6229 makeField := func(label, val string, multiline bool) dom.Element {
6230 wrap := dom.CreateElement("div")
6231 dom.SetStyle(wrap, "marginBottom", "12px")
6232 lbl := dom.CreateElement("div")
6233 dom.SetTextContent(lbl, label)
6234 dom.SetStyle(lbl, "fontSize", "11px")
6235 dom.SetStyle(lbl, "color", "var(--muted)")
6236 dom.SetStyle(lbl, "marginBottom", "4px")
6237 dom.AppendChild(wrap, lbl)
6238 var input dom.Element
6239 if multiline {
6240 input = dom.CreateElement("textarea")
6241 dom.SetAttribute(input, "rows", "3")
6242 } else {
6243 input = dom.CreateElement("input")
6244 dom.SetAttribute(input, "type", "text")
6245 }
6246 dom.SetStyle(input, "width", "100%")
6247 dom.SetStyle(input, "padding", "6px 8px")
6248 dom.SetStyle(input, "background", "var(--bg2)")
6249 dom.SetStyle(input, "color", "var(--fg)")
6250 dom.SetStyle(input, "border", "1px solid var(--border)")
6251 dom.SetStyle(input, "borderRadius", "4px")
6252 dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
6253 dom.SetStyle(input, "fontSize", "13px")
6254 dom.SetStyle(input, "boxSizing", "border-box")
6255 dom.SetAttribute(input, "value", val)
6256 if multiline {
6257 dom.SetTextContent(input, val)
6258 }
6259 dom.AppendChild(wrap, input)
6260 return wrap
6261 }
6262
6263 nameField := makeField(t("profile_name"), curName, false)
6264 aboutField := makeField(t("profile_about"), curAbout, true)
6265 picField := makeField(t("profile_picture"), curPic, false)
6266 bannerField := makeField(t("profile_banner"), curBanner, false)
6267 nip05Field := makeField(t("profile_nip05"), curNip05, false)
6268 websiteField := makeField(t("profile_website"), curWebsite, false)
6269 lud16Field := makeField(t("profile_lud16"), curLud16, false)
6270
6271 addUploadRow := func(field dom.Element) {
6272 input := dom.NextSibling(dom.FirstChild(field))
6273 btn := dom.CreateElement("button")
6274 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>`)
6275 dom.SetStyle(btn, "padding", "6px 8px")
6276 dom.SetStyle(btn, "border", "1px solid var(--border)")
6277 dom.SetStyle(btn, "borderRadius", "4px")
6278 dom.SetStyle(btn, "background", "transparent")
6279 dom.SetStyle(btn, "color", "var(--fg)")
6280 dom.SetStyle(btn, "cursor", "pointer")
6281 dom.SetStyle(btn, "display", "flex")
6282 dom.SetStyle(btn, "alignItems", "center")
6283 dom.SetStyle(btn, "flexShrink", "0")
6284 dom.AddEventListener(btn, "click", dom.RegisterCallback(func() {
6285 dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
6286 if b64 != "" {
6287 blossomUploadToInput(b64, mime, input)
6288 }
6289 })
6290 }))
6291 row := dom.CreateElement("div")
6292 dom.SetStyle(row, "display", "flex")
6293 dom.SetStyle(row, "gap", "4px")
6294 dom.SetStyle(input, "flex", "1")
6295 dom.SetStyle(input, "minWidth", "0")
6296 dom.InsertBefore(field, row, input)
6297 dom.AppendChild(row, input)
6298 dom.AppendChild(row, btn)
6299 }
6300
6301 addUploadRow(picField)
6302 addUploadRow(bannerField)
6303
6304 dom.AppendChild(modal, nameField)
6305 dom.AppendChild(modal, aboutField)
6306 dom.AppendChild(modal, picField)
6307 dom.AppendChild(modal, bannerField)
6308 dom.AppendChild(modal, nip05Field)
6309 dom.AppendChild(modal, websiteField)
6310 dom.AppendChild(modal, lud16Field)
6311
6312 // Buttons row.
6313 btnRow := dom.CreateElement("div")
6314 dom.SetStyle(btnRow, "display", "flex")
6315 dom.SetStyle(btnRow, "gap", "8px")
6316 dom.SetStyle(btnRow, "marginTop", "8px")
6317
6318 getInput := func(field dom.Element) dom.Element {
6319 el := dom.NextSibling(dom.FirstChild(field))
6320 tag := dom.GetProperty(el, "tagName")
6321 if tag == "DIV" {
6322 return dom.FirstChild(el)
6323 }
6324 return el
6325 }
6326
6327 saveBtn := dom.CreateElement("button")
6328 dom.SetTextContent(saveBtn, t("save"))
6329 dom.SetStyle(saveBtn, "padding", "6px 16px")
6330 dom.SetStyle(saveBtn, "fontFamily", "'Fira Code', monospace")
6331 dom.SetStyle(saveBtn, "fontSize", "12px")
6332 dom.SetStyle(saveBtn, "background", "var(--accent)")
6333 dom.SetStyle(saveBtn, "color", "#fff")
6334 dom.SetStyle(saveBtn, "border", "none")
6335 dom.SetStyle(saveBtn, "borderRadius", "4px")
6336 dom.SetStyle(saveBtn, "cursor", "pointer")
6337 dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
6338 newName := dom.GetProperty(getInput(nameField), "value")
6339 newAbout := dom.GetProperty(getInput(aboutField), "value")
6340 newPic := dom.GetProperty(getInput(picField), "value")
6341 newBanner := dom.GetProperty(getInput(bannerField), "value")
6342 newNip05 := dom.GetProperty(getInput(nip05Field), "value")
6343 newWebsite := dom.GetProperty(getInput(websiteField), "value")
6344 newLud16 := dom.GetProperty(getInput(lud16Field), "value")
6345
6346 k0content := "{" |
6347 "\"name\":\"" | jsonEsc(newName) | "\"" |
6348 ",\"about\":\"" | jsonEsc(newAbout) | "\"" |
6349 ",\"picture\":\"" | jsonEsc(newPic) | "\"" |
6350 ",\"banner\":\"" | jsonEsc(newBanner) | "\"" |
6351 ",\"nip05\":\"" | jsonEsc(newNip05) | "\"" |
6352 ",\"website\":\"" | jsonEsc(newWebsite) | "\"" |
6353 ",\"lud16\":\"" | jsonEsc(newLud16) | "\"" |
6354 "}"
6355
6356 ts := dom.NowSeconds()
6357 unsigned := "{\"kind\":0,\"content\":" | jstr(k0content) |
6358 ",\"tags\":[]" |
6359 ",\"created_at\":" | i64toa(ts) |
6360 ",\"pubkey\":\"" | pubhex | "\"}"
6361
6362 hasSigner := "no"
6363 if signer.HasSigner() {
6364 hasSigner = "yes"
6365 }
6366 dom.ConsoleLog("[profile] signing kind 0, hasSigner=" | hasSigner)
6367 signer.SignEvent(unsigned, func(signed string) {
6368 if len(signed) == 0 {
6369 dom.ConsoleLog("[profile] sign returned empty - check [signer] and [ext.dispatch] logs above")
6370 dom.Confirm("sign failed - open signer panel, unlock vault, and try again")
6371 return
6372 }
6373 dom.ConsoleLog("[profile] sign OK, posting to SW")
6374 selected := composeSelectedRelays()
6375 for _, d := range discoveryRelays {
6376 selected = appendUnique(selected, d)
6377 }
6378 dom.ConsoleLog("[profile] PUBLISH_TO " | itoa(len(selected)) | " relays")
6379 msg := "[\"PUBLISH_TO\"," | signed | ",["
6380 for i, u := range selected {
6381 if i > 0 {
6382 msg = msg | ","
6383 }
6384 msg = msg | jstr(u)
6385 }
6386 routeMsg(msg | "]]")
6387 dom.RemoveChild(dom.Body(), overlay)
6388 // Update local cache immediately.
6389 profileContentCache[pubhex] = k0content
6390 if len(newName) > 0 {
6391 profileName = newName
6392 setAuthorName(pubhex, newName)
6393 dom.SetTextContent(nameEl, newName)
6394 }
6395 if len(newPic) > 0 {
6396 profilePic = newPic
6397 authorPics[pubhex] = newPic
6398 setMediaSrc(avatarEl, newPic)
6399 dom.SetStyle(avatarEl, "display", "block")
6400 }
6401 profileTs = ts
6402 renderProfilePage(pubhex)
6403 })
6404 }))
6405 dom.AppendChild(btnRow, saveBtn)
6406
6407 cancelBtn := dom.CreateElement("button")
6408 dom.SetTextContent(cancelBtn, t("cancel"))
6409 dom.SetStyle(cancelBtn, "padding", "6px 16px")
6410 dom.SetStyle(cancelBtn, "fontFamily", "'Fira Code', monospace")
6411 dom.SetStyle(cancelBtn, "fontSize", "12px")
6412 dom.SetStyle(cancelBtn, "background", "var(--bg2)")
6413 dom.SetStyle(cancelBtn, "color", "var(--fg)")
6414 dom.SetStyle(cancelBtn, "border", "1px solid var(--border)")
6415 dom.SetStyle(cancelBtn, "borderRadius", "4px")
6416 dom.SetStyle(cancelBtn, "cursor", "pointer")
6417 dom.AddEventListener(cancelBtn, "click", dom.RegisterCallback(func() {
6418 dom.RemoveChild(dom.Body(), overlay)
6419 }))
6420 dom.AppendChild(btnRow, cancelBtn)
6421
6422 dom.AppendChild(modal, btnRow)
6423 dom.AppendChild(overlay, modal)
6424 dom.AppendChild(dom.Body(), overlay)
6425 }
6426
6427 func showProfile(pk string) {
6428 if fetchedK0 == nil {
6429 return
6430 }
6431 // Save current profile's tab state before switching.
6432 if profileViewPK != "" && profileWrappers[profileViewPK] != 0 {
6433 profileWrapTabSel[profileViewPK] = profileTab
6434 profileWrapTabCont[profileViewPK] = profileTabContent
6435 profileWrapTabs[profileViewPK] = profileTabBtns
6436 }
6437
6438 profileViewPK = pk
6439
6440 // Always fetch fresh metadata in background.
6441 setFetchedK0(pk, false)
6442 profile.Send(`["P_RESOLVE_FORCE",` | jstr(pk) | `]`)
6443
6444 if !navPop {
6445 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
6446 dom.PushState("/p/" | npub)
6447 }
6448
6449 // Hide all cached profile wrappers and remove relay info.
6450 for _, w := range profileWrappers {
6451 dom.SetStyle(w, "display", "none")
6452 }
6453 if relayInfoEl != 0 {
6454 dom.RemoveChild(profilePage, relayInfoEl)
6455 relayInfoEl = 0
6456 }
6457
6458 if wrapper, ok := profileWrappers[pk]; ok {
6459 // Cache hit - restore.
6460 dom.SetStyle(wrapper, "display", "block")
6461 profileTabContent = profileWrapTabCont[pk]
6462 profileTabBtns = profileWrapTabs[pk]
6463 profileTab = profileWrapTabSel[pk]
6464 profileTouchLRU(pk)
6465 } else {
6466 renderProfilePage(pk)
6467 }
6468
6469 activePage = "" // force switchPage to run
6470 switchPage("profile")
6471 }
6472
6473 const profileCacheMax = 8
6474
6475 func profileTouchLRU(pk string) {
6476 // Move pk to end (most recent).
6477 out := []string{:0:len(profileWrapOrder)}
6478 for _, p := range profileWrapOrder {
6479 if p != pk {
6480 out = append(out, p)
6481 }
6482 }
6483 profileWrapOrder = append(out, pk)
6484 }
6485
6486 func profileEvictOldest() {
6487 for len(profileWrapOrder) > profileCacheMax {
6488 old := profileWrapOrder[0]
6489 profileWrapOrder = profileWrapOrder[1:]
6490 if w, ok := profileWrappers[old]; ok {
6491 dom.RemoveChild(profilePage, w)
6492 dom.ReleaseChildren(w)
6493 dom.ReleaseElement(w)
6494 }
6495 delete(profileWrappers, old)
6496 delete(profileWrapTabCont, old)
6497 delete(profileWrapTabs, old)
6498 delete(profileWrapTabSel, old)
6499 delete(profileContentCache, old)
6500 delete(profileFollowsCache, old)
6501 delete(profileMutesCache, old)
6502 delete(profileRelaysCache, old)
6503 }
6504 }
6505
6506 func verifyNip05(nip05, pubkeyHex string, badge dom.Element) {
6507 at := -1
6508 for i := 0; i < len(nip05); i++ {
6509 if nip05[i] == '@' {
6510 at = i
6511 break
6512 }
6513 }
6514 if at < 1 || at >= len(nip05)-1 {
6515 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
6516 return
6517 }
6518 local := nip05[:at]
6519 domain := nip05[at+1:]
6520 url := "https://" | domain | "/.well-known/nostr.json?name=" | local
6521 dom.FetchText(url, func(body string) {
6522 if body == "" {
6523 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
6524 return
6525 }
6526 namesObj := helpers.JsonGetString(body, "names")
6527 if namesObj == "" {
6528 // names might be an object not a string - extract manually
6529 namesStart := -1
6530 key := "\"names\""
6531 for i := 0; i < len(body)-len(key); i++ {
6532 if body[i:i+len(key)] == key {
6533 namesStart = i + len(key)
6534 break
6535 }
6536 }
6537 if namesStart < 0 {
6538 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
6539 return
6540 }
6541 // Skip colon and whitespace to find the object
6542 for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') {
6543 namesStart++
6544 }
6545 if namesStart >= len(body) || body[namesStart] != '{' {
6546 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
6547 return
6548 }
6549 // Find matching brace
6550 depth := 0
6551 end := namesStart
6552 for end < len(body) {
6553 if body[end] == '{' {
6554 depth++
6555 } else if body[end] == '}' {
6556 depth--
6557 if depth == 0 {
6558 end++
6559 break
6560 }
6561 }
6562 end++
6563 }
6564 namesObj = body[namesStart:end]
6565 }
6566 got := helpers.JsonGetString(namesObj, local)
6567 if got == pubkeyHex {
6568 dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅
6569 } else {
6570 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
6571 }
6572 })
6573 }
6574
6575 func renderProfilePage(pk string) {
6576 savedTab := profileTab
6577 closeProfileNoteSub()
6578 profileNotesSeen = map[string]bool{}
6579
6580 // Remove previous wrapper for this pubkey if it exists.
6581 if old, ok := profileWrappers[pk]; ok {
6582 dom.RemoveChild(profilePage, old)
6583 }
6584
6585 wrapper := dom.CreateElement("div")
6586 profileWrappers[pk] = wrapper
6587 profileTouchLRU(pk)
6588 profileEvictOldest()
6589 dom.AppendChild(profilePage, wrapper)
6590
6591 content := profileContentCache[pk]
6592 name := authorNames[pk]
6593 pic := authorPics[pk]
6594 about := helpers.JsonGetString(content, "about")
6595 website := helpers.JsonGetString(content, "website")
6596 nip05 := helpers.JsonGetString(content, "nip05")
6597 lud16 := helpers.JsonGetString(content, "lud16")
6598 banner := helpers.JsonGetString(content, "banner")
6599
6600 // Banner - full width, 200px, cover.
6601 if banner != "" {
6602 bannerEl := dom.CreateElement("img")
6603 dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
6604 setMediaSrc(bannerEl, banner)
6605 dom.SetStyle(bannerEl, "width", "100%")
6606 dom.SetStyle(bannerEl, "height", "240px")
6607 dom.SetStyle(bannerEl, "objectFit", "cover")
6608 dom.SetStyle(bannerEl, "objectPosition", "center 30%")
6609 dom.SetStyle(bannerEl, "display", "block")
6610 dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
6611 dom.AppendChild(wrapper, bannerEl)
6612 }
6613
6614 // User info card - glass effect, overlapping banner.
6615 card := dom.CreateElement("div")
6616 dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 42%, transparent)")
6617 dom.SetStyle(card, "backdropFilter", "blur(8px)")
6618 dom.SetStyle(card, "borderRadius", "8px")
6619 dom.SetStyle(card, "padding", "8px")
6620 if banner != "" {
6621 dom.SetStyle(card, "margin", "-24px 16px 0")
6622 } else {
6623 dom.SetStyle(card, "margin", "16px")
6624 }
6625 dom.SetStyle(card, "position", "relative")
6626 dom.SetStyle(card, "width", "fit-content")
6627 dom.SetStyle(card, "maxWidth", "calc(100% - 32px)")
6628
6629 // Top row: avatar + info. Wraps on narrow screens so avatar goes above.
6630 topRow := dom.CreateElement("div")
6631 dom.SetStyle(topRow, "display", "flex")
6632 dom.SetStyle(topRow, "gap", "16px")
6633 dom.SetStyle(topRow, "alignItems", "flex-start")
6634 dom.SetStyle(topRow, "flexWrap", "wrap")
6635
6636 // Compute npub early - needed for avatar QR click and npub row.
6637 npubBytes := helpers.HexDecode(pk)
6638 npubStr := helpers.EncodeNpub(npubBytes)
6639
6640 if pic != "" {
6641 av := dom.CreateElement("img")
6642 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
6643 setMediaSrc(av, pic)
6644 dom.SetAttribute(av, "width", "64")
6645 dom.SetAttribute(av, "height", "64")
6646 dom.SetStyle(av, "borderRadius", "50%")
6647 dom.SetStyle(av, "objectFit", "cover")
6648 dom.SetStyle(av, "flexShrink", "0")
6649 dom.SetStyle(av, "border", "3px solid var(--bg)")
6650 dom.SetStyle(av, "cursor", "pointer")
6651 dom.SetAttribute(av, "onerror", "this.style.display='none'")
6652 avNpub := npubStr
6653 dom.AddEventListener(av, "click", dom.RegisterCallback(func() {
6654 showQRModal(avNpub)
6655 }))
6656 dom.AppendChild(topRow, av)
6657 }
6658
6659 info := dom.CreateElement("div")
6660 dom.SetStyle(info, "minWidth", "200px")
6661 dom.SetStyle(info, "flex", "1")
6662 dom.SetStyle(info, "overflow", "hidden")
6663
6664 if name != "" {
6665 nameRow := dom.CreateElement("div")
6666 dom.SetStyle(nameRow, "display", "flex")
6667 dom.SetStyle(nameRow, "alignItems", "baseline")
6668 dom.SetStyle(nameRow, "gap", "8px")
6669 nameSpan := dom.CreateElement("span")
6670 dom.SetTextContent(nameSpan, name)
6671 dom.SetStyle(nameSpan, "fontSize", "20px")
6672 dom.SetStyle(nameSpan, "fontWeight", "bold")
6673 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6674 dom.SetStyle(nameSpan, "cursor", "pointer")
6675 nameNpub := npubStr
6676 dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() {
6677 showQRModal(nameNpub)
6678 }))
6679 dom.AppendChild(nameRow, nameSpan)
6680 // "follows you" badge.
6681 if pk != pubhex {
6682 if theirFollows, ok := profileFollowsCache[pk]; ok {
6683 for _, fpk := range theirFollows {
6684 if fpk == pubhex {
6685 badge := dom.CreateElement("span")
6686 dom.SetTextContent(badge, "follows you")
6687 dom.SetStyle(badge, "fontSize", "11px")
6688 dom.SetStyle(badge, "color", "var(--muted)")
6689 dom.SetStyle(badge, "background", "var(--bg2)")
6690 dom.SetStyle(badge, "padding", "2px 6px")
6691 dom.SetStyle(badge, "borderRadius", "4px")
6692 dom.AppendChild(nameRow, badge)
6693 break
6694 }
6695 }
6696 }
6697 }
6698 dom.AppendChild(info, nameRow)
6699 }
6700
6701 if nip05 != "" {
6702 nip05Row := dom.CreateElement("div")
6703 dom.SetStyle(nip05Row, "display", "flex")
6704 dom.SetStyle(nip05Row, "alignItems", "center")
6705 dom.SetStyle(nip05Row, "gap", "4px")
6706 nip05Text := dom.CreateElement("span")
6707 dom.SetTextContent(nip05Text, nip05)
6708 dom.SetStyle(nip05Text, "color", "var(--muted)")
6709 dom.SetStyle(nip05Text, "fontSize", "13px")
6710 dom.AppendChild(nip05Row, nip05Text)
6711 nip05Badge := dom.CreateElement("span")
6712 dom.SetStyle(nip05Badge, "fontSize", "14px")
6713 dom.AppendChild(nip05Row, nip05Badge)
6714 dom.AppendChild(info, nip05Row)
6715
6716 // Async NIP-05 validation.
6717 verifyNip05(nip05, pk, nip05Badge)
6718 }
6719
6720 // npub row: clickable npub text (shows QR) + copy icon.
6721 npubRow := dom.CreateElement("div")
6722 dom.SetStyle(npubRow, "display", "flex")
6723 dom.SetStyle(npubRow, "alignItems", "center")
6724 dom.SetStyle(npubRow, "gap", "6px")
6725 dom.SetStyle(npubRow, "marginTop", "2px")
6726 npubEl := dom.CreateElement("span")
6727 dom.SetStyle(npubEl, "color", "var(--muted)")
6728 dom.SetStyle(npubEl, "fontSize", "12px")
6729 dom.SetStyle(npubEl, "wordBreak", "break-all")
6730 dom.SetStyle(npubEl, "cursor", "pointer")
6731 dom.SetTextContent(npubEl, npubStr)
6732 npubQR := npubStr
6733 dom.AddEventListener(npubEl, "click", dom.RegisterCallback(func() {
6734 showQRModal(npubQR)
6735 }))
6736 dom.AppendChild(npubRow, npubEl)
6737 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>`)
6738 dom.SetAttribute(copyBtn, "data-npub", npubStr)
6739 dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText(this.dataset.npub)")
6740 dom.AppendChild(npubRow, copyBtn)
6741 dom.AppendChild(info, npubRow)
6742
6743 // Website + lightning inline.
6744 if website != "" || lud16 != "" {
6745 metaRow := dom.CreateElement("div")
6746 dom.SetStyle(metaRow, "display", "flex")
6747 dom.SetStyle(metaRow, "flexWrap", "wrap")
6748 dom.SetStyle(metaRow, "gap", "4px 12px")
6749 dom.SetStyle(metaRow, "marginTop", "6px")
6750 dom.SetStyle(metaRow, "fontSize", "12px")
6751 if website != "" {
6752 wEl := dom.CreateElement("span")
6753 dom.SetStyle(wEl, "color", "var(--accent)")
6754 dom.SetStyle(wEl, "wordBreak", "break-all")
6755 dom.SetTextContent(wEl, website)
6756 dom.AppendChild(metaRow, wEl)
6757 }
6758 if lud16 != "" {
6759 lEl := dom.CreateElement("span")
6760 dom.SetStyle(lEl, "color", "var(--muted)")
6761 dom.SetStyle(lEl, "wordBreak", "break-all")
6762 dom.SetStyle(lEl, "cursor", "pointer")
6763 dom.SetTextContent(lEl, "\xE2\x9A\xA1 " | lud16)
6764 capLud := lud16
6765 dom.AddEventListener(lEl, "click", dom.RegisterCallback(func() {
6766 showQRModal(capLud)
6767 }))
6768 dom.AppendChild(metaRow, lEl)
6769 }
6770 dom.AppendChild(info, metaRow)
6771 }
6772
6773 dom.AppendChild(topRow, info)
6774 dom.AppendChild(card, topRow)
6775
6776 // Action buttons - only for other users.
6777 if pk != pubhex {
6778 btnRow := dom.CreateElement("div")
6779 dom.SetStyle(btnRow, "display", "flex")
6780 dom.SetStyle(btnRow, "gap", "8px")
6781 dom.SetStyle(btnRow, "marginTop", "12px")
6782
6783 // Follow/unfollow button.
6784 followBtn := dom.CreateElement("button")
6785 dom.SetStyle(followBtn, "padding", "6px 16px")
6786 dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
6787 dom.SetStyle(followBtn, "fontSize", "12px")
6788 dom.SetStyle(followBtn, "borderRadius", "4px")
6789 dom.SetStyle(followBtn, "cursor", "pointer")
6790 isFollowing := false
6791 if len(myFollows) > 0 {
6792 for _, fpk := range myFollows {
6793 if fpk == pk {
6794 isFollowing = true
6795 break
6796 }
6797 }
6798 }
6799 if isFollowing {
6800 dom.SetTextContent(followBtn, "followed")
6801 dom.SetStyle(followBtn, "background", "var(--bg2)")
6802 dom.SetStyle(followBtn, "color", "var(--fg)")
6803 dom.SetStyle(followBtn, "border", "1px solid var(--border)")
6804 } else {
6805 dom.SetTextContent(followBtn, "follow")
6806 dom.SetStyle(followBtn, "background", "var(--accent)")
6807 dom.SetStyle(followBtn, "color", "#fff")
6808 dom.SetStyle(followBtn, "border", "none")
6809 }
6810 dom.AppendChild(btnRow, followBtn)
6811
6812
6813 // Mute button.
6814 muteBtn := dom.CreateElement("button")
6815 dom.SetStyle(muteBtn, "padding", "6px 16px")
6816 dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
6817 dom.SetStyle(muteBtn, "fontSize", "12px")
6818 dom.SetStyle(muteBtn, "borderRadius", "4px")
6819 dom.SetStyle(muteBtn, "cursor", "pointer")
6820 if isMuted(pk) {
6821 dom.SetTextContent(muteBtn, "muted")
6822 dom.SetStyle(muteBtn, "background", "#c33")
6823 dom.SetStyle(muteBtn, "color", "#fff")
6824 dom.SetStyle(muteBtn, "border", "none")
6825 } else {
6826 dom.SetTextContent(muteBtn, "mute")
6827 dom.SetStyle(muteBtn, "background", "var(--bg2)")
6828 dom.SetStyle(muteBtn, "color", "var(--fg)")
6829 dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
6830 }
6831
6832 // Wire up click handlers with cross-references.
6833 btnPK := pk
6834 cFollow := followBtn
6835 cMute := muteBtn
6836 dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
6837 toggleFollow(btnPK, cFollow, cMute)
6838 }))
6839 dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
6840 toggleMute(btnPK, cMute, cFollow)
6841 }))
6842 dom.AppendChild(btnRow, muteBtn)
6843
6844 dom.AppendChild(card, btnRow)
6845 } else {
6846 // Own profile - edit button.
6847 editRow := dom.CreateElement("div")
6848 dom.SetStyle(editRow, "marginTop", "12px")
6849
6850 editBtn := dom.CreateElement("button")
6851 dom.SetTextContent(editBtn, t("edit_profile"))
6852 dom.SetStyle(editBtn, "padding", "6px 16px")
6853 dom.SetStyle(editBtn, "fontFamily", "'Fira Code', monospace")
6854 dom.SetStyle(editBtn, "fontSize", "12px")
6855 dom.SetStyle(editBtn, "background", "var(--bg2)")
6856 dom.SetStyle(editBtn, "color", "var(--fg)")
6857 dom.SetStyle(editBtn, "border", "1px solid var(--border)")
6858 dom.SetStyle(editBtn, "borderRadius", "4px")
6859 dom.SetStyle(editBtn, "cursor", "pointer")
6860 dom.AddEventListener(editBtn, "click", dom.RegisterCallback(func() {
6861 showEditProfileModal()
6862 }))
6863 dom.AppendChild(editRow, editBtn)
6864 dom.AppendChild(card, editRow)
6865 }
6866
6867 dom.AppendChild(wrapper, card)
6868
6869 // About/bio - same height-cap + show-more pattern as feed notes.
6870 if about != "" {
6871 aboutEl := dom.CreateElement("div")
6872 dom.SetStyle(aboutEl, "padding", "12px 16px")
6873 dom.SetStyle(aboutEl, "fontSize", "14px")
6874 dom.SetStyle(aboutEl, "lineHeight", "1.5")
6875 dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6876 dom.SetStyle(aboutEl, "wordBreak", "break-word")
6877 dom.SetStyle(aboutEl, "overflow", "hidden")
6878 dom.SetStyle(aboutEl, "maxHeight", "18em")
6879 dom.AppendChild(wrapper, aboutEl)
6880 setHTMLWithMedia(aboutEl, renderMarkdown(about))
6881 more := makeShowMore(aboutEl)
6882 dom.SetStyle(more, "padding", "0 16px 8px")
6883 dom.AppendChild(wrapper, more)
6884 attachShowMore(aboutEl, more)
6885 }
6886
6887 // Tab bar.
6888 tabBar := dom.CreateElement("div")
6889 dom.SetStyle(tabBar, "display", "flex")
6890 dom.SetStyle(tabBar, "gap", "0")
6891 dom.SetStyle(tabBar, "margin", "8px 16px 0")
6892 dom.SetStyle(tabBar, "border", "1px solid var(--border)")
6893 dom.SetStyle(tabBar, "borderRadius", "6px")
6894 dom.SetStyle(tabBar, "overflow", "hidden")
6895 dom.SetStyle(tabBar, "width", "fit-content")
6896
6897 profileTabBtns = map[string]dom.Element{}
6898
6899 // Unrolled - tinyjs range/loop closure aliasing.
6900 tabNotes := makeProtoBtn(t("notes"))
6901 dom.SetStyle(tabNotes, "cursor", "pointer")
6902 profileTabBtns["notes"] = tabNotes
6903 tabNotesPK := pk
6904 dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() {
6905 selectProfileTab("notes", tabNotesPK)
6906 }))
6907 dom.AppendChild(tabBar, tabNotes)
6908
6909 tabFollows := makeProtoBtn(t("follows"))
6910 dom.SetStyle(tabFollows, "cursor", "pointer")
6911 profileTabBtns["follows"] = tabFollows
6912 tabFollowsPK := pk
6913 dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() {
6914 selectProfileTab("follows", tabFollowsPK)
6915 }))
6916 dom.AppendChild(tabBar, tabFollows)
6917
6918 tabRelays := makeProtoBtn(t("relays"))
6919 dom.SetStyle(tabRelays, "cursor", "pointer")
6920 profileTabBtns["relays"] = tabRelays
6921 tabRelaysPK := pk
6922 dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() {
6923 selectProfileTab("relays", tabRelaysPK)
6924 }))
6925 dom.AppendChild(tabBar, tabRelays)
6926
6927 tabMutes := makeProtoBtn(t("mutes"))
6928 dom.SetStyle(tabMutes, "cursor", "pointer")
6929 profileTabBtns["mutes"] = tabMutes
6930 tabMutesPK := pk
6931 dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() {
6932 selectProfileTab("mutes", tabMutesPK)
6933 }))
6934 dom.AppendChild(tabBar, tabMutes)
6935
6936 dom.AppendChild(wrapper, tabBar)
6937
6938 // Tab content container.
6939 profileTabContent = dom.CreateElement("div")
6940 dom.SetStyle(profileTabContent, "padding", "8px 0")
6941 dom.AppendChild(wrapper, profileTabContent)
6942
6943 // Restore or default tab.
6944 profileTab = ""
6945 if savedTab != "" {
6946 selectProfileTab(savedTab, pk)
6947 } else {
6948 selectProfileTab("notes", pk)
6949 }
6950
6951 // Save cache refs for this profile.
6952 profileWrapTabCont[pk] = profileTabContent
6953 profileWrapTabs[pk] = profileTabBtns
6954 profileWrapTabSel[pk] = profileTab
6955
6956 _ = name // title shown via topBackBtn, not pageTitleEl
6957 }
6958
6959 func toggleFollow(pk string, btn dom.Element, muteBtn dom.Element) {
6960 if !signer.HasSigner() || pubhex == "" {
6961 return
6962 }
6963 found := false
6964 var newFollows []string
6965 for _, fpk := range myFollows {
6966 if fpk == pk {
6967 found = true
6968 continue
6969 }
6970 newFollows = append(newFollows, fpk)
6971 }
6972 if !found {
6973 newFollows = append(newFollows, pk)
6974 }
6975 myFollows = newFollows
6976 followSet = buildSet(newFollows)
6977
6978 if found {
6979 dom.SetTextContent(btn, "follow")
6980 dom.SetStyle(btn, "background", "var(--accent)")
6981 dom.SetStyle(btn, "color", "#fff")
6982 dom.SetStyle(btn, "border", "none")
6983 } else {
6984 dom.SetTextContent(btn, "followed")
6985 dom.SetStyle(btn, "background", "var(--bg2)")
6986 dom.SetStyle(btn, "color", "var(--fg)")
6987 dom.SetStyle(btn, "border", "1px solid var(--border)")
6988 // Following unmutes.
6989 if isMuted(pk) {
6990 unmute(pk, muteBtn)
6991 }
6992 }
6993
6994 // Bump contactTs so stale kind 3 events from slow relays are rejected.
6995 contactTs = dom.NowSeconds()
6996 publishFollowList(newFollows)
6997 if feedSelectEl != 0 {
6998 populateFeedSelect()
6999 }
7000 if feedMode == "follows" {
7001 refreshFeed()
7002 }
7003 }
7004
7005 func publishFollowList(follows []string) {
7006 tags := "["
7007 for i, pk := range follows {
7008 if i > 0 {
7009 tags = tags | ","
7010 }
7011 tags = tags | "[\"p\"," | jstr(pk) | "]"
7012 }
7013 tags = tags | "]"
7014 ts := dom.NowSeconds()
7015 unsigned := "{\"kind\":3,\"content\":\"\"" |
7016 ",\"tags\":" | tags |
7017 ",\"created_at\":" | i64toa(ts) |
7018 ",\"pubkey\":\"" | pubhex | "\"}"
7019 logActivity("follow", "publishing kind 3, " | itoa(len(follows)) | " follows", "calling signer.SignEvent")
7020 signer.SignEvent(unsigned, func(signed string) {
7021 if signed == "" {
7022 logActivity("follow", "FAILED: signer returned empty", "vault locked or signer crashed")
7023 return
7024 }
7025 targets := []string{:len(relayURLs)}
7026 copy(targets, relayURLs)
7027 for _, d := range discoveryRelays {
7028 targets = appendUnique(targets, d)
7029 }
7030 msg := "[\"PUBLISH_TO\"," | signed | ",["
7031 relayList := ""
7032 for i, u := range targets {
7033 if i > 0 {
7034 msg = msg | ","
7035 relayList = relayList | "\n"
7036 }
7037 msg = msg | jstr(u)
7038 relayList = relayList | u
7039 }
7040 routeMsg(msg | "]]")
7041 logActivity("follow", "kind 3 sent to " | itoa(len(targets)) | " relays", "relays:\n" | relayList | "\n\nevent:\n" | signed)
7042 })
7043 }
7044
7045 func isMuted(pk string) (ok bool) { return muteSet[pk] }
7046
7047 func buildSet(pks []string) (m map[string]bool) {
7048 s := map[string]bool{}
7049 for _, pk := range pks {
7050 s[pk] = true
7051 }
7052 return s
7053 }
7054
7055 // broadcastFollows pushes the current follow list to Feed and Notif workers.
7056 // P_FOLLOWS is outbound from Profile Worker to Shell only; never send it inbound.
7057 func broadcastFollows(pks []string) {
7058 j := buildJSONStrArr(pks)
7059 feed.Send(`["F_SET_FOLLOWS",` | j | `]`)
7060 notif.Send(`["N_SET_FOLLOWS",` | j | `]`)
7061 }
7062
7063 // broadcastMutes pushes the current mute list to Feed and Notif workers.
7064 func broadcastMutes(pks []string) {
7065 j := buildJSONStrArr(pks)
7066 feed.Send(`["F_SET_MUTES",` | j | `]`)
7067 notif.Send(`["N_SET_MUTES",` | j | `]`)
7068 }
7069
7070 // buildJSONStrArr builds a JSON array of quoted strings.
7071 func buildJSONStrArr(pks []string) (s string) {
7072 s = "["
7073 for i, pk := range pks {
7074 if i > 0 { s = s | "," }
7075 s = s | jstr(pk)
7076 }
7077 return s | "]"
7078 }
7079
7080 // buildURLsJSON builds a JSON array of quoted URL strings.
7081 func buildURLsJSON(urls []string) (s string) {
7082 return buildJSONStrArr(urls)
7083 }
7084
7085 // repliesToMuted returns true if ev is a reply whose parent "p" tag is a muted user.
7086 func repliesToMuted(ev *nostr.Event) (ok bool) {
7087 if ev.Kind != 1 {
7088 return false
7089 }
7090 hasE := false
7091 for _, tag := range ev.Tags {
7092 if len(tag) >= 2 && tag[0] == "e" {
7093 hasE = true
7094 break
7095 }
7096 }
7097 if !hasE {
7098 return false // not a reply
7099 }
7100 for _, tag := range ev.Tags {
7101 if len(tag) >= 2 && tag[0] == "p" && isMuted(tag[1]) {
7102 return true
7103 }
7104 }
7105 return false
7106 }
7107
7108 func toggleMute(pk string, btn dom.Element, followBtn dom.Element) {
7109 if !signer.HasSigner() || pubhex == "" {
7110 return
7111 }
7112 found := false
7113 var newMutes []string
7114 for _, m := range myMutes {
7115 if m == pk {
7116 found = true
7117 continue
7118 }
7119 newMutes = append(newMutes, m)
7120 }
7121 if !found {
7122 newMutes = append(newMutes, pk)
7123 }
7124 myMutes = newMutes
7125 muteSet = buildSet(newMutes)
7126
7127 if found {
7128 dom.SetTextContent(btn, "mute")
7129 dom.SetStyle(btn, "background", "var(--bg2)")
7130 dom.SetStyle(btn, "color", "var(--fg)")
7131 dom.SetStyle(btn, "border", "1px solid var(--border)")
7132 // Re-fetch profile notes now that user is unmuted.
7133 if profileViewPK == pk && profileTab == "notes" && profileTabContent != 0 {
7134 clearChildren(profileTabContent)
7135 renderProfileNotes(pk)
7136 }
7137 } else {
7138 dom.SetTextContent(btn, "muted")
7139 dom.SetStyle(btn, "background", "#c33")
7140 dom.SetStyle(btn, "color", "#fff")
7141 dom.SetStyle(btn, "border", "none")
7142 // Muting unfollows.
7143 if isFollowing(pk) {
7144 unfollow(pk, followBtn)
7145 }
7146 removeMutedNotes(pk)
7147 }
7148
7149 muteTs = dom.NowSeconds()
7150 publishMuteList(newMutes)
7151 }
7152
7153 func unmute(pk string, btn dom.Element) {
7154 var newMutes []string
7155 for _, m := range myMutes {
7156 if m != pk {
7157 newMutes = append(newMutes, m)
7158 }
7159 }
7160 myMutes = newMutes
7161 muteSet = buildSet(newMutes)
7162 dom.SetTextContent(btn, "mute")
7163 dom.SetStyle(btn, "background", "var(--bg2)")
7164 dom.SetStyle(btn, "color", "var(--fg)")
7165 dom.SetStyle(btn, "border", "1px solid var(--border)")
7166 muteTs = dom.NowSeconds()
7167 publishMuteList(newMutes)
7168 }
7169
7170 func isFollowing(pk string) (ok bool) { return followSet[pk] }
7171
7172 func unfollow(pk string, btn dom.Element) {
7173 var newFollows []string
7174 for _, fpk := range myFollows {
7175 if fpk != pk {
7176 newFollows = append(newFollows, fpk)
7177 }
7178 }
7179 myFollows = newFollows
7180 followSet = buildSet(newFollows)
7181 dom.SetTextContent(btn, "follow")
7182 dom.SetStyle(btn, "background", "var(--accent)")
7183 dom.SetStyle(btn, "color", "#fff")
7184 dom.SetStyle(btn, "border", "none")
7185 contactTs = dom.NowSeconds()
7186 publishFollowList(newFollows)
7187 }
7188
7189 func removeMutedNotes(pk string) {
7190 // Remove from main feed DOM.
7191 removeChildrenByPK(feedContainer, pk)
7192 // Clear profile notes tab if viewing the muted user.
7193 if profileViewPK == pk && profileTab == "notes" && profileTabContent != 0 {
7194 closeProfileNoteSub()
7195 clearChildren(profileTabContent)
7196 }
7197 // Purge from feed buffer so muted notes don't reappear on refresh.
7198 if len(feedBuffer) > 0 {
7199 var kept []*nostr.Event
7200 for _, ev := range feedBuffer {
7201 if ev.PubKey != pk {
7202 kept = append(kept, ev)
7203 }
7204 }
7205 feedBuffer = kept
7206 }
7207 // Purge from thread cache if a thread is open.
7208 if threadOpen {
7209 for id, ev := range threadEvents {
7210 if ev.PubKey == pk {
7211 delete(threadEvents, id)
7212 }
7213 }
7214 threadLastRendered = 0
7215 renderThreadTree()
7216 }
7217 }
7218
7219 func publishMuteList(mutes []string) {
7220 tags := "["
7221 for i, pk := range mutes {
7222 if i > 0 {
7223 tags = tags | ","
7224 }
7225 tags = tags | "[\"p\"," | jstr(pk) | "]"
7226 }
7227 tags = tags | "]"
7228 ts := dom.NowSeconds()
7229 unsigned := "{\"kind\":10000,\"content\":\"\"" |
7230 ",\"tags\":" | tags |
7231 ",\"created_at\":" | i64toa(ts) |
7232 ",\"pubkey\":\"" | pubhex | "\"}"
7233 dom.ConsoleLog("[mute] signing kind 10000 with " | itoa(len(mutes)) | " entries")
7234 signer.SignEvent(unsigned, func(signed string) {
7235 if signed == "" {
7236 dom.ConsoleLog("[mute] sign failed - signer returned empty")
7237 return
7238 }
7239 dom.ConsoleLog("[mute] publishing kind 10000")
7240 selected := composeSelectedRelays()
7241 if len(selected) > 0 {
7242 msg := "[\"PUBLISH_TO\"," | signed | ",["
7243 for i, u := range selected {
7244 if i > 0 {
7245 msg = msg | ","
7246 }
7247 msg = msg | jstr(u)
7248 }
7249 routeMsg(msg | "]]")
7250 } else {
7251 routeMsg("[\"EVENT\"," | signed | "]")
7252 }
7253 })
7254 }
7255
7256 func profileMetaRow(icon, text, link string) (e dom.Element) {
7257 row := dom.CreateElement("div")
7258 dom.SetStyle(row, "padding", "4px 0")
7259 dom.SetStyle(row, "display", "flex")
7260 dom.SetStyle(row, "alignItems", "center")
7261 dom.SetStyle(row, "gap", "8px")
7262
7263 iconEl := dom.CreateElement("span")
7264 dom.SetTextContent(iconEl, icon)
7265 dom.AppendChild(row, iconEl)
7266
7267 if link != "" {
7268 href := link
7269 if strIndex(href, "://") < 0 {
7270 href = "https://" | href
7271 }
7272 a := dom.CreateElement("a")
7273 dom.SetAttribute(a, "href", href)
7274 dom.SetAttribute(a, "target", "_blank")
7275 dom.SetAttribute(a, "rel", "noopener")
7276 dom.SetStyle(a, "color", "var(--accent)")
7277 dom.SetStyle(a, "wordBreak", "break-all")
7278 dom.SetTextContent(a, text)
7279 dom.AppendChild(row, a)
7280 } else {
7281 span := dom.CreateElement("span")
7282 dom.SetStyle(span, "color", "var(--fg)")
7283 dom.SetTextContent(span, text)
7284 dom.AppendChild(row, span)
7285 }
7286 return row
7287 }
7288
7289 // --- Profile tab functions ---
7290
7291 func closeProfileNoteSub() {
7292 if activeProfileNoteSub != "" {
7293 routeMsg("[\"CLOSE\"," | jstr(activeProfileNoteSub) | "]")
7294 activeProfileNoteSub = ""
7295 }
7296 }
7297
7298 // refreshProfileTab re-renders the active tab if we're viewing this author's profile.
7299 func refreshProfileTab(pk string) {
7300 if profileViewPK != pk || profileTab == "" {
7301 return
7302 }
7303 // Force re-render by clearing current tab and re-selecting.
7304 saved := profileTab
7305 profileTab = ""
7306 selectProfileTab(saved, pk)
7307 }
7308
7309 func selectProfileTab(tab, pk string) {
7310 if tab == profileTab {
7311 return
7312 }
7313 closeProfileNoteSub()
7314 profileTab = tab
7315 clearChildren(profileTabContent)
7316
7317 for id, btn := range profileTabBtns {
7318 if id == tab {
7319 dom.SetStyle(btn, "background", "var(--accent)")
7320 dom.SetStyle(btn, "color", "#fff")
7321 } else {
7322 dom.SetStyle(btn, "background", "transparent")
7323 dom.SetStyle(btn, "color", "var(--fg)")
7324 }
7325 }
7326
7327 // Update URL hash to reflect active tab.
7328 if !navPop && profileViewPK != "" {
7329 npub := helpers.EncodeNpub(helpers.HexDecode(profileViewPK))
7330 dom.ReplaceState("/p/" | npub | "#" | tab)
7331 }
7332
7333 switch tab {
7334 case "notes":
7335 renderProfileNotes(pk)
7336 case "follows":
7337 renderProfileFollows(pk)
7338 case "relays":
7339 renderProfileRelays(pk)
7340 case "mutes":
7341 renderProfileMutes(pk)
7342 }
7343 }
7344
7345 func renderProfileNotes(pk string) {
7346 profileNotesSeen = map[string]bool{}
7347 profileSubCounter++
7348 subID := "pn-" | itoa(profileSubCounter)
7349 activeProfileNoteSub = subID
7350 proxyRelays := buildProxy(pk)
7351 routeMsg(buildProxyMsg(subID,
7352 "{\"authors\":[" | jstr(pk) | "],\"kinds\":[1],\"limit\":20}",
7353 proxyRelays))
7354 }
7355
7356 func renderProfileNote(ev *nostr.Event) {
7357 if profileTabContent == 0 || profileTab != "notes" {
7358 return
7359 }
7360 if isMuted(ev.PubKey) {
7361 return
7362 }
7363 note := dom.CreateElement("div")
7364 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
7365 dom.SetStyle(note, "padding", "12px 16px")
7366
7367 // Header row: author link (left) + relay widget + timestamp (right).
7368 header := dom.CreateElement("div")
7369 dom.SetStyle(header, "display", "flex")
7370 dom.SetStyle(header, "alignItems", "center")
7371 dom.SetStyle(header, "marginBottom", "4px")
7372 dom.SetStyle(header, "maxWidth", "65ch")
7373
7374 authorLink := dom.CreateElement("div")
7375 dom.SetStyle(authorLink, "display", "flex")
7376 dom.SetStyle(authorLink, "alignItems", "center")
7377 dom.SetStyle(authorLink, "gap", "8px")
7378 dom.SetStyle(authorLink, "cursor", "pointer")
7379 headerPK := ev.PubKey
7380 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
7381 showProfile(headerPK)
7382 }))
7383
7384 avatar := dom.CreateElement("img")
7385 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
7386 dom.SetAttribute(avatar, "width", "24")
7387 dom.SetAttribute(avatar, "height", "24")
7388 dom.SetStyle(avatar, "borderRadius", "50%")
7389 dom.SetStyle(avatar, "objectFit", "cover")
7390 dom.SetStyle(avatar, "flexShrink", "0")
7391
7392 nameSpan := dom.CreateElement("span")
7393 dom.SetStyle(nameSpan, "fontSize", "18px")
7394 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7395 dom.SetStyle(nameSpan, "fontWeight", "bold")
7396 dom.SetStyle(nameSpan, "color", "var(--fg)")
7397
7398 pk := ev.PubKey
7399 if pic, ok := authorPics[pk]; ok && pic != "" {
7400 setMediaSrc(avatar, pic)
7401 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
7402 } else {
7403 dom.SetStyle(avatar, "display", "none")
7404 }
7405 if name, ok := authorNames[pk]; ok && name != "" {
7406 dom.SetTextContent(nameSpan, name)
7407 } else {
7408 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7409 if len(npub) > 20 {
7410 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
7411 }
7412 }
7413
7414 dom.AppendChild(authorLink, avatar)
7415 dom.AppendChild(authorLink, nameSpan)
7416 dom.AppendChild(header, authorLink)
7417
7418 appendClientTag(header, ev)
7419
7420 rw := relayWidget(ev.ID)
7421 dom.SetStyle(rw, "marginLeft", "auto")
7422 dom.AppendChild(header, rw)
7423 if ev.CreatedAt > 0 {
7424 tsEl := dom.CreateElement("span")
7425 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
7426 dom.SetStyle(tsEl, "fontSize", "11px")
7427 dom.SetStyle(tsEl, "color", "var(--muted)")
7428 dom.SetStyle(tsEl, "cursor", "pointer")
7429 dom.SetStyle(tsEl, "flexShrink", "0")
7430 evID := ev.ID
7431 evRootID := getRootID(ev)
7432 if evRootID == "" {
7433 evRootID = evID
7434 }
7435 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
7436 showNoteThread(evRootID, evID)
7437 }))
7438 dom.AppendChild(header, tsEl)
7439 }
7440 dom.AppendChild(note, header)
7441
7442 // Track author link for update when profile arrives.
7443 if _, cached := authorNames[pk]; !cached {
7444 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
7445 if !lookupFetchedK0(pk) {
7446 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
7447 }
7448 }
7449
7450 // Reply preview.
7451 addReplyPreview(note, ev)
7452
7453 content := dom.CreateElement("div")
7454 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7455 dom.SetStyle(content, "fontSize", "14px")
7456 dom.SetStyle(content, "lineHeight", "1.5")
7457 dom.SetStyle(content, "wordBreak", "break-word")
7458 dom.SetStyle(content, "maxWidth", "65ch")
7459 dom.SetStyle(content, "overflow", "hidden")
7460 dom.SetStyle(content, "maxHeight", "18em")
7461 setHTMLWithMedia(content, renderMarkdown(ev.Content))
7462 dom.AppendChild(note, content)
7463
7464 more := makeShowMore(content)
7465 dom.AppendChild(note, buildActionRow(ev, note, content, more))
7466
7467 dom.AppendChild(profileTabContent, note)
7468 attachShowMore(content, more)
7469 resolveEmbeds()
7470 }
7471
7472 func renderProfileFollows(pk string) {
7473 var follows []string
7474 if pk == pubhex {
7475 follows = myFollows
7476 } else {
7477 follows = profileFollowsCache[pk]
7478 }
7479 if len(follows) == 0 {
7480 empty := dom.CreateElement("div")
7481 dom.SetTextContent(empty, t("no_follows"))
7482 dom.SetStyle(empty, "padding", "16px")
7483 dom.SetStyle(empty, "color", "var(--muted)")
7484 dom.SetStyle(empty, "fontSize", "13px")
7485 dom.AppendChild(profileTabContent, empty)
7486 return
7487 }
7488
7489 countEl := dom.CreateElement("div")
7490 dom.SetTextContent(countEl, itoa(len(follows)) | " " | t("following"))
7491 dom.SetStyle(countEl, "padding", "8px 16px")
7492 dom.SetStyle(countEl, "color", "var(--muted)")
7493 dom.SetStyle(countEl, "fontSize", "12px")
7494 dom.AppendChild(profileTabContent, countEl)
7495
7496 for i := 0; i < len(follows); i++ {
7497 fpk := follows[i]
7498 row := makeProfileRow(fpk)
7499 dom.AppendChild(profileTabContent, row)
7500 }
7501 scheduleTabRetry()
7502 }
7503
7504 func renderProfileRelays(pk string) {
7505 relays := profileRelaysCache[pk]
7506 if len(relays) == 0 {
7507 empty := dom.CreateElement("div")
7508 dom.SetTextContent(empty, t("no_relays"))
7509 dom.SetStyle(empty, "padding", "16px")
7510 dom.SetStyle(empty, "color", "var(--muted)")
7511 dom.SetStyle(empty, "fontSize", "13px")
7512 dom.AppendChild(profileTabContent, empty)
7513 return
7514 }
7515
7516 for i := 0; i < len(relays); i++ {
7517 rURL := relays[i]
7518 row := dom.CreateElement("div")
7519 dom.SetStyle(row, "padding", "10px 16px")
7520 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
7521 dom.SetStyle(row, "cursor", "pointer")
7522 dom.SetStyle(row, "fontSize", "13px")
7523
7524 urlEl := dom.CreateElement("span")
7525 dom.SetTextContent(urlEl, rURL)
7526 dom.SetStyle(urlEl, "color", "var(--accent)")
7527 dom.AppendChild(row, urlEl)
7528
7529 clickURL := rURL
7530 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
7531 showRelayInfo(clickURL)
7532 }))
7533 dom.AppendChild(profileTabContent, row)
7534 }
7535 }
7536
7537 func renderProfileMutes(pk string) {
7538 var mutes []string
7539 if pk == pubhex {
7540 mutes = myMutes
7541 } else {
7542 mutes = profileMutesCache[pk]
7543 }
7544 if len(mutes) == 0 {
7545 empty := dom.CreateElement("div")
7546 dom.SetTextContent(empty, t("no_mutes"))
7547 dom.SetStyle(empty, "padding", "16px")
7548 dom.SetStyle(empty, "color", "var(--muted)")
7549 dom.SetStyle(empty, "fontSize", "13px")
7550 dom.AppendChild(profileTabContent, empty)
7551 return
7552 }
7553
7554 countEl := dom.CreateElement("div")
7555 dom.SetTextContent(countEl, itoa(len(mutes)) | " " | t("muted"))
7556 dom.SetStyle(countEl, "padding", "8px 16px")
7557 dom.SetStyle(countEl, "color", "var(--muted)")
7558 dom.SetStyle(countEl, "fontSize", "12px")
7559 dom.AppendChild(profileTabContent, countEl)
7560
7561 for i := 0; i < len(mutes); i++ {
7562 mpk := mutes[i]
7563 row := makeProfileRow(mpk)
7564 dom.AppendChild(profileTabContent, row)
7565 }
7566 scheduleTabRetry()
7567 }
7568
7569 func makeProfileRow(pk string) (e dom.Element) {
7570 row := dom.CreateElement("div")
7571 dom.SetStyle(row, "display", "flex")
7572 dom.SetStyle(row, "alignItems", "center")
7573 dom.SetStyle(row, "gap", "10px")
7574 dom.SetStyle(row, "padding", "10px 16px")
7575 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
7576 dom.SetStyle(row, "cursor", "pointer")
7577
7578 av := dom.CreateElement("img")
7579 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
7580 dom.SetAttribute(av, "width", "32")
7581 dom.SetAttribute(av, "height", "32")
7582 dom.SetStyle(av, "borderRadius", "50%")
7583 dom.SetStyle(av, "objectFit", "cover")
7584 dom.SetStyle(av, "flexShrink", "0")
7585 if pic, ok := authorPics[pk]; ok && pic != "" {
7586 setMediaSrc(av, pic)
7587 } else {
7588 dom.SetStyle(av, "display", "none")
7589 }
7590 dom.SetAttribute(av, "onerror", "this.style.display='none'")
7591 dom.AppendChild(row, av)
7592
7593 nameSpan := dom.CreateElement("span")
7594 dom.SetStyle(nameSpan, "fontSize", "14px")
7595 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7596 if name, ok := authorNames[pk]; ok && name != "" {
7597 dom.SetTextContent(nameSpan, name)
7598 } else {
7599 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7600 if len(npub) > 20 {
7601 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
7602 }
7603 }
7604 dom.AppendChild(row, nameSpan)
7605
7606 rowPK := pk
7607 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
7608 showProfile(rowPK)
7609 }))
7610
7611 if _, cached := authorNames[pk]; !cached {
7612 pendingNotes[pk] = append(pendingNotes[pk], row)
7613 if !lookupFetchedK0(pk) {
7614 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
7615 }
7616 }
7617
7618 return row
7619 }
7620
7621 // --- Relay info page ---
7622
7623 func showRelayInfo(url string) {
7624 profileViewPK = ""
7625 closeProfileNoteSub()
7626 // Hide cached profile wrappers but keep them.
7627 for _, w := range profileWrappers {
7628 dom.SetStyle(w, "display", "none")
7629 }
7630 // Remove old relay info page if any.
7631 if relayInfoEl != 0 {
7632 dom.RemoveChild(profilePage, relayInfoEl)
7633 relayInfoEl = 0
7634 }
7635 relayInfoEl = dom.CreateElement("div")
7636 dom.AppendChild(profilePage, relayInfoEl)
7637
7638 hdr := dom.CreateElement("div")
7639 dom.SetStyle(hdr, "display", "flex")
7640 dom.SetStyle(hdr, "alignItems", "center")
7641 dom.SetStyle(hdr, "gap", "10px")
7642 dom.SetStyle(hdr, "padding", "16px")
7643 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
7644
7645 backBtn := dom.CreateElement("button")
7646 dom.SetInnerHTML(backBtn, "←")
7647 dom.SetStyle(backBtn, "background", "none")
7648 dom.SetStyle(backBtn, "border", "none")
7649 dom.SetStyle(backBtn, "fontSize", "20px")
7650 dom.SetStyle(backBtn, "cursor", "pointer")
7651 dom.SetStyle(backBtn, "color", "var(--fg)")
7652 dom.SetStyle(backBtn, "padding", "0")
7653 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
7654 switchPage("feed")
7655 }))
7656 dom.AppendChild(hdr, backBtn)
7657
7658 urlEl := dom.CreateElement("span")
7659 dom.SetTextContent(urlEl, url)
7660 dom.SetStyle(urlEl, "fontWeight", "bold")
7661 dom.SetStyle(urlEl, "fontSize", "14px")
7662 dom.SetStyle(urlEl, "wordBreak", "break-all")
7663 dom.AppendChild(hdr, urlEl)
7664 dom.AppendChild(relayInfoEl, hdr)
7665
7666 loading := dom.CreateElement("div")
7667 dom.SetTextContent(loading, t("loading"))
7668 dom.SetStyle(loading, "padding", "16px")
7669 dom.SetStyle(loading, "color", "var(--muted)")
7670 dom.AppendChild(relayInfoEl, loading)
7671
7672 activePage = ""
7673 switchPage("profile")
7674 dom.SetTextContent(pageTitleEl, t("relay_info"))
7675
7676 // Convert wss→https for NIP-11 HTTP fetch.
7677 httpURL := url
7678 if len(httpURL) > 6 && httpURL[:6] == "wss://" {
7679 httpURL = "https://" | httpURL[6:]
7680 } else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
7681 httpURL = "http://" | httpURL[5:]
7682 }
7683
7684 dom.FetchRelayInfo(httpURL, func(body string) {
7685 dom.RemoveChild(profilePage, loading)
7686 if body == "" {
7687 errEl := dom.CreateElement("div")
7688 dom.SetTextContent(errEl, t("relay_fail"))
7689 dom.SetStyle(errEl, "padding", "16px")
7690 dom.SetStyle(errEl, "color", "#e55")
7691 dom.AppendChild(relayInfoEl, errEl)
7692 return
7693 }
7694 renderRelayInfoBody(body)
7695 })
7696 }
7697
7698 func renderRelayInfoBody(body string) {
7699 container := dom.CreateElement("div")
7700 dom.SetStyle(container, "padding", "16px")
7701
7702 name := helpers.JsonGetString(body, "name")
7703 desc := helpers.JsonGetString(body, "description")
7704 pk := helpers.JsonGetString(body, "pubkey")
7705 contact := helpers.JsonGetString(body, "contact")
7706 software := helpers.JsonGetString(body, "software")
7707 ver := helpers.JsonGetString(body, "version")
7708
7709 if name != "" {
7710 el := dom.CreateElement("div")
7711 dom.SetTextContent(el, name)
7712 dom.SetStyle(el, "fontSize", "20px")
7713 dom.SetStyle(el, "fontWeight", "bold")
7714 dom.SetStyle(el, "marginBottom", "8px")
7715 dom.SetStyle(el, "fontFamily", "system-ui, sans-serif")
7716 dom.AppendChild(container, el)
7717 }
7718
7719 if desc != "" {
7720 el := dom.CreateElement("div")
7721 dom.SetStyle(el, "fontSize", "14px")
7722 dom.SetStyle(el, "lineHeight", "1.5")
7723 dom.SetStyle(el, "marginBottom", "12px")
7724 dom.SetStyle(el, "wordBreak", "break-word")
7725 dom.AppendChild(container, el)
7726 setHTMLWithMedia(el, renderMarkdown(desc))
7727 }
7728
7729 if contact != "" {
7730 dom.AppendChild(container, profileMetaRow("@", contact, ""))
7731 }
7732 if pk != "" {
7733 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7734 short := npub
7735 if len(short) > 20 {
7736 short = short[:16] | "..." | short[len(short)-8:]
7737 }
7738 dom.AppendChild(container, profileMetaRow("pk", short, ""))
7739 }
7740 if software != "" {
7741 label := software
7742 if ver != "" {
7743 label = label | " " | ver
7744 }
7745 dom.AppendChild(container, profileMetaRow("sw", label, ""))
7746 }
7747
7748 dom.AppendChild(relayInfoEl, container)
7749 }
7750
7751 // --- Messaging ---
7752
7753 func relayURLsJSON() (s string) {
7754 msg := "["
7755 for i, url := range relayURLs {
7756 if i > 0 {
7757 msg = msg | ","
7758 }
7759 msg = msg | jstr(url)
7760 }
7761 return msg | "]"
7762 }
7763
7764 func readRelayURLsJSON() (s string) {
7765 msg := "["
7766 first := true
7767 for i, url := range relayURLs {
7768 role := "both"
7769 if i < len(relayRole) {
7770 role = relayRole[i]
7771 }
7772 if role == "write" {
7773 continue
7774 }
7775 if !first {
7776 msg = msg | ","
7777 }
7778 msg = msg | jstr(url)
7779 first = false
7780 }
7781 return msg | "]"
7782 }
7783
7784 func formatTime(ts int64) (s string) {
7785 if ts == 0 {
7786 return ""
7787 }
7788 d := dom.NowSeconds() - ts
7789 if d < 0 {
7790 d = 0
7791 }
7792 if d < 60 {
7793 return "now"
7794 }
7795 if d < 3600 {
7796 return itoa(int32(d/60)) | "m ago"
7797 }
7798 if d < 86400 {
7799 return itoa(int32(d/3600)) | "h ago"
7800 }
7801 if d < 2592000 {
7802 return itoa(int32(d/86400)) | "d ago"
7803 }
7804 if d < 31536000 {
7805 return itoa(int32(d/2592000)) | "mo ago"
7806 }
7807 return itoa(int32(d/31536000)) | "y ago"
7808 }
7809
7810 func dismissHoverCard() {
7811 if hoverCardEl != 0 {
7812 dom.Remove(hoverCardEl)
7813 hoverCardEl = 0
7814 hoverCardParent = 0
7815 hoverCardPK = ""
7816 hoverCardHover = false
7817 }
7818 if hoverCardTimer != 0 {
7819 dom.ClearTimeout(hoverCardTimer)
7820 hoverCardTimer = 0
7821 }
7822 if hoverCardDismiss != 0 {
7823 dom.ClearTimeout(hoverCardDismiss)
7824 hoverCardDismiss = 0
7825 }
7826 }
7827
7828 func scheduleDismissHoverCard() {
7829 if hoverCardDismiss != 0 {
7830 dom.ClearTimeout(hoverCardDismiss)
7831 }
7832 hoverCardDismiss = dom.SetTimeout(func() {
7833 hoverCardDismiss = 0
7834 if !hoverCardHover {
7835 dismissHoverCard()
7836 }
7837 }, 300)
7838 }
7839
7840 func showHoverCard(pk string, anchor dom.Element) {
7841 if pk == pubhex {
7842 return // don't show hover card for self
7843 }
7844 dismissHoverCard()
7845 hoverCardPK = pk
7846
7847 card := dom.CreateElement("div")
7848 dom.SetStyle(card, "position", "fixed")
7849 dom.SetStyle(card, "zIndex", "2000")
7850 dom.SetStyle(card, "width", "280px")
7851 dom.SetStyle(card, "maxWidth", "calc(100vw - 16px)")
7852 dom.SetStyle(card, "background", "var(--bg)")
7853 dom.SetStyle(card, "border", "1px solid var(--border)")
7854 dom.SetStyle(card, "borderRadius", "8px")
7855 dom.SetStyle(card, "boxShadow", "0 4px 16px rgba(0,0,0,0.3)")
7856 dom.SetStyle(card, "overflow", "hidden")
7857 dom.SetStyle(card, "fontFamily", "'Fira Code', monospace")
7858
7859 // Banner.
7860 content := profileContentCache[pk]
7861 if content != "" {
7862 banner := helpers.JsonGetString(content, "banner")
7863 if banner != "" {
7864 bannerEl := dom.CreateElement("img")
7865 dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
7866 setMediaSrc(bannerEl, banner)
7867 dom.SetStyle(bannerEl, "width", "100%")
7868 dom.SetStyle(bannerEl, "height", "80px")
7869 dom.SetStyle(bannerEl, "objectFit", "cover")
7870 dom.SetStyle(bannerEl, "display", "block")
7871 dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
7872 dom.AppendChild(card, bannerEl)
7873 }
7874 }
7875
7876 // Avatar + name row.
7877 infoRow := dom.CreateElement("div")
7878 dom.SetStyle(infoRow, "display", "flex")
7879 dom.SetStyle(infoRow, "alignItems", "center")
7880 dom.SetStyle(infoRow, "gap", "8px")
7881 dom.SetStyle(infoRow, "padding", "8px 10px")
7882
7883 if pic, ok := authorPics[pk]; ok && pic != "" {
7884 av := dom.CreateElement("img")
7885 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
7886 setMediaSrc(av, pic)
7887 dom.SetAttribute(av, "width", "36")
7888 dom.SetAttribute(av, "height", "36")
7889 dom.SetStyle(av, "borderRadius", "50%")
7890 dom.SetStyle(av, "objectFit", "cover")
7891 dom.SetStyle(av, "flexShrink", "0")
7892 dom.SetAttribute(av, "onerror", "this.style.display='none'")
7893 dom.AppendChild(infoRow, av)
7894 }
7895
7896 nameEl := dom.CreateElement("span")
7897 dom.SetStyle(nameEl, "fontWeight", "bold")
7898 dom.SetStyle(nameEl, "fontSize", "13px")
7899 dom.SetStyle(nameEl, "color", "var(--fg)")
7900 if name, ok := authorNames[pk]; ok && name != "" {
7901 dom.SetTextContent(nameEl, name)
7902 } else {
7903 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7904 if len(npub) > 20 {
7905 dom.SetTextContent(nameEl, npub[:12] | "..." | npub[len(npub)-4:])
7906 }
7907 }
7908 dom.AppendChild(infoRow, nameEl)
7909 dom.AppendChild(card, infoRow)
7910
7911 // Button row: follow + mute.
7912 if signer.HasSigner() && pubhex != "" {
7913 btnRow := dom.CreateElement("div")
7914 dom.SetStyle(btnRow, "display", "flex")
7915 dom.SetStyle(btnRow, "gap", "6px")
7916 dom.SetStyle(btnRow, "padding", "0 10px 8px")
7917
7918 followBtn := dom.CreateElement("button")
7919 dom.SetStyle(followBtn, "padding", "4px 10px")
7920 dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
7921 dom.SetStyle(followBtn, "fontSize", "11px")
7922 dom.SetStyle(followBtn, "borderRadius", "4px")
7923 dom.SetStyle(followBtn, "cursor", "pointer")
7924 isFollowing := false
7925 if len(myFollows) > 0 {
7926 for _, fpk := range myFollows {
7927 if fpk == pk {
7928 isFollowing = true
7929 break
7930 }
7931 }
7932 }
7933 if isFollowing {
7934 dom.SetTextContent(followBtn, "followed")
7935 dom.SetStyle(followBtn, "background", "var(--bg2)")
7936 dom.SetStyle(followBtn, "color", "var(--fg)")
7937 dom.SetStyle(followBtn, "border", "1px solid var(--border)")
7938 } else {
7939 dom.SetTextContent(followBtn, "follow")
7940 dom.SetStyle(followBtn, "background", "var(--accent)")
7941 dom.SetStyle(followBtn, "color", "#fff")
7942 dom.SetStyle(followBtn, "border", "none")
7943 }
7944 dom.AppendChild(btnRow, followBtn)
7945
7946 muteBtn := dom.CreateElement("button")
7947 dom.SetStyle(muteBtn, "padding", "4px 10px")
7948 dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
7949 dom.SetStyle(muteBtn, "fontSize", "11px")
7950 dom.SetStyle(muteBtn, "borderRadius", "4px")
7951 dom.SetStyle(muteBtn, "cursor", "pointer")
7952 if isMuted(pk) {
7953 dom.SetTextContent(muteBtn, "muted")
7954 dom.SetStyle(muteBtn, "background", "#c33")
7955 dom.SetStyle(muteBtn, "color", "#fff")
7956 dom.SetStyle(muteBtn, "border", "none")
7957 } else {
7958 dom.SetTextContent(muteBtn, "mute")
7959 dom.SetStyle(muteBtn, "background", "var(--bg2)")
7960 dom.SetStyle(muteBtn, "color", "var(--fg)")
7961 dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
7962 }
7963
7964 hcPK := pk
7965 hcFollow := followBtn
7966 hcMute := muteBtn
7967 dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
7968 toggleFollow(hcPK, hcFollow, hcMute)
7969 }))
7970 dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
7971 toggleMute(hcPK, hcMute, hcFollow)
7972 }))
7973 dom.AppendChild(btnRow, muteBtn)
7974
7975 dom.AppendChild(card, btnRow)
7976 }
7977
7978 hoverCardHover = false
7979 dom.AddEventListener(card, "mouseenter", dom.RegisterCallback(func() {
7980 hoverCardHover = true
7981 if hoverCardDismiss != 0 {
7982 dom.ClearTimeout(hoverCardDismiss)
7983 hoverCardDismiss = 0
7984 }
7985 }))
7986 dom.AddEventListener(card, "mouseleave", dom.RegisterCallback(func() {
7987 hoverCardHover = false
7988 scheduleDismissHoverCard()
7989 }))
7990
7991 hoverCardEl = card
7992 hoverCardParent = dom.Body()
7993 dom.AppendChild(dom.Body(), card)
7994 dom.GetBoundingRect(anchor, func(left, top, right, bottom, width, height int32) {
7995 vw := dom.GetViewportWidth()
7996 cardW := 280
7997 if cardW > vw-16 {
7998 cardW = vw - 16
7999 }
8000 x := left
8001 if x+cardW > vw-8 {
8002 x = vw - 8 - cardW
8003 }
8004 if x < 8 {
8005 x = 8
8006 }
8007 dom.SetStyle(card, "left", itoa(x) | "px")
8008 dom.SetStyle(card, "top", itoa(bottom) | "px")
8009 })
8010 }
8011
8012 func attachHoverCard(authorLink dom.Element, pk string) {
8013 dom.SetStyle(authorLink, "position", "relative")
8014 hcPK := pk
8015 dom.AddEventListener(authorLink, "mouseenter", dom.RegisterCallback(func() {
8016 if hoverCardTimer != 0 {
8017 dom.ClearTimeout(hoverCardTimer)
8018 }
8019 if hoverCardDismiss != 0 {
8020 dom.ClearTimeout(hoverCardDismiss)
8021 hoverCardDismiss = 0
8022 }
8023 capPK := hcPK
8024 capAnchor := authorLink
8025 hoverCardTimer = dom.SetTimeout(func() {
8026 hoverCardTimer = 0
8027 showHoverCard(capPK, capAnchor)
8028 }, 400)
8029 }))
8030 dom.AddEventListener(authorLink, "mouseleave", dom.RegisterCallback(func() {
8031 if hoverCardTimer != 0 {
8032 dom.ClearTimeout(hoverCardTimer)
8033 hoverCardTimer = 0
8034 }
8035 scheduleDismissHoverCard()
8036 }))
8037 }
8038
8039 // relayWidget creates a hover tooltip showing which relays delivered a note.
8040 func relayWidget(evID string) (e dom.Element) {
8041 wrap := dom.CreateElement("span")
8042 dom.SetStyle(wrap, "position", "relative")
8043 dom.SetStyle(wrap, "flexShrink", "0")
8044 dom.SetStyle(wrap, "marginRight", "6px")
8045
8046 btn := dom.CreateElement("span")
8047 dom.SetTextContent(btn, "\xf0\x9f\x93\xa1") // 📡
8048 dom.SetStyle(btn, "fontSize", "10px")
8049 dom.SetStyle(btn, "cursor", "pointer")
8050 dom.SetStyle(btn, "opacity", "0.6")
8051 dom.AppendChild(wrap, btn)
8052
8053 var tip dom.Element
8054 capturedID := evID
8055
8056 dom.AddEventListener(wrap, "click", dom.RegisterCallback(func() {
8057 showRelayModal(capturedID)
8058 }))
8059
8060 dom.AddEventListener(wrap, "mouseenter", dom.RegisterCallback(func() {
8061 if tip != 0 {
8062 return
8063 }
8064 urls := eventRelays[capturedID]
8065
8066 tip = dom.CreateElement("div")
8067 dom.SetStyle(tip, "position", "absolute")
8068 dom.SetStyle(tip, "top", "22px")
8069 dom.SetStyle(tip, "right", "0")
8070 dom.SetStyle(tip, "zIndex", "1000")
8071 dom.SetStyle(tip, "background", "var(--bg)")
8072 dom.SetStyle(tip, "border", "1px solid var(--border)")
8073 dom.SetStyle(tip, "borderRadius", "6px")
8074 dom.SetStyle(tip, "padding", "6px 10px")
8075 dom.SetStyle(tip, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
8076 dom.SetStyle(tip, "fontSize", "11px")
8077 dom.SetStyle(tip, "whiteSpace", "nowrap")
8078 dom.SetStyle(tip, "fontFamily", "'Fira Code', monospace")
8079 dom.SetStyle(tip, "pointerEvents", "none")
8080
8081 if len(urls) == 0 {
8082 line := dom.CreateElement("div")
8083 dom.SetStyle(line, "color", "var(--muted)")
8084 dom.SetTextContent(line, "(no relay data)")
8085 dom.AppendChild(tip, line)
8086 } else {
8087 for _, u := range urls {
8088 line := dom.CreateElement("div")
8089 dom.SetStyle(line, "padding", "1px 0")
8090 dom.SetStyle(line, "color", "var(--fg)")
8091 dom.SetTextContent(line, u)
8092 dom.AppendChild(tip, line)
8093 }
8094 }
8095 dom.AppendChild(wrap, tip)
8096 }))
8097
8098 dom.AddEventListener(wrap, "mouseleave", dom.RegisterCallback(func() {
8099 if tip != 0 {
8100 dom.RemoveChild(wrap, tip)
8101 tip = 0
8102 }
8103 }))
8104
8105 return wrap
8106 }
8107
8108 func showRelayModal(evID string) {
8109 overlay := dom.CreateElement("div")
8110 dom.SetStyle(overlay, "position", "fixed")
8111 dom.SetStyle(overlay, "top", "0")
8112 dom.SetStyle(overlay, "left", "0")
8113 dom.SetStyle(overlay, "width", "100%")
8114 dom.SetStyle(overlay, "height", "100%")
8115 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
8116 dom.SetStyle(overlay, "zIndex", "500")
8117 dom.SetStyle(overlay, "display", "flex")
8118 dom.SetStyle(overlay, "alignItems", "center")
8119 dom.SetStyle(overlay, "justifyContent", "center")
8120
8121 modal := dom.CreateElement("div")
8122 dom.SetStyle(modal, "background", "var(--bg)")
8123 dom.SetStyle(modal, "border", "1px solid var(--border)")
8124 dom.SetStyle(modal, "borderRadius", "8px")
8125 dom.SetStyle(modal, "maxWidth", "90vw")
8126 dom.SetStyle(modal, "maxHeight", "70vh")
8127 dom.SetStyle(modal, "display", "flex")
8128 dom.SetStyle(modal, "flexDirection", "column")
8129 dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.3)")
8130
8131 // Header.
8132 hdr := dom.CreateElement("div")
8133 dom.SetStyle(hdr, "display", "flex")
8134 dom.SetStyle(hdr, "alignItems", "center")
8135 dom.SetStyle(hdr, "justifyContent", "space-between")
8136 dom.SetStyle(hdr, "padding", "12px 16px")
8137 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
8138
8139 title := dom.CreateElement("span")
8140 dom.SetTextContent(title, "note seen on:")
8141 dom.SetStyle(title, "fontWeight", "bold")
8142 dom.SetStyle(title, "fontSize", "14px")
8143 dom.AppendChild(hdr, title)
8144
8145 closeBtn := dom.CreateElement("button")
8146 dom.SetTextContent(closeBtn, "\xc3\x97") // ×
8147 dom.SetStyle(closeBtn, "background", "transparent")
8148 dom.SetStyle(closeBtn, "border", "none")
8149 dom.SetStyle(closeBtn, "fontSize", "20px")
8150 dom.SetStyle(closeBtn, "cursor", "pointer")
8151 dom.SetStyle(closeBtn, "color", "var(--muted)")
8152 dom.SetStyle(closeBtn, "padding", "0 4px")
8153 dom.AppendChild(hdr, closeBtn)
8154 dom.AppendChild(modal, hdr)
8155
8156 // Scrollable list - rebuilt by renderList() on open and on SEEN_ON updates.
8157 list := dom.CreateElement("div")
8158 dom.SetStyle(list, "overflowY", "auto")
8159 dom.SetStyle(list, "padding", "12px 16px")
8160 dom.AppendChild(modal, list)
8161
8162 renderList := func() {
8163 dom.ReleaseChildren(list)
8164 dom.SetInnerHTML(list, "")
8165 urls := eventRelays[evID]
8166 if len(urls) == 0 {
8167 line := dom.CreateElement("div")
8168 dom.SetStyle(line, "color", "var(--muted)")
8169 dom.SetStyle(line, "fontSize", "13px")
8170 dom.SetTextContent(line, "(no relay data yet - waiting for OK)")
8171 dom.AppendChild(list, line)
8172 return
8173 }
8174 for _, u := range urls {
8175 row := dom.CreateElement("div")
8176 dom.SetStyle(row, "padding", "6px 0")
8177 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
8178 dom.SetStyle(row, "fontSize", "13px")
8179 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
8180 dom.SetStyle(row, "wordBreak", "break-all")
8181 dom.SetTextContent(row, u)
8182 dom.AppendChild(list, row)
8183 }
8184 }
8185 renderList()
8186 seenOnSubs[evID] = renderList
8187
8188 // Rebroadcast button - publish this event to all write relays.
8189 if pubhex != "" {
8190 ev, _ := lookupEvent(evID)
8191 if ev != nil {
8192 footer := dom.CreateElement("div")
8193 dom.SetStyle(footer, "padding", "8px 16px 12px")
8194 dom.SetStyle(footer, "borderTop", "1px solid var(--border)")
8195 rbtn := dom.CreateElement("span")
8196 dom.SetTextContent(rbtn, "rebroadcast to all relays")
8197 dom.SetStyle(rbtn, "cursor", "pointer")
8198 dom.SetStyle(rbtn, "color", "var(--accent)")
8199 dom.SetStyle(rbtn, "fontSize", "13px")
8200 dom.SetStyle(rbtn, "fontFamily", "'Fira Code', monospace")
8201 evJSON := ev.ToJSON()
8202 capturedEvID := ev.ID
8203 dom.AddEventListener(rbtn, "click", dom.RegisterCallback(func() {
8204 dom.SetTextContent(rbtn, "sending...")
8205 dom.SetStyle(rbtn, "color", "var(--muted)")
8206 dom.SetStyle(rbtn, "cursor", "default")
8207 msg := "[\"PUBLISH_TO\"," | evJSON | ",["
8208 relayList := ""
8209 for i, url := range relayURLs {
8210 if i > 0 {
8211 msg = msg | ","
8212 relayList = relayList | "\n"
8213 }
8214 msg = msg | jstr(url)
8215 relayList = relayList | url
8216 }
8217 routeMsg(msg | "]]")
8218 logActivity("rebroadcast", capturedEvID[:12] | " to " | itoa(len(relayURLs)) | " relays", "event " | capturedEvID | "\nrelays:\n" | relayList)
8219 dom.SetTimeout(func() {
8220 dom.SetTextContent(rbtn, "sent \u2713")
8221 }, 500)
8222 }))
8223 dom.AppendChild(footer, rbtn)
8224 dom.AppendChild(modal, footer)
8225 }
8226 }
8227
8228 dom.AppendChild(overlay, modal)
8229
8230 closeFn := dom.RegisterCallback(func() {
8231 delete(seenOnSubs, evID)
8232 dom.RemoveChild(dom.Body(), overlay)
8233 })
8234 dom.AddEventListener(closeBtn, "click", closeFn)
8235 dom.AddSelfEventListener(overlay, "click", closeFn)
8236
8237 dom.AppendChild(dom.Body(), overlay)
8238 }
8239
8240 // --- Emoji data ---
8241 // emojiList is defined in emoji_data.mx (1761 entries from Unicode 16.0).
8242
8243 type emojiEntry struct {
8244 emoji string
8245 name string
8246 }
8247
8248 var emojiByName map[string]string
8249
8250 func initEmoji() {
8251 emojiByName = map[string]string{}
8252 for _, e := range emojiList {
8253 emojiByName[":" | e.name | ":"] = e.emoji
8254 }
8255 }
8256
8257 // replaceEmojiCodes replaces :name: shortcodes in text with emoji characters.
8258 func replaceEmojiCodes(s string) (sv string) {
8259 out := ""
8260 start := 0
8261 i := 0
8262 for i < len(s) {
8263 if s[i] == ':' {
8264 end := -1
8265 for j := i + 1; j < len(s) && j < i+30; j++ {
8266 if s[j] == ':' {
8267 end = j
8268 break
8269 }
8270 if s[j] == ' ' || s[j] == '\n' {
8271 break
8272 }
8273 }
8274 if end > 0 {
8275 code := s[i : end+1]
8276 if emoji, ok := emojiByName[code]; ok {
8277 out = out | s[start:i] | emoji
8278 i = end + 1
8279 start = i
8280 continue
8281 }
8282 }
8283 }
8284 i++
8285 }
8286 return out | s[start:]
8287 }
8288
8289 // --- Action button row ---
8290
8291 type zapRowPending struct {
8292 row dom.Element
8293 ev *nostr.Event
8294 }
8295
8296 // attachZapButton appends the zap button + total span to the end of an
8297 // action button row, and registers the total element for 9735 updates.
8298 func attachZapButton(row dom.Element, ev *nostr.Event) {
8299 zapWrap := dom.CreateElement("span")
8300 dom.SetStyle(zapWrap, "display", "inline-flex")
8301 dom.SetStyle(zapWrap, "alignItems", "center")
8302 dom.SetStyle(zapWrap, "gap", "4px")
8303 zapBtn := actionBtn(svgZap)
8304 capturedZapEv := ev
8305 dom.AddEventListener(zapBtn, "click", dom.RegisterCallback(func() {
8306 showZapModal(capturedZapEv)
8307 }))
8308 dom.AppendChild(zapWrap, zapBtn)
8309 zapTotalEl := dom.CreateElement("span")
8310 dom.SetStyle(zapTotalEl, "fontSize", "12px")
8311 dom.SetStyle(zapTotalEl, "color", "var(--muted)")
8312 dom.SetStyle(zapTotalEl, "whiteSpace", "nowrap")
8313 if msats, ok := noteZapTotals[ev.ID]; ok && msats > 0 {
8314 dom.SetTextContent(zapTotalEl, "\xe2\x9a\xa1 " | i64toa(msats/1000) | " sats")
8315 }
8316 dom.AppendChild(zapWrap, zapTotalEl)
8317 noteZapEls[ev.ID] = zapTotalEl
8318 moreEl := dom.QuerySelectorFrom(row, "[data-more]")
8319 if moreEl != 0 {
8320 dom.InsertBefore(row, zapWrap, moreEl)
8321 } else {
8322 dom.AppendChild(row, zapWrap)
8323 }
8324 }
8325
8326 // applyZapButtonsForAuthor injects zap buttons into any action rows that
8327 // were built before this author's lud16 was known.
8328 func applyZapButtonsForAuthor(pk string) {
8329 pending, ok := pendingZapRows[pk]
8330 if !ok {
8331 return
8332 }
8333 delete(pendingZapRows, pk)
8334 for _, p := range pending {
8335 attachZapButton(p.row, p.ev)
8336 }
8337 }
8338
8339 func buildActionRow(ev *nostr.Event, noteEl dom.Element, contentEl dom.Element, moreEl dom.Element) (e dom.Element) {
8340 wrap := dom.CreateElement("div")
8341 dom.SetStyle(wrap, "marginTop", "8px")
8342 dom.SetStyle(wrap, "maxWidth", "65ch")
8343
8344 // Cache the event for repost/quote content.
8345 setEvent(ev.ID, ev)
8346 fillEmbed(ev)
8347
8348 // Reaction display - own line above buttons.
8349 reactDisplay := dom.CreateElement("div")
8350 dom.SetStyle(reactDisplay, "display", "flex")
8351 dom.SetStyle(reactDisplay, "flexWrap", "wrap")
8352 dom.SetStyle(reactDisplay, "alignItems", "center")
8353 dom.SetStyle(reactDisplay, "gap", "4px")
8354 dom.SetStyle(reactDisplay, "fontSize", "12px")
8355 dom.SetStyle(reactDisplay, "marginBottom", "4px")
8356 if _, ok := noteReactionMap[ev.ID]; ok {
8357 fillReactionDisplay(reactDisplay, ev.ID)
8358 }
8359 noteReactionEls[ev.ID] = reactDisplay
8360 dom.AppendChild(wrap, reactDisplay)
8361
8362 // Button row.
8363 row := dom.CreateElement("div")
8364 dom.SetStyle(row, "display", "flex")
8365 dom.SetStyle(row, "alignItems", "center")
8366 dom.SetStyle(row, "gap", "16px")
8367
8368 // Reply button.
8369 replyBtn := actionBtn(svgReply)
8370 capturedEv1 := ev
8371 capturedNote1 := noteEl
8372 dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
8373 showEditor("reply", capturedEv1, capturedNote1)
8374 }))
8375 dom.AppendChild(row, replyBtn)
8376
8377 // Quote button.
8378 quoteBtn := actionBtn(svgQuote)
8379 capturedEv2 := ev
8380 capturedNote2 := noteEl
8381 dom.AddEventListener(quoteBtn, "click", dom.RegisterCallback(func() {
8382 showEditor("quote", capturedEv2, capturedNote2)
8383 }))
8384 dom.AppendChild(row, quoteBtn)
8385
8386 // Repost button + counter.
8387 repostWrap := dom.CreateElement("span")
8388 dom.SetStyle(repostWrap, "display", "flex")
8389 dom.SetStyle(repostWrap, "alignItems", "center")
8390 dom.SetStyle(repostWrap, "gap", "4px")
8391 repostBtn := actionBtn(svgRepost)
8392 capturedEv3 := ev
8393 dom.AddEventListener(repostBtn, "click", dom.RegisterCallback(func() {
8394 onRepostClick(capturedEv3)
8395 }))
8396 dom.AppendChild(repostWrap, repostBtn)
8397 repostCount := dom.CreateElement("span")
8398 dom.SetStyle(repostCount, "fontSize", "12px")
8399 dom.SetStyle(repostCount, "color", "var(--muted)")
8400 if pks, ok := noteRepostCounts[ev.ID]; ok && len(pks) > 0 {
8401 dom.SetTextContent(repostCount, itoa(len(pks)))
8402 }
8403 dom.AppendChild(repostWrap, repostCount)
8404 noteRepostEls[ev.ID] = repostCount
8405 dom.AppendChild(row, repostWrap)
8406
8407 // React button.
8408 reactWrap := dom.CreateElement("span")
8409 dom.SetStyle(reactWrap, "display", "inline-flex")
8410 dom.SetStyle(reactWrap, "alignItems", "center")
8411 reactBtn := actionBtn(svgReact)
8412 capturedEv4 := ev
8413 capturedWrap := reactWrap
8414 dom.AddEventListener(reactBtn, "click", dom.RegisterCallback(func() {
8415 showEmojiPicker(capturedEv4, capturedWrap)
8416 }))
8417 dom.AppendChild(reactWrap, reactBtn)
8418 dom.AppendChild(row, reactWrap)
8419
8420 // Zap button - right of react. Only rendered if the author has a
8421 // lightning address. If the profile isn't cached yet, queue this row so
8422 // applyAuthorProfile can inject the button when it arrives.
8423 authorHasLN := false
8424 if content, ok := profileContentCache[ev.PubKey]; ok {
8425 if helpers.JsonGetString(content, "lud16") != "" || helpers.JsonGetString(content, "lud06") != "" {
8426 authorHasLN = true
8427 }
8428 }
8429 if authorHasLN {
8430 attachZapButton(row, ev)
8431 } else {
8432 pendingZapRows[ev.PubKey] = append(pendingZapRows[ev.PubKey], zapRowPending{row: row, ev: ev})
8433 }
8434
8435 if moreEl != 0 {
8436 dom.SetStyle(moreEl, "marginLeft", "auto")
8437 dom.SetAttribute(moreEl, "data-more", "1")
8438 dom.AppendChild(row, moreEl)
8439 }
8440 dom.AppendChild(wrap, row)
8441
8442 // Queue fetch for kind 6/7 if not already fetched.
8443 queueActionFetch(ev.ID)
8444
8445 return wrap
8446 }
8447
8448 func actionBtn(svgHTML string) (e dom.Element) {
8449 btn := dom.CreateElement("span")
8450 dom.SetInnerHTML(btn, svgHTML)
8451 dom.SetStyle(btn, "cursor", "pointer")
8452 dom.SetStyle(btn, "display", "inline-flex")
8453 dom.SetStyle(btn, "alignItems", "center")
8454 dom.SetStyle(btn, "color", "var(--muted)")
8455 dom.SetStyle(btn, "opacity", "0.6")
8456 dom.SetStyle(btn, "userSelect", "none")
8457 capturedBtn := btn
8458 dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
8459 dom.SetStyle(capturedBtn, "opacity", "1")
8460 }))
8461 dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
8462 dom.SetStyle(capturedBtn, "opacity", "0.5")
8463 }))
8464 return btn
8465 }
8466
8467 // --- Reaction/repost display ---
8468
8469 func fillReactionDisplay(el dom.Element, evID string) {
8470 rm, ok := noteReactionMap[evID]
8471 if !ok {
8472 return
8473 }
8474 // Sort by count descending (simple selection sort).
8475 type ec struct {
8476 emoji string
8477 count int32
8478 }
8479 var sorted []ec
8480 for e, pks := range rm {
8481 sorted = append(sorted, ec{e, len(pks)})
8482 }
8483 for i := 0; i < len(sorted); i++ {
8484 for j := i + 1; j < len(sorted); j++ {
8485 if sorted[j].count > sorted[i].count {
8486 sorted[i], sorted[j] = sorted[j], sorted[i]
8487 }
8488 }
8489 }
8490 clearChildren(el)
8491 for i, e := range sorted {
8492 if i >= 8 {
8493 more := dom.CreateElement("span")
8494 dom.SetTextContent(more, "...")
8495 dom.SetStyle(more, "color", "var(--muted)")
8496 dom.AppendChild(el, more)
8497 break
8498 }
8499 chip := dom.CreateElement("span")
8500 dom.SetTextContent(chip, e.emoji | " " | itoa(e.count))
8501 dom.SetStyle(chip, "whiteSpace", "nowrap")
8502 dom.AppendChild(el, chip)
8503 }
8504 }
8505
8506 // --- Reaction/repost fetching ---
8507
8508 func queueActionFetch(evID string) {
8509 if actionFetchedIDs[evID] {
8510 return
8511 }
8512 actionFetchedIDs[evID] = true
8513 actionFetchQueue = append(actionFetchQueue, evID)
8514 if actionFetchTimer != 0 {
8515 dom.ClearTimeout(actionFetchTimer)
8516 }
8517 actionFetchTimer = dom.SetTimeout(func() {
8518 actionFetchTimer = 0
8519 flushActionFetch()
8520 }, 400)
8521 }
8522
8523 func flushActionFetch() {
8524 if len(actionFetchQueue) == 0 {
8525 return
8526 }
8527 queue := actionFetchQueue
8528 actionFetchQueue = nil
8529
8530 const batchSize = 20
8531 for i := 0; i < len(queue); i += batchSize {
8532 end := i + batchSize
8533 if end > len(queue) {
8534 end = len(queue)
8535 }
8536 chunk := queue[i:end]
8537 ids := "["
8538 for j, id := range chunk {
8539 if j > 0 {
8540 ids = ids | ","
8541 }
8542 ids = ids | jstr(id)
8543 }
8544 ids = ids | "]"
8545 actionSubCounter++
8546 subID := "act-" | itoa(actionSubCounter)
8547
8548 var urls []string
8549 seen := map[string]bool{}
8550 for _, u := range relayURLs {
8551 if !seen[u] {
8552 seen[u] = true
8553 urls = append(urls, u)
8554 }
8555 }
8556 for _, u := range topRelays(4) {
8557 if !seen[u] {
8558 seen[u] = true
8559 urls = append(urls, u)
8560 }
8561 }
8562 routeMsg(buildProxyMsg(subID,
8563 "{\"kinds\":[6,7,9735],\"#e\":" | ids | ",\"limit\":500}",
8564 urls))
8565 }
8566 }
8567
8568 func handleActionEvent(ev *nostr.Event) {
8569 if noteRepostCounts == nil || noteReactionMap == nil || noteRepostEls == nil || noteReactionEls == nil {
8570 return
8571 }
8572 // Find which event this references.
8573 var targetID string
8574 for _, tag := range ev.Tags {
8575 if len(tag) >= 2 && tag[0] == "e" {
8576 targetID = tag[1]
8577 break
8578 }
8579 }
8580 if targetID == "" {
8581 return
8582 }
8583
8584 if ev.Kind == 6 {
8585 pks := noteRepostCounts[targetID]
8586 if pks == nil {
8587 pks = map[string]bool{}
8588 noteRepostCounts[targetID] = pks
8589 }
8590 if !pks[ev.PubKey] {
8591 pks[ev.PubKey] = true
8592 if el, ok := noteRepostEls[targetID]; ok {
8593 dom.SetTextContent(el, itoa(len(pks)))
8594 }
8595 }
8596 } else if ev.Kind == 9735 {
8597 // Zap receipt: accumulate msat amount from the bolt11 tag (ground truth).
8598 seen := noteZapSeen[targetID]
8599 if seen == nil {
8600 seen = map[string]bool{}
8601 noteZapSeen[targetID] = seen
8602 }
8603 if seen[ev.ID] {
8604 return
8605 }
8606 seen[ev.ID] = true
8607 var bolt11 string
8608 for _, tag := range ev.Tags {
8609 if len(tag) >= 2 && tag[0] == "bolt11" {
8610 bolt11 = tag[1]
8611 break
8612 }
8613 }
8614 if bolt11 == "" {
8615 return
8616 }
8617 msats := parseBolt11Msat(bolt11)
8618 if msats <= 0 {
8619 return
8620 }
8621 cur := int64(0)
8622 if v, ok := noteZapTotals[targetID]; ok {
8623 cur = v
8624 }
8625 cur = cur + msats
8626 noteZapTotals[targetID] = cur
8627 if el, ok := noteZapEls[targetID]; ok {
8628 dom.SetTextContent(el, "\xe2\x9a\xa1 " | i64toa(cur/1000) | " sats")
8629 }
8630 } else if ev.Kind == 7 {
8631 emoji := ev.Content
8632 if emoji == "" || emoji == " | " {
8633 emoji = "\xe2\x9d\xa4\xef\xb8\x8f"
8634 }
8635 inner := noteReactionMap[targetID]
8636 if inner == nil {
8637 inner = map[string]map[string]bool{}
8638 }
8639 pks := inner[emoji]
8640 if pks == nil {
8641 pks = map[string]bool{}
8642 }
8643 if !pks[ev.PubKey] {
8644 pks[ev.PubKey] = true
8645 inner[emoji] = pks
8646 noteReactionMap[targetID] = inner
8647 if el, ok := noteReactionEls[targetID]; ok {
8648 fillReactionDisplay(el, targetID)
8649 }
8650 }
8651 }
8652 }
8653
8654 // --- Repost ---
8655
8656 func onRepostClick(ev *nostr.Event) {
8657 if !signer.HasSigner() || pubhex == "" {
8658 return
8659 }
8660 if !dom.Confirm("Repost this note?") {
8661 return
8662 }
8663 relay := ""
8664 if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
8665 relay = urls[0]
8666 }
8667 tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
8668 ts := dom.NowSeconds()
8669 unsigned := "{\"kind\":6,\"content\":" | jstr(ev.ToJSON()) |
8670 ",\"tags\":" | tags |
8671 ",\"created_at\":" | i64toa(ts) |
8672 ",\"pubkey\":\"" | pubhex | "\"}"
8673 signer.SignEvent(unsigned, func(signed string) {
8674 if signed != "" {
8675 selected := composeSelectedRelays()
8676 if len(selected) > 0 {
8677 msg := "[\"PUBLISH_TO\"," | signed | ",["
8678 for i, u := range selected {
8679 if i > 0 {
8680 msg = msg | ","
8681 }
8682 msg = msg | jstr(u)
8683 }
8684 routeMsg(msg | "]]")
8685 } else {
8686 routeMsg("[\"EVENT\"," | signed | "]")
8687 }
8688 pks := noteRepostCounts[ev.ID]
8689 if pks == nil {
8690 pks = map[string]bool{}
8691 noteRepostCounts[ev.ID] = pks
8692 }
8693 pks[pubhex] = true
8694 if el, ok := noteRepostEls[ev.ID]; ok {
8695 dom.SetTextContent(el, itoa(len(pks)))
8696 }
8697 }
8698 })
8699 }
8700
8701 // --- Compose editor (reply + quote) ---
8702
8703 func editorDraftKey(mode string, evID string) (s string) {
8704 return mode | ":" | evID
8705 }
8706
8707 func showEditor(mode string, ev *nostr.Event, noteEl dom.Element) {
8708 if !signer.HasSigner() || pubhex == "" {
8709 return
8710 }
8711 if editorOpen {
8712 closeEditor()
8713 }
8714 editorOpen = true
8715 editorPreviewing = false
8716 editorMode = mode
8717 editorTargetEv = ev
8718 editorNoteEl = noteEl
8719
8720 composer := dom.CreateElement("div")
8721 dom.SetStyle(composer, "borderTop", "1px solid var(--border)")
8722 dom.SetStyle(composer, "marginTop", "8px")
8723 dom.SetStyle(composer, "paddingTop", "8px")
8724 dom.SetStyle(composer, "maxWidth", "65ch")
8725 dom.SetStyle(composer, "display", "flex")
8726 dom.SetStyle(composer, "flexDirection", "column")
8727 dom.SetStyle(composer, "gap", "8px")
8728 editorComposer = composer
8729
8730 // Header row: mode label + preview toggle + close.
8731 headerRow := dom.CreateElement("div")
8732 dom.SetStyle(headerRow, "display", "flex")
8733 dom.SetStyle(headerRow, "alignItems", "center")
8734 dom.SetStyle(headerRow, "gap", "8px")
8735
8736 modeLabel := dom.CreateElement("span")
8737 dom.SetStyle(modeLabel, "fontSize", "13px")
8738 dom.SetStyle(modeLabel, "fontWeight", "bold")
8739 dom.SetStyle(modeLabel, "color", "var(--muted)")
8740 if mode == "reply" {
8741 dom.SetTextContent(modeLabel, "Reply")
8742 } else {
8743 dom.SetTextContent(modeLabel, "Quote")
8744 }
8745 dom.AppendChild(headerRow, modeLabel)
8746
8747 editorImgBtn := dom.CreateElement("button")
8748 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>`)
8749 dom.SetStyle(editorImgBtn, "padding", "2px 6px")
8750 dom.SetStyle(editorImgBtn, "border", "1px solid var(--border)")
8751 dom.SetStyle(editorImgBtn, "borderRadius", "4px")
8752 dom.SetStyle(editorImgBtn, "background", "transparent")
8753 dom.SetStyle(editorImgBtn, "color", "var(--fg)")
8754 dom.SetStyle(editorImgBtn, "cursor", "pointer")
8755 dom.SetStyle(editorImgBtn, "display", "flex")
8756 dom.SetStyle(editorImgBtn, "alignItems", "center")
8757 dom.AddEventListener(editorImgBtn, "click", dom.RegisterCallback(func() {
8758 dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
8759 if b64 != "" {
8760 blossomUpload(b64, mime, editorTextarea)
8761 }
8762 })
8763 }))
8764 dom.AppendChild(headerRow, editorImgBtn)
8765
8766 previewBtn := dom.CreateElement("button")
8767 dom.SetTextContent(previewBtn, "Preview")
8768 dom.SetStyle(previewBtn, "padding", "2px 8px")
8769 dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
8770 dom.SetStyle(previewBtn, "borderRadius", "4px")
8771 dom.SetStyle(previewBtn, "background", "transparent")
8772 dom.SetStyle(previewBtn, "color", "var(--fg)")
8773 dom.SetStyle(previewBtn, "cursor", "pointer")
8774 dom.SetStyle(previewBtn, "fontSize", "11px")
8775 dom.AppendChild(headerRow, previewBtn)
8776
8777 closeBtn := dom.CreateElement("span")
8778 dom.SetTextContent(closeBtn, "\u2715")
8779 dom.SetStyle(closeBtn, "marginLeft", "auto")
8780 dom.SetStyle(closeBtn, "cursor", "pointer")
8781 dom.SetStyle(closeBtn, "color", "var(--muted)")
8782 dom.SetStyle(closeBtn, "fontSize", "14px")
8783 dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(func() {
8784 closeEditor()
8785 }))
8786 dom.AppendChild(headerRow, closeBtn)
8787
8788 dom.AppendChild(composer, headerRow)
8789
8790 ta := dom.CreateElement("textarea")
8791 dom.SetStyle(ta, "width", "100%")
8792 dom.SetStyle(ta, "minHeight", "80px")
8793 dom.SetStyle(ta, "resize", "vertical")
8794 dom.SetStyle(ta, "overflowY", "auto")
8795 dom.SetStyle(ta, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8796 dom.SetStyle(ta, "fontSize", "14px")
8797 dom.SetStyle(ta, "padding", "8px")
8798 dom.SetStyle(ta, "border", "1px solid var(--border)")
8799 dom.SetStyle(ta, "borderRadius", "4px")
8800 dom.SetStyle(ta, "background", "var(--bg)")
8801 dom.SetStyle(ta, "color", "var(--fg)")
8802 dom.SetStyle(ta, "boxSizing", "border-box")
8803 dom.SetAttribute(ta, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
8804 editorTextarea = ta
8805
8806 // Restore draft or set initial content.
8807 draftKey := editorDraftKey(mode, ev.ID)
8808 if draft, ok := editorDrafts[draftKey]; ok {
8809 dom.SetProperty(ta, "value", draft)
8810 } else if mode == "quote" {
8811 relay := ""
8812 if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
8813 relay = urls[0]
8814 }
8815 var relays []string
8816 if relay != "" {
8817 relays = []string{relay}
8818 }
8819 nevent := helpers.EncodeNevent(ev.ID, relays, ev.PubKey)
8820 dom.SetProperty(ta, "value", "nostr:" | nevent | "\n\n")
8821 }
8822
8823 wireEmojiHandler(ta)
8824 wireBlossomHandlers(ta)
8825 wireMentionHandler(ta)
8826 dom.AppendChild(composer, ta)
8827
8828 // Markdown preview area (hidden initially).
8829 prevArea := dom.CreateElement("div")
8830 dom.SetStyle(prevArea, "display", "none")
8831 dom.SetStyle(prevArea, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8832 dom.SetStyle(prevArea, "fontSize", "14px")
8833 dom.SetStyle(prevArea, "lineHeight", "1.5")
8834 dom.SetStyle(prevArea, "padding", "8px")
8835 dom.SetStyle(prevArea, "border", "1px solid var(--border)")
8836 dom.SetStyle(prevArea, "borderRadius", "4px")
8837 dom.SetStyle(prevArea, "minHeight", "80px")
8838 dom.SetStyle(prevArea, "wordBreak", "break-word")
8839 dom.SetStyle(prevArea, "overflowY", "auto")
8840 editorPreviewEl = prevArea
8841 dom.AppendChild(composer, prevArea)
8842
8843 // Preview toggle.
8844 capturedTa := ta
8845 capturedPrev := prevArea
8846 capturedBtn := previewBtn
8847 dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
8848 editorPreviewing = !editorPreviewing
8849 if editorPreviewing {
8850 text := dom.GetProperty(capturedTa, "value")
8851 text = replaceEmojiCodes(text)
8852 setHTMLWithMedia(capturedPrev, renderMarkdown(text))
8853 resolveEmbeds()
8854 dom.SetStyle(capturedTa, "display", "none")
8855 dom.SetStyle(capturedPrev, "display", "block")
8856 dom.SetTextContent(capturedBtn, "Edit")
8857 dom.SetStyle(capturedBtn, "background", "var(--accent)")
8858 dom.SetStyle(capturedBtn, "color", "#fff")
8859 dom.SetStyle(capturedBtn, "border", "1px solid var(--accent)")
8860 } else {
8861 dom.SetStyle(capturedTa, "display", "")
8862 dom.SetStyle(capturedPrev, "display", "none")
8863 dom.SetTextContent(capturedBtn, "Preview")
8864 dom.SetStyle(capturedBtn, "background", "transparent")
8865 dom.SetStyle(capturedBtn, "color", "var(--fg)")
8866 dom.SetStyle(capturedBtn, "border", "1px solid var(--border)")
8867 }
8868 }))
8869
8870 // Button row.
8871 btnRow := dom.CreateElement("div")
8872 dom.SetStyle(btnRow, "display", "flex")
8873 dom.SetStyle(btnRow, "justifyContent", "flex-end")
8874 dom.SetStyle(btnRow, "gap", "8px")
8875
8876 pubBtn := dom.CreateElement("button")
8877 dom.SetTextContent(pubBtn, "Publish")
8878 dom.SetAttribute(pubBtn, "data-publish-btn", "1")
8879 dom.SetStyle(pubBtn, "padding", "5px 14px")
8880 dom.SetStyle(pubBtn, "border", "none")
8881 dom.SetStyle(pubBtn, "borderRadius", "4px")
8882 dom.SetStyle(pubBtn, "background", "var(--accent)")
8883 dom.SetStyle(pubBtn, "color", "#fff")
8884 dom.SetStyle(pubBtn, "cursor", "pointer")
8885 dom.SetStyle(pubBtn, "fontWeight", "bold")
8886 dom.SetStyle(pubBtn, "fontSize", "13px")
8887 dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
8888 publishEditorContent()
8889 }))
8890 dom.AppendChild(btnRow, pubBtn)
8891
8892 dom.AppendChild(composer, btnRow)
8893 dom.AppendChild(noteEl, composer)
8894 dom.Focus(ta)
8895 }
8896
8897 func closeEditor() {
8898 // Save draft before closing.
8899 if editorTextarea != 0 && editorTargetEv != nil {
8900 text := dom.GetProperty(editorTextarea, "value")
8901 key := editorDraftKey(editorMode, editorTargetEv.ID)
8902 if text != "" {
8903 editorDrafts[key] = text
8904 } else {
8905 delete(editorDrafts, key)
8906 }
8907 }
8908 if editorComposer != 0 && editorNoteEl != 0 {
8909 dom.RemoveChild(editorNoteEl, editorComposer)
8910 editorComposer = 0
8911 editorNoteEl = 0
8912 }
8913 editorOpen = false
8914 editorTargetEv = nil
8915 editorTextarea = 0
8916 editorPreviewEl = 0
8917 editorPreviewing = false
8918 closeEmojiAuto()
8919 closeMentionPopup()
8920 }
8921
8922 func publishEditorContent() {
8923 dom.ConsoleLog("[publish-reply] enter")
8924 if editorTargetEv == nil || editorTextarea == 0 {
8925 dom.ConsoleLog("[publish-reply] no target or textarea")
8926 return
8927 }
8928 content := dom.GetProperty(editorTextarea, "value")
8929 dom.ConsoleLog("[publish-reply] content len=" | itoa(len(content)))
8930 content = replaceEmojiCodes(content)
8931 if content == "" {
8932 dom.ConsoleLog("[publish-reply] empty after emoji replace")
8933 return
8934 }
8935
8936 ev := editorTargetEv
8937 mode := editorMode
8938 // Clear draft on publish.
8939 delete(editorDrafts, editorDraftKey(mode, ev.ID))
8940 closeEditor()
8941
8942 relay := ""
8943 if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
8944 relay = urls[0]
8945 }
8946
8947 // Build tags.
8948 tags := "["
8949 rootID := getRootID(ev)
8950
8951 if mode == "reply" {
8952 // e-tags: root + reply.
8953 if rootID != "" && rootID != ev.ID {
8954 tags = tags | "[\"e\"," | jstr(rootID) | "," | jstr(relay) | ",\"root\"],"
8955 tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"reply\"]"
8956 } else {
8957 // Replying to a root note - it becomes the root, our reply points to it.
8958 tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"root\"]"
8959 }
8960 // p-tags: collect all users in the chain.
8961 ptags := map[string]bool{}
8962 for _, tag := range ev.Tags {
8963 if len(tag) >= 2 && tag[0] == "p" && tag[1] != pubhex {
8964 ptags[tag[1]] = true
8965 }
8966 }
8967 if ev.PubKey != pubhex {
8968 ptags[ev.PubKey] = true
8969 }
8970 for pk := range ptags {
8971 tags = tags | ",[\"p\"," | jstr(pk) | "]"
8972 }
8973 } else {
8974 // Quote: q-tag + p-tag for quoted author.
8975 tags = tags | "[\"q\"," | jstr(ev.ID) | "]"
8976 if ev.PubKey != pubhex {
8977 tags = tags | ",[\"p\"," | jstr(ev.PubKey) | "]"
8978 }
8979 }
8980 // Client tag.
8981 if localstorage.GetItem("smesh-client-tag") != "0" {
8982 tags = tags | ",[\"client\",\"https://smesh.lol\"]"
8983 }
8984 tags = tags | "]"
8985
8986 ts := dom.NowSeconds()
8987 unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
8988 ",\"tags\":" | tags |
8989 ",\"created_at\":" | i64toa(ts) |
8990 ",\"pubkey\":\"" | pubhex | "\"}"
8991 dom.ConsoleLog("[publish-reply] calling SignEvent")
8992 signer.SignEvent(unsigned, func(signed string) {
8993 if signed == "" {
8994 logActivity("reply", "FAILED: signer error (vault locked?)", unsigned)
8995 dom.Confirm("publish failed: signer error (vault locked?)")
8996 return
8997 }
8998 // Publish to selected relays (or all if none selected).
8999 selected := composeSelectedRelays()
9000 preview := content
9001 if len(preview) > 80 {
9002 preview = preview[:80] | "..."
9003 }
9004 relayList := ""
9005 for j, u := range selected {
9006 if j > 0 {
9007 relayList = relayList | "\n"
9008 }
9009 relayList = relayList | u
9010 }
9011 if len(selected) > 0 {
9012 msg := "[\"PUBLISH_TO\"," | signed | ",["
9013 for i, u := range selected {
9014 if i > 0 {
9015 msg = msg | ","
9016 }
9017 msg = msg | jstr(u)
9018 }
9019 routeMsg(msg | "]]")
9020 } else {
9021 routeMsg("[\"EVENT\"," | signed | "]")
9022 }
9023 logActivity("reply", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
9024 published := nostr.ParseEvent(signed)
9025 if published != nil {
9026 insertPublishedNote(published)
9027 }
9028 })
9029 }
9030
9031 func publishDelete(targetID string) {
9032 if !dom.Confirm("Delete this note?") {
9033 return
9034 }
9035 ts := dom.NowSeconds()
9036 unsigned := "{\"kind\":5,\"content\":\"\"" |
9037 ",\"tags\":[[\"e\"," | jstr(targetID) | "]]" |
9038 ",\"created_at\":" | i64toa(ts) |
9039 ",\"pubkey\":\"" | pubhex | "\"}"
9040 signer.SignEvent(unsigned, func(signed string) {
9041 if signed == "" {
9042 return
9043 }
9044 delEv := nostr.ParseEvent(signed)
9045 if delEv != nil {
9046 pendingDeletes[delEv.ID] = targetID
9047 delID := delEv.ID
9048 dom.SetTimeout(func() {
9049 delete(pendingDeletes, delID)
9050 }, 30000)
9051 }
9052 routeMsg("[\"EVENT\"," | signed | "]")
9053 })
9054 }
9055
9056 func removeNoteFromDOM(evID string) {
9057 el, ok := noteElements[evID]
9058 if !ok {
9059 return
9060 }
9061 dom.Remove(el)
9062 delete(noteElements, evID)
9063 delete(seenEvents, evID)
9064 }
9065
9066 func fetchDeletesForFeed() {
9067 // Collect event IDs from rendered notes.
9068 var ids []string
9069 for id := range noteElements {
9070 ids = append(ids, id)
9071 }
9072 if len(ids) == 0 {
9073 return
9074 }
9075 // Build a filter for kind 5 events referencing these note IDs.
9076 eList := jstr(ids[0])
9077 for i := 1; i < len(ids); i++ {
9078 eList = eList | "," | jstr(ids[i])
9079 }
9080 filter := "{\"kinds\":[5],\"#e\":[" | eList | "]}"
9081 routeMsg(buildProxyMsg("del-scan", filter, feedRelays()))
9082 }
9083
9084 func buildComposePage() {
9085 composeTextarea = dom.CreateElement("textarea")
9086 dom.SetAttribute(composeTextarea, "placeholder", "What's on your mind?")
9087 dom.SetStyle(composeTextarea, "width", "100%")
9088 dom.SetStyle(composeTextarea, "minHeight", "160px")
9089 dom.SetStyle(composeTextarea, "flex", "1")
9090 dom.SetStyle(composeTextarea, "padding", "12px")
9091 dom.SetStyle(composeTextarea, "fontSize", "15px")
9092 dom.SetStyle(composeTextarea, "lineHeight", "1.5")
9093 dom.SetStyle(composeTextarea, "border", "1px solid var(--border)")
9094 dom.SetStyle(composeTextarea, "borderRadius", "8px")
9095 dom.SetStyle(composeTextarea, "background", "var(--bg)")
9096 dom.SetStyle(composeTextarea, "color", "var(--fg)")
9097 dom.SetStyle(composeTextarea, "resize", "vertical")
9098 dom.SetStyle(composeTextarea, "overflowY", "auto")
9099 dom.SetStyle(composeTextarea, "boxSizing", "border-box")
9100 dom.SetStyle(composeTextarea, "fontFamily", "inherit")
9101 dom.SetAttribute(composeTextarea, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
9102 dom.AppendChild(composePage, composeTextarea)
9103
9104 // Preview area (hidden by default).
9105 composePreview = dom.CreateElement("div")
9106 dom.SetStyle(composePreview, "display", "none")
9107 dom.SetStyle(composePreview, "padding", "12px")
9108 dom.SetStyle(composePreview, "border", "1px solid var(--border)")
9109 dom.SetStyle(composePreview, "borderRadius", "8px")
9110 dom.SetStyle(composePreview, "marginTop", "8px")
9111 dom.SetStyle(composePreview, "lineHeight", "1.5")
9112 dom.SetStyle(composePreview, "wordBreak", "break-word")
9113 dom.AppendChild(composePage, composePreview)
9114
9115 // Button row.
9116 btnRow := dom.CreateElement("div")
9117 dom.SetStyle(btnRow, "display", "flex")
9118 dom.SetStyle(btnRow, "gap", "8px")
9119 dom.SetStyle(btnRow, "marginTop", "12px")
9120 dom.SetStyle(btnRow, "alignItems", "center")
9121
9122 // Relay selector button (left side).
9123 relaySelWrap := dom.CreateElement("div")
9124 dom.SetStyle(relaySelWrap, "position", "relative")
9125 dom.SetStyle(relaySelWrap, "display", "flex")
9126 dom.SetStyle(relaySelWrap, "alignItems", "center")
9127 dom.SetStyle(relaySelWrap, "gap", "6px")
9128
9129 relaySelBtn := dom.CreateElement("button")
9130 dom.SetStyle(relaySelBtn, "padding", "8px 16px")
9131 dom.SetStyle(relaySelBtn, "border", "1px solid var(--border)")
9132 dom.SetStyle(relaySelBtn, "borderRadius", "6px")
9133 dom.SetStyle(relaySelBtn, "background", "transparent")
9134 dom.SetStyle(relaySelBtn, "color", "var(--fg)")
9135 dom.SetStyle(relaySelBtn, "cursor", "pointer")
9136 updateRelaySelBtn(relaySelBtn, false)
9137
9138 composeWarnBadge := makeRelayWarnBadge()
9139
9140 composeRelayPopEl = dom.CreateElement("div")
9141 dom.SetStyle(composeRelayPopEl, "display", "none")
9142 dom.SetStyle(composeRelayPopEl, "position", "absolute")
9143 dom.SetStyle(composeRelayPopEl, "bottom", "100%")
9144 dom.SetStyle(composeRelayPopEl, "left", "0")
9145 dom.SetStyle(composeRelayPopEl, "marginBottom", "4px")
9146 dom.SetStyle(composeRelayPopEl, "background", "var(--bg)")
9147 dom.SetStyle(composeRelayPopEl, "border", "1px solid var(--border)")
9148 dom.SetStyle(composeRelayPopEl, "borderRadius", "6px")
9149 dom.SetStyle(composeRelayPopEl, "padding", "8px")
9150 dom.SetStyle(composeRelayPopEl, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
9151 dom.SetStyle(composeRelayPopEl, "zIndex", "1000")
9152 dom.SetStyle(composeRelayPopEl, "minWidth", "200px")
9153 dom.SetStyle(composeRelayPopEl, "maxWidth", "calc(100vw - 24px)")
9154 dom.SetStyle(composeRelayPopEl, "maxHeight", "200px")
9155 dom.SetStyle(composeRelayPopEl, "overflowY", "auto")
9156
9157 dom.AddEventListener(relaySelBtn, "click", dom.RegisterCallback(func() {
9158 if composeRelayPopOpen {
9159 dom.SetStyle(composeRelayPopEl, "display", "none")
9160 composeRelayPopOpen = false
9161 updateRelaySelBtn(relaySelBtn, false)
9162 } else {
9163 populateRelayPopover(composeRelayPopEl, func() {
9164 updateRelaySelBtn(relaySelBtn, true)
9165 updateRelayWarnBadge(composeWarnBadge)
9166 })
9167 dom.SetStyle(composeRelayPopEl, "display", "block")
9168 composeRelayPopOpen = true
9169 updateRelaySelBtn(relaySelBtn, true)
9170 }
9171 }))
9172
9173 dom.AppendChild(relaySelWrap, relaySelBtn)
9174 dom.AppendChild(relaySelWrap, composeWarnBadge)
9175 dom.AppendChild(relaySelWrap, composeRelayPopEl)
9176 dom.AppendChild(btnRow, relaySelWrap)
9177
9178 // Spacer.
9179 spacer := dom.CreateElement("div")
9180 dom.SetStyle(spacer, "flex", "1")
9181 dom.AppendChild(btnRow, spacer)
9182
9183 // Image upload button.
9184 imgBtn := dom.CreateElement("button")
9185 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>`)
9186 dom.SetStyle(imgBtn, "padding", "8px 12px")
9187 dom.SetStyle(imgBtn, "border", "1px solid var(--border)")
9188 dom.SetStyle(imgBtn, "borderRadius", "6px")
9189 dom.SetStyle(imgBtn, "background", "transparent")
9190 dom.SetStyle(imgBtn, "color", "var(--fg)")
9191 dom.SetStyle(imgBtn, "cursor", "pointer")
9192 dom.SetStyle(imgBtn, "display", "flex")
9193 dom.SetStyle(imgBtn, "alignItems", "center")
9194 dom.AddEventListener(imgBtn, "click", dom.RegisterCallback(func() {
9195 dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
9196 if b64 != "" {
9197 blossomUpload(b64, mime, composeTextarea)
9198 }
9199 })
9200 }))
9201 dom.AppendChild(btnRow, imgBtn)
9202
9203 // Preview toggle.
9204 previewBtn := dom.CreateElement("button")
9205 dom.SetTextContent(previewBtn, "preview")
9206 dom.SetStyle(previewBtn, "padding", "8px 16px")
9207 dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
9208 dom.SetStyle(previewBtn, "borderRadius", "6px")
9209 dom.SetStyle(previewBtn, "background", "transparent")
9210 dom.SetStyle(previewBtn, "color", "var(--fg)")
9211 dom.SetStyle(previewBtn, "cursor", "pointer")
9212 composePreviewing := false
9213 dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
9214 content := dom.GetProperty(composeTextarea, "value")
9215 if !composePreviewing {
9216 dom.SetStyle(composePreview, "display", "block")
9217 dom.SetStyle(composeTextarea, "display", "none")
9218 setHTMLWithMedia(composePreview, renderMarkdown(content))
9219 dom.SetTextContent(previewBtn, "edit")
9220 composePreviewing = true
9221 } else {
9222 dom.SetStyle(composePreview, "display", "none")
9223 dom.SetStyle(composeTextarea, "display", "block")
9224 dom.SetTextContent(previewBtn, "preview")
9225 composePreviewing = false
9226 }
9227 }))
9228 dom.AppendChild(btnRow, previewBtn)
9229
9230 // Publish button.
9231 pubBtn := dom.CreateElement("button")
9232 dom.SetTextContent(pubBtn, "publish")
9233 dom.SetAttribute(pubBtn, "data-publish-btn", "1")
9234 dom.SetStyle(pubBtn, "padding", "8px 20px")
9235 dom.SetStyle(pubBtn, "border", "none")
9236 dom.SetStyle(pubBtn, "borderRadius", "6px")
9237 dom.SetStyle(pubBtn, "background", "var(--accent)")
9238 dom.SetStyle(pubBtn, "color", "#fff")
9239 dom.SetStyle(pubBtn, "cursor", "pointer")
9240 dom.SetStyle(pubBtn, "fontWeight", "bold")
9241 dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
9242 dom.ConsoleLog("[publish-compose] enter")
9243 content := dom.GetProperty(composeTextarea, "value")
9244 dom.ConsoleLog("[publish-compose] content len=" | itoa(len(content)))
9245 content = replaceEmojiCodes(content)
9246 if content == "" {
9247 dom.ConsoleLog("[publish-compose] empty")
9248 return
9249 }
9250 dom.ConsoleLog("[publish-compose] hasSigner=" | boolStr(signer.HasSigner()) | " pubhex=" | pubhex)
9251 if !signer.HasSigner() || pubhex == "" {
9252 dom.ConsoleLog("[publish-compose] no signer or pubhex")
9253 return
9254 }
9255 ts := dom.NowSeconds()
9256 tags := "["
9257 if localstorage.GetItem("smesh-client-tag") != "0" {
9258 tags = tags | "[\"client\",\"https://smesh.lol\"]"
9259 }
9260 tags = tags | "]"
9261 unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
9262 ",\"tags\":" | tags |
9263 ",\"created_at\":" | i64toa(ts) |
9264 ",\"pubkey\":\"" | pubhex | "\"}"
9265 dom.ConsoleLog("[publish-compose] calling SignEvent")
9266 capturedContent := content
9267 signer.SignEvent(unsigned, func(signed string) {
9268 if signed == "" {
9269 logActivity("publish", "FAILED: signer error", unsigned)
9270 return
9271 }
9272 // Build relay list from checked relays.
9273 selected := composeSelectedRelays()
9274 preview := capturedContent
9275 if len(preview) > 80 {
9276 preview = preview[:80] | "..."
9277 }
9278 relayList := ""
9279 for j, u := range selected {
9280 if j > 0 {
9281 relayList = relayList | "\n"
9282 }
9283 relayList = relayList | u
9284 }
9285 if len(selected) > 0 {
9286 msg := "[\"PUBLISH_TO\"," | signed | ",["
9287 for i, u := range selected {
9288 if i > 0 {
9289 msg = msg | ","
9290 }
9291 msg = msg | jstr(u)
9292 }
9293 routeMsg(msg | "]]")
9294 } else {
9295 routeMsg("[\"EVENT\"," | signed | "]")
9296 }
9297 logActivity("publish", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
9298 published := nostr.ParseEvent(signed)
9299 if published != nil {
9300 insertPublishedNote(published)
9301 }
9302 dom.SetProperty(composeTextarea, "value", "")
9303 dom.SetStyle(composePreview, "display", "none")
9304 dom.SetStyle(composeTextarea, "display", "block")
9305 composeRelayPopOpen = false
9306 dom.SetStyle(composeRelayPopEl, "display", "none")
9307 dom.SetProperty(contentArea, "scrollTop", "0")
9308 switchPage("feed")
9309 })
9310 }))
9311 dom.AppendChild(btnRow, pubBtn)
9312 dom.AppendChild(composePage, btnRow)
9313
9314 // Emoji autocomplete popup.
9315 emojiAutoEl = dom.CreateElement("div")
9316 dom.SetStyle(emojiAutoEl, "display", "none")
9317 dom.SetStyle(emojiAutoEl, "position", "fixed")
9318 dom.SetStyle(emojiAutoEl, "background", "var(--bg)")
9319 dom.SetStyle(emojiAutoEl, "border", "1px solid var(--border)")
9320 dom.SetStyle(emojiAutoEl, "borderRadius", "6px")
9321 dom.SetStyle(emojiAutoEl, "padding", "4px")
9322 dom.SetStyle(emojiAutoEl, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
9323 dom.SetStyle(emojiAutoEl, "zIndex", "2000")
9324 dom.SetStyle(emojiAutoEl, "maxHeight", "200px")
9325 dom.SetStyle(emojiAutoEl, "overflowY", "auto")
9326 dom.SetStyle(emojiAutoEl, "minWidth", "200px")
9327 dom.AppendChild(dom.Body(), emojiAutoEl)
9328
9329 wireEmojiHandler(composeTextarea)
9330 wireBlossomHandlers(composeTextarea)
9331 wireMentionHandler(composeTextarea)
9332 }
9333
9334 func wireEmojiHandler(ta dom.Element) {
9335 dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
9336 checkEmojiAutocomplete(ta)
9337 }))
9338 }
9339
9340 func wireBlossomHandlers(ta dom.Element) {
9341 upload := func(b64 string, mime string) {
9342 blossomUpload(b64, mime, ta)
9343 }
9344 dom.OnPasteImage(ta, upload)
9345 dom.OnDropImage(ta, upload)
9346 }
9347
9348 func blossomUpload(b64data string, mime string, ta dom.Element) {
9349 if blossomServerURL == "" {
9350 dom.ConsoleLog("blossom: no server configured")
9351 return
9352 }
9353 raw := helpers.Base64Decode(b64data)
9354 if len(raw) == 0 {
9355 return
9356 }
9357 hash := sha256.Sum(raw)
9358 hashHex := helpers.HexEncode(hash[:])
9359 ext := ".bin"
9360 if mime == "image/png" {
9361 ext = ".png"
9362 } else if mime == "image/jpeg" || mime == "image/jpg" {
9363 ext = ".jpg"
9364 } else if mime == "image/gif" {
9365 ext = ".gif"
9366 } else if mime == "image/webp" {
9367 ext = ".webp"
9368 } else if mime == "image/svg+xml" {
9369 ext = ".svg"
9370 } else if mime == "video/mp4" {
9371 ext = ".mp4"
9372 } else if mime == "video/webm" {
9373 ext = ".webm"
9374 } else if mime == "video/quicktime" {
9375 ext = ".mov"
9376 } else if mime == "video/x-matroska" {
9377 ext = ".mkv"
9378 }
9379 placeholder := "\xe2\x8f\xb3 uploading to " | blossomServerURL | "..."
9380 val := dom.GetProperty(ta, "value")
9381 if len(val) > 0 && val[len(val)-1] != '\n' {
9382 val = val | "\n"
9383 }
9384 dom.SetProperty(ta, "value", val | placeholder)
9385 ts := dom.NowSeconds()
9386 expiry := ts + 300
9387 tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
9388 unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
9389 "\",\"tags\":" | tags |
9390 ",\"created_at\":" | i64toa(ts) |
9391 ",\"pubkey\":\"" | pubhex | "\"}"
9392 signer.SignEvent(unsigned, func(signed string) {
9393 if signed == "" {
9394 dom.ConsoleLog("blossom: sign failed")
9395 blossomReplacePlaceholder(ta, placeholder, "")
9396 return
9397 }
9398 authB64 := helpers.Base64Encode([]byte(signed))
9399 authHeader := "Nostr " | authB64
9400 url := blossomServerURL
9401 if len(url) > 0 && url[len(url)-1] != '/' {
9402 url = url | "/"
9403 }
9404 url = url | "upload"
9405 dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
9406 if resp == "" {
9407 dom.ConsoleLog("blossom: upload failed")
9408 blossomReplacePlaceholder(ta, placeholder, "")
9409 return
9410 }
9411 blobURL := helpers.JsonGetString(resp, "url")
9412 if blobURL == "" {
9413 blobURL = hashHex | ext
9414 }
9415 if len(blobURL) < 4 || blobURL[:4] != "http" {
9416 base := blossomOrigin(blossomServerURL)
9417 if len(blobURL) > 0 && blobURL[0] == '/' {
9418 blobURL = base | blobURL
9419 } else {
9420 blobURL = base | "/" | blobURL
9421 }
9422 }
9423 blossomReplacePlaceholder(ta, placeholder, blobURL)
9424 })
9425 })
9426 }
9427
9428 func blossomOrigin(u string) (s string) {
9429 slashes := 0
9430 for i := 0; i < len(u); i++ {
9431 if u[i] == '/' {
9432 slashes++
9433 if slashes == 3 {
9434 return u[:i]
9435 }
9436 }
9437 }
9438 return u
9439 }
9440
9441 func blossomReplacePlaceholder(ta dom.Element, placeholder, replacement string) {
9442 val := dom.GetProperty(ta, "value")
9443 idx := -1
9444 plen := len(placeholder)
9445 for i := 0; i <= len(val)-plen; i++ {
9446 if val[i:i+plen] == placeholder {
9447 idx = i
9448 break
9449 }
9450 }
9451 if idx >= 0 {
9452 dom.SetProperty(ta, "value", val[:idx] | replacement | val[idx+plen:])
9453 } else if replacement != "" {
9454 if len(val) > 0 && val[len(val)-1] != '\n' {
9455 val = val | "\n"
9456 }
9457 dom.SetProperty(ta, "value", val | replacement | "\n")
9458 }
9459 }
9460
9461 func blossomUploadToInput(b64data string, mime string, input dom.Element) {
9462 if blossomServerURL == "" {
9463 return
9464 }
9465 raw := helpers.Base64Decode(b64data)
9466 if len(raw) == 0 {
9467 return
9468 }
9469 hash := sha256.Sum(raw)
9470 hashHex := helpers.HexEncode(hash[:])
9471 ext := ".bin"
9472 if mime == "image/png" {
9473 ext = ".png"
9474 } else if mime == "image/jpeg" || mime == "image/jpg" {
9475 ext = ".jpg"
9476 } else if mime == "image/gif" {
9477 ext = ".gif"
9478 } else if mime == "image/webp" {
9479 ext = ".webp"
9480 } else if mime == "image/svg+xml" {
9481 ext = ".svg"
9482 } else if mime == "video/mp4" {
9483 ext = ".mp4"
9484 } else if mime == "video/webm" {
9485 ext = ".webm"
9486 } else if mime == "video/quicktime" {
9487 ext = ".mov"
9488 } else if mime == "video/x-matroska" {
9489 ext = ".mkv"
9490 }
9491 dom.SetProperty(input, "value", "\xe2\x8f\xb3 uploading...")
9492 ts := dom.NowSeconds()
9493 expiry := ts + 300
9494 tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
9495 unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
9496 "\",\"tags\":" | tags |
9497 ",\"created_at\":" | i64toa(ts) |
9498 ",\"pubkey\":\"" | pubhex | "\"}"
9499 signer.SignEvent(unsigned, func(signed string) {
9500 if signed == "" {
9501 dom.SetProperty(input, "value", "")
9502 return
9503 }
9504 authB64 := helpers.Base64Encode([]byte(signed))
9505 authHeader := "Nostr " | authB64
9506 url := blossomServerURL
9507 if len(url) > 0 && url[len(url)-1] != '/' {
9508 url = url | "/"
9509 }
9510 url = url | "upload"
9511 dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
9512 if resp == "" {
9513 dom.SetProperty(input, "value", "")
9514 return
9515 }
9516 blobURL := helpers.JsonGetString(resp, "url")
9517 if blobURL == "" {
9518 blobURL = hashHex | ext
9519 }
9520 if len(blobURL) < 4 || blobURL[:4] != "http" {
9521 base := blossomOrigin(blossomServerURL)
9522 if len(blobURL) > 0 && blobURL[0] == '/' {
9523 blobURL = base | blobURL
9524 } else {
9525 blobURL = base | "/" | blobURL
9526 }
9527 }
9528 dom.SetProperty(input, "value", blobURL)
9529 })
9530 })
9531 }
9532
9533 type mentionMatch struct {
9534 pk string
9535 name string
9536 pic string
9537 }
9538
9539 func mentionSearch(query string) (ss []mentionMatch) {
9540 q := toLower(query)
9541 var results []mentionMatch
9542 for pk, content := range profileContentCache {
9543 if len(results) >= 10 {
9544 break
9545 }
9546 name := helpers.JsonGetString(content, "name")
9547 dname := helpers.JsonGetString(content, "display_name")
9548 nl := toLower(name)
9549 dl := toLower(dname)
9550 if (len(nl) > 0 && strIndex(nl, q) >= 0) || (len(dl) > 0 && strIndex(dl, q) >= 0) {
9551 label := name
9552 if len(label) == 0 {
9553 label = dname
9554 }
9555 if len(label) == 0 {
9556 continue
9557 }
9558 pic, _ := authorPics[pk]
9559 results = append(results, mentionMatch{pk: pk, name: label, pic: pic})
9560 }
9561 }
9562 return results
9563 }
9564
9565 func initMentionPopup() {
9566 mentionPopup = dom.CreateElement("div")
9567 dom.SetStyle(mentionPopup, "display", "none")
9568 dom.SetStyle(mentionPopup, "position", "fixed")
9569 dom.SetStyle(mentionPopup, "background", "var(--bg)")
9570 dom.SetStyle(mentionPopup, "border", "1px solid var(--border)")
9571 dom.SetStyle(mentionPopup, "borderRadius", "6px")
9572 dom.SetStyle(mentionPopup, "padding", "4px")
9573 dom.SetStyle(mentionPopup, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
9574 dom.SetStyle(mentionPopup, "zIndex", "3000")
9575 dom.SetStyle(mentionPopup, "maxHeight", "200px")
9576 dom.SetStyle(mentionPopup, "overflowY", "auto")
9577 dom.SetStyle(mentionPopup, "minWidth", "220px")
9578 dom.AppendChild(dom.Body(), mentionPopup)
9579 }
9580
9581 func showMentionPopup(ta dom.Element, atPos int32, query string) {
9582 matches := mentionSearch(query)
9583 if len(matches) == 0 {
9584 closeMentionPopup()
9585 return
9586 }
9587 mentionTA = ta
9588 mentionQuery = query
9589 mentionAtPos = atPos
9590 mentionItems = matches
9591 mentionSelIdx = 0
9592
9593 clearChildren(mentionPopup)
9594 for i, m := range matches {
9595 row := dom.CreateElement("div")
9596 dom.SetStyle(row, "display", "flex")
9597 dom.SetStyle(row, "alignItems", "center")
9598 dom.SetStyle(row, "gap", "8px")
9599 dom.SetStyle(row, "padding", "4px 8px")
9600 dom.SetStyle(row, "cursor", "pointer")
9601 dom.SetStyle(row, "borderRadius", "4px")
9602 dom.SetStyle(row, "fontSize", "13px")
9603 if i == 0 {
9604 dom.SetStyle(row, "background", "var(--bg2)")
9605 }
9606
9607 if len(m.pic) > 0 {
9608 img := dom.CreateElement("img")
9609 setMediaSrc(img, m.pic)
9610 dom.SetStyle(img, "width", "24px")
9611 dom.SetStyle(img, "height", "24px")
9612 dom.SetStyle(img, "borderRadius", "50%")
9613 dom.SetStyle(img, "objectFit", "cover")
9614 dom.SetStyle(img, "flexShrink", "0")
9615 dom.AppendChild(row, img)
9616 } else {
9617 placeholder := dom.CreateElement("div")
9618 dom.SetStyle(placeholder, "width", "24px")
9619 dom.SetStyle(placeholder, "height", "24px")
9620 dom.SetStyle(placeholder, "borderRadius", "50%")
9621 dom.SetStyle(placeholder, "background", "var(--border)")
9622 dom.SetStyle(placeholder, "flexShrink", "0")
9623 dom.AppendChild(row, placeholder)
9624 }
9625
9626 nameSpan := dom.CreateElement("span")
9627 dom.SetTextContent(nameSpan, m.name)
9628 dom.AppendChild(row, nameSpan)
9629
9630 npubShort := helpers.EncodeNpub(helpers.HexDecode(m.pk))
9631 if len(npubShort) > 16 {
9632 npubShort = npubShort[:12] | "..."
9633 }
9634 npubSpan := dom.CreateElement("span")
9635 dom.SetTextContent(npubSpan, npubShort)
9636 dom.SetStyle(npubSpan, "color", "var(--muted)")
9637 dom.SetStyle(npubSpan, "fontSize", "11px")
9638 dom.AppendChild(row, npubSpan)
9639
9640 dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
9641 dom.SetStyle(row, "background", "var(--bg2)")
9642 }))
9643 dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
9644 if mentionSelIdx >= 0 && mentionSelIdx < len(mentionItems) {
9645 dom.SetStyle(row, "background", "transparent")
9646 }
9647 }))
9648
9649 capturedPK := m.pk
9650 dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
9651 insertMention(capturedPK)
9652 }))
9653
9654 dom.AppendChild(mentionPopup, row)
9655 }
9656
9657 positionMentionPopup(ta)
9658 dom.SetStyle(mentionPopup, "display", "block")
9659 }
9660
9661 func positionMentionPopup(ta dom.Element) {
9662 dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
9663 vh := dom.GetViewportHeight()
9664 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9665 val := dom.GetProperty(ta, "value")
9666 lineH := 22
9667 charsPerLine := width / 8
9668 if charsPerLine < 1 {
9669 charsPerLine = 40
9670 }
9671 linesBefore := 0
9672 col := 0
9673 for i := 0; i < cur && i < len(val); i++ {
9674 if val[i] == '\n' {
9675 linesBefore++
9676 col = 0
9677 } else {
9678 col++
9679 if col >= charsPerLine {
9680 linesBefore++
9681 col = 0
9682 }
9683 }
9684 }
9685 cursorY := top + 8 + linesBefore*lineH
9686 popupH := 200
9687 if cursorY < vh/2 {
9688 dom.SetStyle(mentionPopup, "top", itoa(cursorY+lineH) | "px")
9689 dom.SetStyle(mentionPopup, "bottom", "auto")
9690 } else {
9691 dom.SetStyle(mentionPopup, "bottom", itoa(vh-cursorY) | "px")
9692 dom.SetStyle(mentionPopup, "top", "auto")
9693 }
9694 _ = popupH
9695 xOff := left + col*8
9696 if xOff > right-220 {
9697 xOff = right - 220
9698 }
9699 if xOff < left {
9700 xOff = left
9701 }
9702 dom.SetStyle(mentionPopup, "left", itoa(xOff) | "px")
9703 })
9704 }
9705
9706 func insertMention(pk string) {
9707 if mentionTA == 0 {
9708 return
9709 }
9710 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
9711 val := dom.GetProperty(mentionTA, "value")
9712 cur := parseIntProp(dom.GetProperty(mentionTA, "selectionStart"))
9713 replacement := "nostr:" | npub | " "
9714 before := val[:mentionAtPos]
9715 after := val[cur:]
9716 dom.SetProperty(mentionTA, "value", before | replacement | after)
9717 newCur := itoa(len(before) + len(replacement))
9718 dom.SetProperty(mentionTA, "selectionStart", newCur)
9719 dom.SetProperty(mentionTA, "selectionEnd", newCur)
9720 closeMentionPopup()
9721 dom.Focus(mentionTA)
9722 }
9723
9724 func closeMentionPopup() {
9725 dom.SetStyle(mentionPopup, "display", "none")
9726 mentionItems = nil
9727 mentionSelIdx = 0
9728 mentionTA = 0
9729 }
9730
9731 func checkMentionAutocomplete(ta dom.Element) {
9732 val := dom.GetProperty(ta, "value")
9733 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9734 if cur > len(val) {
9735 cur = len(val)
9736 }
9737 atPos := -1
9738 for i := cur - 1; i >= 0; i-- {
9739 c := val[i]
9740 if c == '@' {
9741 atPos = i
9742 break
9743 }
9744 if c == ' ' || c == '\n' || c == '\t' {
9745 break
9746 }
9747 }
9748 if atPos < 0 || cur-atPos < 2 {
9749 closeMentionPopup()
9750 return
9751 }
9752 if atPos > 0 {
9753 prev := val[atPos-1]
9754 if prev != ' ' && prev != '\n' && prev != '\t' {
9755 closeMentionPopup()
9756 return
9757 }
9758 }
9759 query := val[atPos+1 : cur]
9760 showMentionPopup(ta, atPos, query)
9761 }
9762
9763 func wireMentionHandler(ta dom.Element) {
9764 dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
9765 checkMentionAutocomplete(ta)
9766 }))
9767 dom.AddEventListener(ta, "blur", dom.RegisterCallback(func() {
9768 dom.SetTimeout(func() { closeMentionPopup() }, 200)
9769 }))
9770 dom.OnKeydown(ta, func(key string, prevent func()) {
9771 if mentionTA == 0 || len(mentionItems) == 0 {
9772 if key == "Escape" {
9773 closeMentionPopup()
9774 }
9775 return
9776 }
9777 if key == "Escape" {
9778 closeMentionPopup()
9779 prevent()
9780 } else if key == "ArrowDown" {
9781 mentionSelIdx++
9782 if mentionSelIdx >= len(mentionItems) {
9783 mentionSelIdx = 0
9784 }
9785 highlightMentionRow()
9786 prevent()
9787 } else if key == "ArrowUp" {
9788 mentionSelIdx--
9789 if mentionSelIdx < 0 {
9790 mentionSelIdx = len(mentionItems) - 1
9791 }
9792 highlightMentionRow()
9793 prevent()
9794 } else if key == "Enter" || key == "Tab" {
9795 insertMention(mentionItems[mentionSelIdx].pk)
9796 prevent()
9797 }
9798 })
9799 }
9800
9801 func highlightMentionRow() {
9802 child := dom.FirstElementChild(mentionPopup)
9803 idx := 0
9804 for child != 0 {
9805 if idx == mentionSelIdx {
9806 dom.SetStyle(child, "background", "var(--bg2)")
9807 } else {
9808 dom.SetStyle(child, "background", "transparent")
9809 }
9810 child = dom.NextSibling(child)
9811 idx++
9812 }
9813 }
9814
9815 func checkEmojiAutocomplete(ta dom.Element) {
9816 val := dom.GetProperty(ta, "value")
9817 curStr := dom.GetProperty(ta, "selectionStart")
9818 cur := parseIntProp(curStr)
9819 if cur > len(val) {
9820 cur = len(val)
9821 }
9822
9823 searchFrom := cur - 1
9824 closingColon := false
9825 if searchFrom >= 0 && val[searchFrom] == ':' {
9826 closingColon = true
9827 searchFrom--
9828 }
9829 colonPos := -1
9830 for i := searchFrom; i >= 0; i-- {
9831 c := val[i]
9832 if c == ':' {
9833 colonPos = i
9834 break
9835 }
9836 if c == ' ' || c == '\n' || c == '\t' {
9837 break
9838 }
9839 }
9840
9841 if colonPos < 0 || cur-colonPos < 2 {
9842 closeEmojiAuto()
9843 return
9844 }
9845
9846 // Extract the text between the opening colon and cursor (excluding closing colon).
9847 queryEnd := cur
9848 if closingColon {
9849 queryEnd = cur - 1
9850 }
9851 query := val[colonPos+1 : queryEnd]
9852
9853 // If user typed a closing ':', auto-replace if exact match.
9854 if closingColon && len(query) > 0 {
9855 code := ":" | query | ":"
9856 if emoji, ok := emojiByName[code]; ok {
9857 before := val[:colonPos]
9858 after := val[cur:]
9859 newVal := before | emoji | after
9860 dom.SetProperty(ta, "value", newVal)
9861 newCur := itoa(len(before) + len(emoji))
9862 dom.SetProperty(ta, "selectionStart", newCur)
9863 dom.SetProperty(ta, "selectionEnd", newCur)
9864 closeEmojiAuto()
9865 return
9866 }
9867 }
9868
9869 // Filter matching emoji.
9870 var matches []emojiEntry
9871 q := toLower(query)
9872 for _, e := range emojiList {
9873 if len(matches) >= 10 {
9874 break
9875 }
9876 if len(e.name) >= len(q) && hasPrefix(e.name, q) {
9877 matches = append(matches, e)
9878 }
9879 }
9880 // Also match substring.
9881 if len(matches) < 10 {
9882 for _, e := range emojiList {
9883 if len(matches) >= 10 {
9884 break
9885 }
9886 if !hasPrefix(e.name, q) && strIndex(e.name, q) >= 0 {
9887 matches = append(matches, e)
9888 }
9889 }
9890 }
9891
9892 if len(matches) == 0 {
9893 closeEmojiAuto()
9894 return
9895 }
9896
9897 emojiAutoOpen = true
9898 emojiAutoStart = colonPos
9899 emojiAutoTA = ta
9900 clearChildren(emojiAutoEl)
9901
9902 for _, m := range matches {
9903 row := dom.CreateElement("div")
9904 dom.SetStyle(row, "display", "flex")
9905 dom.SetStyle(row, "alignItems", "center")
9906 dom.SetStyle(row, "gap", "6px")
9907 dom.SetStyle(row, "padding", "4px 8px")
9908 dom.SetStyle(row, "cursor", "pointer")
9909 dom.SetStyle(row, "borderRadius", "4px")
9910 dom.SetStyle(row, "fontSize", "13px")
9911
9912 emojiSpan := dom.CreateElement("span")
9913 dom.SetTextContent(emojiSpan, m.emoji)
9914 dom.SetStyle(emojiSpan, "fontSize", "18px")
9915 dom.AppendChild(row, emojiSpan)
9916
9917 nameSpan := dom.CreateElement("span")
9918 dom.SetTextContent(nameSpan, ":" | m.name | ":")
9919 dom.SetStyle(nameSpan, "color", "var(--muted)")
9920 dom.AppendChild(row, nameSpan)
9921
9922 dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
9923 dom.SetStyle(row, "background", "var(--bg2)")
9924 }))
9925 dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
9926 dom.SetStyle(row, "background", "transparent")
9927 }))
9928
9929 capturedEmoji := m.emoji
9930 dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
9931 insertEmojiAt(capturedEmoji)
9932 }))
9933
9934 dom.AppendChild(emojiAutoEl, row)
9935 }
9936
9937 dom.SetStyle(emojiAutoEl, "display", "block")
9938 positionEmojiPopup()
9939 }
9940
9941 func insertEmojiAt(emoji string) {
9942 ta := emojiAutoTA
9943 if ta == 0 {
9944 return
9945 }
9946 val := dom.GetProperty(ta, "value")
9947 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9948 before := val[:emojiAutoStart]
9949 after := val[cur:]
9950 newVal := before | emoji | after
9951 dom.SetProperty(ta, "value", newVal)
9952 newCur := itoa(len(before) + len(emoji))
9953 dom.SetProperty(ta, "selectionStart", newCur)
9954 dom.SetProperty(ta, "selectionEnd", newCur)
9955 closeEmojiAuto()
9956 dom.Focus(ta)
9957 }
9958
9959 func closeEmojiAuto() {
9960 if emojiAutoOpen {
9961 dom.SetStyle(emojiAutoEl, "display", "none")
9962 emojiAutoOpen = false
9963 }
9964 }
9965
9966 func positionEmojiPopup() {
9967 ta := emojiAutoTA
9968 if ta == 0 {
9969 return
9970 }
9971 dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
9972 vh := dom.GetViewportHeight()
9973 cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9974 val := dom.GetProperty(ta, "value")
9975 lineH := 22
9976 charsPerLine := width / 8
9977 if charsPerLine < 1 {
9978 charsPerLine = 40
9979 }
9980 linesBefore := 0
9981 col := 0
9982 for i := 0; i < cur && i < len(val); i++ {
9983 if val[i] == '\n' {
9984 linesBefore++
9985 col = 0
9986 } else {
9987 col++
9988 if col >= charsPerLine {
9989 linesBefore++
9990 col = 0
9991 }
9992 }
9993 }
9994 cursorY := top + 8 + linesBefore*lineH
9995 if cursorY < vh/2 {
9996 dom.SetStyle(emojiAutoEl, "top", itoa(cursorY+lineH) | "px")
9997 dom.SetStyle(emojiAutoEl, "bottom", "auto")
9998 } else {
9999 dom.SetStyle(emojiAutoEl, "bottom", itoa(vh-cursorY) | "px")
10000 dom.SetStyle(emojiAutoEl, "top", "auto")
10001 }
10002 xOff := left + col*8
10003 if xOff > right-220 {
10004 xOff = right - 220
10005 }
10006 if xOff < left {
10007 xOff = left
10008 }
10009 dom.SetStyle(emojiAutoEl, "left", itoa(xOff) | "px")
10010 })
10011 }
10012
10013 func populateComposeRelays() {
10014 populateRelayPopover(composeRelayPopEl, nil)
10015 }
10016
10017 func populateRelayPopover(target dom.Element, onChanged func()) {
10018 renderRows := func() {
10019 clearChildren(target)
10020 populateRelayRows(target, onChanged)
10021 if onChanged != nil {
10022 onChanged()
10023 }
10024 }
10025 if composeRelayChecks == nil {
10026 composeRelayChecks = map[string]bool{}
10027 for _, u := range relayURLs {
10028 composeRelayChecks[u] = true
10029 }
10030 dom.IDBGet("cache", "compose_relays", func(val string) {
10031 if val != "" {
10032 composeRelayChecks = map[string]bool{}
10033 start := 0
10034 for i := 0; i <= len(val); i++ {
10035 if i == len(val) || val[i] == ',' {
10036 if i > start {
10037 composeRelayChecks[val[start:i]] = true
10038 }
10039 start = i + 1
10040 }
10041 }
10042 }
10043 renderRows()
10044 })
10045 return
10046 }
10047 renderRows()
10048 }
10049
10050 func populateRelayRows(target dom.Element, onChanged func()) {
10051 for idx, u := range relayURLs {
10052 row := dom.CreateElement("div")
10053 dom.SetStyle(row, "display", "flex")
10054 dom.SetStyle(row, "alignItems", "center")
10055 dom.SetStyle(row, "gap", "6px")
10056 dom.SetStyle(row, "padding", "0 4px")
10057 dom.SetStyle(row, "margin", "2px 0")
10058 dom.SetStyle(row, "cursor", "pointer")
10059 dom.SetStyle(row, "fontSize", "12px")
10060 dom.SetStyle(row, "overflow", "hidden")
10061
10062 relayURL := u
10063 selected := composeRelayChecks[u]
10064
10065 applyRowStyle := func(sel bool) {
10066 if sel {
10067 dom.SetStyle(row, "background", "var(--accent)")
10068 dom.SetStyle(row, "color", "#fff")
10069 } else {
10070 dom.SetStyle(row, "background", "transparent")
10071 dom.SetStyle(row, "color", "var(--fg)")
10072 }
10073 }
10074 applyRowStyle(selected)
10075
10076 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
10077 composeRelayChecks[relayURL] = !composeRelayChecks[relayURL]
10078 applyRowStyle(composeRelayChecks[relayURL])
10079 saveComposeRelaySelection()
10080 if onChanged != nil {
10081 onChanged()
10082 }
10083 }))
10084
10085 label := dom.CreateElement("span")
10086 dom.SetTextContent(label, stripScheme(u))
10087 dom.SetStyle(label, "overflow", "hidden")
10088 dom.SetStyle(label, "textOverflow", "ellipsis")
10089 dom.SetStyle(label, "whiteSpace", "nowrap")
10090 dom.SetStyle(label, "flex", "1")
10091 dom.SetStyle(label, "minWidth", "0")
10092 dom.AppendChild(row, label)
10093
10094 caps := relayCaps[idx]
10095 if caps.fetchedOK {
10096 if !hasNIP(caps.supportedNIPs, 42) {
10097 dom.AppendChild(row, makeRelayBadge("!42"))
10098 }
10099 if !hasNIP(caps.supportedNIPs, 70) {
10100 dom.AppendChild(row, makeRelayBadge("!70"))
10101 }
10102 }
10103
10104 dom.AppendChild(target, row)
10105 }
10106 }
10107
10108 func selectedRelaysLeaky() (ok bool) {
10109 if composeRelayChecks == nil {
10110 return false
10111 }
10112 for i, u := range relayURLs {
10113 if !composeRelayChecks[u] {
10114 continue
10115 }
10116 caps := relayCaps[i]
10117 if !caps.fetchedOK {
10118 continue
10119 }
10120 if !hasNIP(caps.supportedNIPs, 42) || !hasNIP(caps.supportedNIPs, 70) {
10121 return true
10122 }
10123 }
10124 return false
10125 }
10126
10127 func updateRelaySelBtn(btn dom.Element, open bool) {
10128 if open {
10129 dom.SetTextContent(btn, "relays \u2715")
10130 dom.SetStyle(btn, "background", "var(--accent)")
10131 dom.SetStyle(btn, "color", "#fff")
10132 } else {
10133 dom.SetTextContent(btn, "relays")
10134 dom.SetStyle(btn, "background", "transparent")
10135 dom.SetStyle(btn, "color", "var(--fg)")
10136 }
10137 }
10138
10139 func updateRelayWarnBadge(badge dom.Element) {
10140 if selectedRelaysLeaky() {
10141 dom.SetStyle(badge, "display", "inline-block")
10142 } else {
10143 dom.SetStyle(badge, "display", "none")
10144 }
10145 }
10146
10147 func makeRelayWarnBadge() (e dom.Element) {
10148 badge := dom.CreateElement("span")
10149 dom.SetTextContent(badge, "\u26a0")
10150 dom.SetStyle(badge, "display", "none")
10151 dom.SetStyle(badge, "fontSize", "16px")
10152 dom.SetStyle(badge, "lineHeight", "1")
10153 dom.SetStyle(badge, "background", "#e67e00")
10154 dom.SetStyle(badge, "borderRadius", "50%")
10155 dom.SetStyle(badge, "width", "22px")
10156 dom.SetStyle(badge, "height", "22px")
10157 dom.SetStyle(badge, "textAlign", "center")
10158 dom.SetStyle(badge, "cursor", "default")
10159 dom.SetAttribute(badge, "title", t("relay_leak_warn"))
10160 updateRelayWarnBadge(badge)
10161 return badge
10162 }
10163
10164 func saveComposeRelaySelection() {
10165 val := ""
10166 for u, sel := range composeRelayChecks {
10167 if sel {
10168 if val != "" {
10169 val = val | ","
10170 }
10171 val = val | u
10172 }
10173 }
10174 dom.IDBPut("cache", "compose_relays", val)
10175 }
10176
10177 func composeSelectedRelays() (ss []string) {
10178 if composeRelayChecks == nil {
10179 return relayURLs
10180 }
10181 var out []string
10182 for u, sel := range composeRelayChecks {
10183 if sel {
10184 out = append(out, u)
10185 }
10186 }
10187 // If nothing selected, fall back to all relays.
10188 if len(out) == 0 {
10189 return relayURLs
10190 }
10191 return out
10192 }
10193
10194 func insertPublishedNote(ev *nostr.Event) {
10195 setEvent(ev.ID, ev)
10196 fillEmbed(ev)
10197
10198 // Thread view: insert and re-render, scrolling new reply into view.
10199 if threadOpen {
10200 threadEvents[ev.ID] = ev
10201 threadFocusID = ev.ID
10202 dom.ReplaceState("/t/" | threadRootID | "#" | ev.ID)
10203 renderThreadTree()
10204 }
10205
10206 // Feed: prepend at top. renderNote must run before seenEvents is set,
10207 // because renderNote's dedup guard checks seenEvents.
10208 trackOldestTs(ev)
10209 renderNote(ev)
10210 seenEvents[ev.ID] = true
10211 }
10212
10213 // --- Emoji picker ---
10214
10215 func showEmojiPicker(ev *nostr.Event, anchor dom.Element) {
10216 _ = anchor
10217 if !signer.HasSigner() || pubhex == "" {
10218 return
10219 }
10220 if emojiOpen {
10221 closeEmojiPicker()
10222 return
10223 }
10224 emojiOpen = true
10225 emojiTargetEv = ev
10226
10227 // Full-screen scrim.
10228 overlay := dom.CreateElement("div")
10229 dom.SetStyle(overlay, "position", "fixed")
10230 dom.SetStyle(overlay, "top", "0")
10231 dom.SetStyle(overlay, "left", "0")
10232 dom.SetStyle(overlay, "width", "100%")
10233 dom.SetStyle(overlay, "height", "100%")
10234 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
10235 dom.SetStyle(overlay, "zIndex", "400")
10236 dom.SetStyle(overlay, "display", "flex")
10237 dom.SetStyle(overlay, "alignItems", "center")
10238 dom.SetStyle(overlay, "justifyContent", "center")
10239 dom.AddSelfEventListener(overlay, "click", dom.RegisterCallback(func() {
10240 closeEmojiPicker()
10241 }))
10242 emojiOverlay = overlay
10243
10244 modal := dom.CreateElement("div")
10245 dom.SetStyle(modal, "background", "var(--bg)")
10246 dom.SetStyle(modal, "border", "1px solid var(--border)")
10247 dom.SetStyle(modal, "borderRadius", "8px")
10248 dom.SetStyle(modal, "padding", "12px")
10249 dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.4)")
10250 dom.SetStyle(modal, "width", "320px")
10251 dom.SetStyle(modal, "maxWidth", "90vw")
10252 dom.SetStyle(modal, "maxHeight", "60vh")
10253 dom.SetStyle(modal, "display", "flex")
10254 dom.SetStyle(modal, "flexDirection", "column")
10255 dom.SetStyle(modal, "gap", "8px")
10256
10257 // Search input.
10258 search := dom.CreateElement("input")
10259 dom.SetAttribute(search, "type", "text")
10260 dom.SetAttribute(search, "placeholder", "Search emoji...")
10261 dom.SetStyle(search, "width", "100%")
10262 dom.SetStyle(search, "padding", "6px 10px")
10263 dom.SetStyle(search, "border", "1px solid var(--border)")
10264 dom.SetStyle(search, "borderRadius", "4px")
10265 dom.SetStyle(search, "background", "var(--bg)")
10266 dom.SetStyle(search, "color", "var(--fg)")
10267 dom.SetStyle(search, "fontSize", "14px")
10268 dom.SetStyle(search, "boxSizing", "border-box")
10269 dom.SetStyle(search, "flexShrink", "0")
10270 dom.AppendChild(modal, search)
10271
10272 // Emoji grid - scrollable.
10273 grid := dom.CreateElement("div")
10274 dom.SetStyle(grid, "display", "flex")
10275 dom.SetStyle(grid, "flexWrap", "wrap")
10276 dom.SetStyle(grid, "gap", "2px")
10277 dom.SetStyle(grid, "overflowY", "auto")
10278 dom.SetStyle(grid, "flex", "1")
10279 dom.SetStyle(grid, "minHeight", "0")
10280 dom.SetStyle(grid, "alignContent", "flex-start")
10281 dom.SetStyle(grid, "WebkitOverflowScrolling", "touch")
10282
10283 capturedEv := ev
10284 seen := map[string]bool{}
10285 for _, entry := range emojiList {
10286 if seen[entry.emoji] {
10287 continue
10288 }
10289 seen[entry.emoji] = true
10290 cell := dom.CreateElement("span")
10291 dom.SetTextContent(cell, entry.emoji)
10292 dom.SetStyle(cell, "fontSize", "22px")
10293 dom.SetStyle(cell, "cursor", "pointer")
10294 dom.SetStyle(cell, "padding", "4px")
10295 dom.SetStyle(cell, "borderRadius", "4px")
10296 dom.SetStyle(cell, "textAlign", "center")
10297 dom.SetStyle(cell, "width", "36px")
10298 dom.SetStyle(cell, "height", "36px")
10299 dom.SetStyle(cell, "lineHeight", "36px")
10300 dom.SetStyle(cell, "display", "inline-flex")
10301 dom.SetStyle(cell, "alignItems", "center")
10302 dom.SetStyle(cell, "justifyContent", "center")
10303 dom.SetAttribute(cell, "title", entry.name)
10304 capturedEmoji := entry.emoji
10305 dom.AddEventListener(cell, "mouseenter", dom.RegisterCallback(func() {
10306 dom.SetStyle(cell, "background", "var(--border)")
10307 }))
10308 dom.AddEventListener(cell, "mouseleave", dom.RegisterCallback(func() {
10309 dom.SetStyle(cell, "background", "transparent")
10310 }))
10311 dom.AddEventListener(cell, "click", dom.RegisterCallback(func() {
10312 publishReaction(capturedEv, capturedEmoji)
10313 closeEmojiPicker()
10314 }))
10315 dom.AppendChild(grid, cell)
10316 }
10317 dom.AppendChild(modal, grid)
10318
10319 // Search filtering - match against all names for each emoji.
10320 capturedGrid := grid
10321 dom.AddEventListener(search, "input", dom.RegisterCallback(func() {
10322 q := toLower(dom.GetProperty(search, "value"))
10323 child := dom.FirstElementChild(capturedGrid)
10324 for child != 0 {
10325 next := dom.NextSibling(child)
10326 title := dom.GetProperty(child, "title")
10327 if q == "" || contains(title, q) {
10328 dom.SetStyle(child, "display", "inline-flex")
10329 } else {
10330 dom.SetStyle(child, "display", "none")
10331 }
10332 child = next
10333 }
10334 }))
10335
10336 dom.AppendChild(overlay, modal)
10337 dom.AppendChild(dom.Body(), overlay)
10338 dom.Focus(search)
10339 }
10340
10341 func closeEmojiPicker() {
10342 if emojiOverlay != 0 {
10343 dom.RemoveChild(dom.Body(), emojiOverlay)
10344 emojiOverlay = 0
10345 }
10346 emojiOpen = false
10347 emojiTargetEv = nil
10348 }
10349
10350 func publishReaction(ev *nostr.Event, emoji string) {
10351 if !signer.HasSigner() || pubhex == "" {
10352 return
10353 }
10354 relay := ""
10355 if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
10356 relay = urls[0]
10357 }
10358 tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
10359 ts := dom.NowSeconds()
10360 unsigned := "{\"kind\":7,\"content\":" | jstr(emoji) |
10361 ",\"tags\":" | tags |
10362 ",\"created_at\":" | i64toa(ts) |
10363 ",\"pubkey\":\"" | pubhex | "\"}"
10364 capturedEvID := ev.ID
10365 capturedEmoji := emoji
10366 signer.SignEvent(unsigned, func(signed string) {
10367 if signed != "" {
10368 selected := composeSelectedRelays()
10369 if len(selected) > 0 {
10370 msg := "[\"PUBLISH_TO\"," | signed | ",["
10371 for i, u := range selected {
10372 if i > 0 {
10373 msg = msg | ","
10374 }
10375 msg = msg | jstr(u)
10376 }
10377 routeMsg(msg | "]]")
10378 relayList := ""
10379 for j, u := range selected {
10380 if j > 0 {
10381 relayList = relayList | "\n"
10382 }
10383 relayList = relayList | u
10384 }
10385 logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "relays:\n" | relayList | "\n\nevent:\n" | signed)
10386 } else {
10387 routeMsg("[\"EVENT\"," | signed | "]")
10388 logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "no targeted relays\n\nevent:\n" | signed)
10389 }
10390 ri := noteReactionMap[capturedEvID]
10391 if ri == nil {
10392 ri = map[string]map[string]bool{}
10393 }
10394 pks := ri[capturedEmoji]
10395 if pks == nil {
10396 pks = map[string]bool{}
10397 }
10398 pks[pubhex] = true
10399 ri[capturedEmoji] = pks
10400 noteReactionMap[capturedEvID] = ri
10401 if el, ok := noteReactionEls[capturedEvID]; ok {
10402 fillReactionDisplay(el, capturedEvID)
10403 }
10404 }
10405 })
10406 }
10407
10408 func contains(s, sub string) (ok bool) {
10409 if len(sub) > len(s) {
10410 return false
10411 }
10412 for i := 0; i <= len(s)-len(sub); i++ {
10413 if s[i:i+len(sub)] == sub {
10414 return true
10415 }
10416 }
10417 return false
10418 }
10419
10420 // --- Settings page ---
10421
10422 func renderSettings() {
10423 clearChildren(settingsPage)
10424
10425 title := dom.CreateElement("h2")
10426 dom.SetTextContent(title, t("settings_title"))
10427 dom.SetStyle(title, "fontSize", "20px")
10428 dom.SetStyle(title, "marginBottom", "24px")
10429 dom.AppendChild(settingsPage, title)
10430
10431 // Language selector.
10432 langRow := dom.CreateElement("div")
10433 dom.SetStyle(langRow, "display", "flex")
10434 dom.SetStyle(langRow, "alignItems", "center")
10435 dom.SetStyle(langRow, "gap", "12px")
10436 dom.SetStyle(langRow, "marginBottom", "16px")
10437
10438 langLabel := dom.CreateElement("span")
10439 dom.SetTextContent(langLabel, t("lang_label"))
10440 dom.SetStyle(langLabel, "fontSize", "14px")
10441 dom.SetStyle(langLabel, "minWidth", "140px")
10442 dom.AppendChild(langRow, langLabel)
10443
10444 langSel := dom.CreateElement("select")
10445 dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
10446 dom.SetStyle(langSel, "fontSize", "13px")
10447 dom.SetStyle(langSel, "background", "var(--bg2)")
10448 dom.SetStyle(langSel, "color", "var(--fg)")
10449 dom.SetStyle(langSel, "border", "1px solid var(--border)")
10450 dom.SetStyle(langSel, "borderRadius", "4px")
10451 dom.SetStyle(langSel, "padding", "6px 12px")
10452 for code, name := range langNames {
10453 opt := dom.CreateElement("option")
10454 dom.SetAttribute(opt, "value", code)
10455 dom.SetTextContent(opt, name)
10456 if code == currentLang {
10457 dom.SetAttribute(opt, "selected", "selected")
10458 }
10459 dom.AppendChild(langSel, opt)
10460 }
10461 dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
10462 val := dom.GetProperty(langSel, "value")
10463 setLang(val)
10464 saveAppSettings()
10465 dom.SetTextContent(pageTitleEl, t("settings"))
10466 renderSettings()
10467 }))
10468 dom.AppendChild(langRow, langSel)
10469 dom.AppendChild(settingsPage, langRow)
10470
10471 // Theme selector.
10472 themeRow := dom.CreateElement("div")
10473 dom.SetStyle(themeRow, "display", "flex")
10474 dom.SetStyle(themeRow, "alignItems", "center")
10475 dom.SetStyle(themeRow, "gap", "12px")
10476 dom.SetStyle(themeRow, "marginBottom", "16px")
10477
10478 themeLabel := dom.CreateElement("span")
10479 dom.SetTextContent(themeLabel, t("theme_label"))
10480 dom.SetStyle(themeLabel, "fontSize", "14px")
10481 dom.SetStyle(themeLabel, "minWidth", "140px")
10482 dom.AppendChild(themeRow, themeLabel)
10483
10484 themeToggle := dom.CreateElement("button")
10485 if isDark {
10486 dom.SetTextContent(themeToggle, t("dark"))
10487 } else {
10488 dom.SetTextContent(themeToggle, t("light"))
10489 }
10490 dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
10491 dom.SetStyle(themeToggle, "fontSize", "13px")
10492 dom.SetStyle(themeToggle, "background", "var(--bg2)")
10493 dom.SetStyle(themeToggle, "color", "var(--fg)")
10494 dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
10495 dom.SetStyle(themeToggle, "borderRadius", "4px")
10496 dom.SetStyle(themeToggle, "padding", "6px 16px")
10497 dom.SetStyle(themeToggle, "cursor", "pointer")
10498 dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
10499 toggleTheme()
10500 if isDark {
10501 dom.SetTextContent(themeToggle, t("dark"))
10502 } else {
10503 dom.SetTextContent(themeToggle, t("light"))
10504 }
10505 saveAppSettings()
10506 }))
10507 dom.AppendChild(themeRow, themeToggle)
10508 dom.AppendChild(settingsPage, themeRow)
10509
10510 // Client tag setting.
10511 clientRow := dom.CreateElement("div")
10512 dom.SetStyle(clientRow, "display", "flex")
10513 dom.SetStyle(clientRow, "alignItems", "center")
10514 dom.SetStyle(clientRow, "gap", "12px")
10515 dom.SetStyle(clientRow, "marginBottom", "16px")
10516
10517 clientLabel := dom.CreateElement("span")
10518 dom.SetTextContent(clientLabel, "client tag")
10519 dom.SetStyle(clientLabel, "fontSize", "14px")
10520 dom.SetStyle(clientLabel, "minWidth", "140px")
10521 dom.AppendChild(clientRow, clientLabel)
10522
10523 clientCb := dom.CreateElement("input")
10524 dom.SetAttribute(clientCb, "type", "checkbox")
10525 if localstorage.GetItem("smesh-client-tag") != "0" {
10526 dom.SetProperty(clientCb, "checked", "true")
10527 }
10528 dom.AddEventListener(clientCb, "change", dom.RegisterCallback(func() {
10529 checked := dom.GetProperty(clientCb, "checked")
10530 if checked == "true" {
10531 localstorage.RemoveItem("smesh-client-tag")
10532 } else {
10533 localstorage.SetItem("smesh-client-tag", "0")
10534 }
10535 saveAppSettings()
10536 }))
10537 dom.AppendChild(clientRow, clientCb)
10538
10539 clientDesc := dom.CreateElement("span")
10540 dom.SetTextContent(clientDesc, "add smesh.lol tag to posts")
10541 dom.SetStyle(clientDesc, "fontSize", "12px")
10542 dom.SetStyle(clientDesc, "color", "var(--muted)")
10543 dom.AppendChild(clientRow, clientDesc)
10544
10545 dom.AppendChild(settingsPage, clientRow)
10546
10547 // Blossom server URL.
10548 blossomRow := dom.CreateElement("div")
10549 dom.SetStyle(blossomRow, "display", "flex")
10550 dom.SetStyle(blossomRow, "flexWrap", "wrap")
10551 dom.SetStyle(blossomRow, "alignItems", "center")
10552 dom.SetStyle(blossomRow, "gap", "12px")
10553 dom.SetStyle(blossomRow, "marginBottom", "16px")
10554
10555 blossomLabel := dom.CreateElement("span")
10556 dom.SetTextContent(blossomLabel, "blossom server")
10557 dom.SetStyle(blossomLabel, "fontSize", "14px")
10558 dom.SetStyle(blossomLabel, "minWidth", "140px")
10559 dom.AppendChild(blossomRow, blossomLabel)
10560
10561 blossomInput := dom.CreateElement("input")
10562 dom.SetAttribute(blossomInput, "type", "text")
10563 dom.SetAttribute(blossomInput, "placeholder", "https://blossom.example.com")
10564 blossomVal := blossomServerURL
10565 if blossomVal == "" {
10566 blossomVal = defaultBlossomURL()
10567 }
10568 dom.SetProperty(blossomInput, "value", blossomVal)
10569 dom.SetStyle(blossomInput, "fontFamily", "'Fira Code', monospace")
10570 dom.SetStyle(blossomInput, "fontSize", "13px")
10571 dom.SetStyle(blossomInput, "background", "var(--bg)")
10572 dom.SetStyle(blossomInput, "color", "var(--fg)")
10573 dom.SetStyle(blossomInput, "border", "1px solid var(--border)")
10574 dom.SetStyle(blossomInput, "borderRadius", "4px")
10575 dom.SetStyle(blossomInput, "padding", "6px 12px")
10576 dom.SetStyle(blossomInput, "flex", "1")
10577 dom.SetStyle(blossomInput, "minWidth", "200px")
10578 dom.AppendChild(blossomRow, blossomInput)
10579
10580 blossomSave := dom.CreateElement("button")
10581 dom.SetTextContent(blossomSave, "save")
10582 dom.SetStyle(blossomSave, "padding", "6px 16px")
10583 dom.SetStyle(blossomSave, "borderRadius", "4px")
10584 dom.SetStyle(blossomSave, "border", "1px solid var(--border)")
10585 dom.SetStyle(blossomSave, "background", "var(--accent)")
10586 dom.SetStyle(blossomSave, "color", "var(--bg)")
10587 dom.SetStyle(blossomSave, "cursor", "pointer")
10588 dom.SetStyle(blossomSave, "fontSize", "13px")
10589 dom.AddEventListener(blossomSave, "click", dom.RegisterCallback(func() {
10590 val := dom.GetProperty(blossomInput, "value")
10591 blossomServerURL = val
10592 if val == "" {
10593 localstorage.RemoveItem(lsKeyBlossomServer)
10594 } else {
10595 localstorage.SetItem(lsKeyBlossomServer, val)
10596 }
10597 saveAppSettings()
10598 dom.SetTextContent(blossomSave, "saved!")
10599 dom.SetTimeout(func() {
10600 dom.SetTextContent(blossomSave, "save")
10601 }, 1500)
10602 }))
10603 dom.AppendChild(blossomRow, blossomSave)
10604 dom.AppendChild(settingsPage, blossomRow)
10605
10606 // Invidious URL setting.
10607 appendServiceURLSetting(settingsPage, "invidious server", invidiousURL,
10608 defaultServiceURL("invidious"), lsKeyInvidiousURL, func(v string) { invidiousURL = v },
10609 "use invidious.smesh.lol")
10610
10611 // Campion (Bandcamp player) URL setting.
10612 appendServiceURLSetting(settingsPage, "campion (bandcamp)", campionURL,
10613 defaultServiceURL("campion"), lsKeyCampionURL, func(v string) { campionURL = v },
10614 "use campion.smesh.lol")
10615
10616 renderRelaysSettings()
10617 }
10618
10619 func addRelayURL(url string) {
10620 addRelay(url, true)
10621 sendWriteRelays()
10622 populateFeedSelect()
10623 }
10624
10625 type relayFreqEntry struct {
10626 url string
10627 freq int32
10628 }
10629
10630 // sortRelaysByFreq: relayFreq moved to Profile Worker; Shell returns empty.
10631 func sortRelaysByFreq() (ss []relayFreqEntry) { return nil }
10632
10633 // renderRelaysSettings renders the "Relays" section of the settings page:
10634 // active relays with capability badges, required-capability checkboxes, and
10635 // the blocklist. Called from renderSettings(). Read-only behavior at this
10636 // step: checkboxes persist to localStorage but no enforcement is applied.
10637 // appendServiceURLSetting appends a labelled URL input + "use X" prefill button + save button.
10638 // onSet is called with the new value when saved. prefillLabel is the text on the prefill button.
10639 func appendServiceURLSetting(parent dom.Element, label, current, defaultVal, lsKey string, onSet func(string), prefillLabel string) {
10640 row := dom.CreateElement("div")
10641 dom.SetStyle(row, "display", "flex")
10642 dom.SetStyle(row, "flexWrap", "wrap")
10643 dom.SetStyle(row, "alignItems", "center")
10644 dom.SetStyle(row, "gap", "12px")
10645 dom.SetStyle(row, "marginBottom", "16px")
10646
10647 lbl := dom.CreateElement("span")
10648 dom.SetTextContent(lbl, label)
10649 dom.SetStyle(lbl, "fontSize", "14px")
10650 dom.SetStyle(lbl, "minWidth", "160px")
10651 dom.AppendChild(row, lbl)
10652
10653 input := dom.CreateElement("input")
10654 dom.SetAttribute(input, "type", "text")
10655 dom.SetAttribute(input, "placeholder", "https://...")
10656 val := current
10657 if val == "" && defaultVal != "" {
10658 val = defaultVal
10659 }
10660 dom.SetProperty(input, "value", val)
10661 dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
10662 dom.SetStyle(input, "fontSize", "13px")
10663 dom.SetStyle(input, "background", "var(--bg)")
10664 dom.SetStyle(input, "color", "var(--fg)")
10665 dom.SetStyle(input, "border", "1px solid var(--border)")
10666 dom.SetStyle(input, "borderRadius", "4px")
10667 dom.SetStyle(input, "padding", "6px 12px")
10668 dom.SetStyle(input, "flex", "1")
10669 dom.SetStyle(input, "minWidth", "200px")
10670 dom.AppendChild(row, input)
10671
10672 if defaultVal != "" {
10673 prefillBtn := dom.CreateElement("button")
10674 dom.SetTextContent(prefillBtn, prefillLabel)
10675 dom.SetStyle(prefillBtn, "padding", "6px 10px")
10676 dom.SetStyle(prefillBtn, "borderRadius", "4px")
10677 dom.SetStyle(prefillBtn, "border", "1px solid var(--border)")
10678 dom.SetStyle(prefillBtn, "background", "transparent")
10679 dom.SetStyle(prefillBtn, "color", "var(--fg)")
10680 dom.SetStyle(prefillBtn, "cursor", "pointer")
10681 dom.SetStyle(prefillBtn, "fontSize", "12px")
10682 capturedInput := input
10683 capturedDefault := defaultVal
10684 dom.AddEventListener(prefillBtn, "click", dom.RegisterCallback(func() {
10685 dom.SetProperty(capturedInput, "value", capturedDefault)
10686 }))
10687 dom.AppendChild(row, prefillBtn)
10688 }
10689
10690 saveBtn := dom.CreateElement("button")
10691 dom.SetTextContent(saveBtn, "save")
10692 dom.SetStyle(saveBtn, "padding", "6px 16px")
10693 dom.SetStyle(saveBtn, "borderRadius", "4px")
10694 dom.SetStyle(saveBtn, "border", "1px solid var(--border)")
10695 dom.SetStyle(saveBtn, "background", "var(--accent)")
10696 dom.SetStyle(saveBtn, "color", "var(--bg)")
10697 dom.SetStyle(saveBtn, "cursor", "pointer")
10698 dom.SetStyle(saveBtn, "fontSize", "13px")
10699 capturedInput2 := input
10700 capturedLsKey := lsKey
10701 capturedOnSet := onSet
10702 capturedSaveBtn := saveBtn
10703 dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
10704 v := dom.GetProperty(capturedInput2, "value")
10705 capturedOnSet(v)
10706 if v == "" {
10707 localstorage.RemoveItem(capturedLsKey)
10708 } else {
10709 localstorage.SetItem(capturedLsKey, v)
10710 }
10711 saveAppSettings()
10712 dom.SetTextContent(capturedSaveBtn, "saved!")
10713 dom.SetTimeout(func() { dom.SetTextContent(capturedSaveBtn, "save") }, 1500)
10714 }))
10715 dom.AppendChild(row, saveBtn)
10716 dom.AppendChild(parent, row)
10717 }
10718
10719 func renderRelaysSettings() {
10720 // Section header.
10721 hdr := dom.CreateElement("h3")
10722 dom.SetTextContent(hdr, t("relays_section"))
10723 dom.SetStyle(hdr, "fontSize", "16px")
10724 dom.SetStyle(hdr, "marginTop", "32px")
10725 dom.SetStyle(hdr, "marginBottom", "12px")
10726 dom.AppendChild(settingsPage, hdr)
10727
10728 // --- Required capabilities checkboxes ---
10729 reqHdr := dom.CreateElement("div")
10730 dom.SetTextContent(reqHdr, t("relays_required"))
10731 dom.SetStyle(reqHdr, "fontSize", "13px")
10732 dom.SetStyle(reqHdr, "color", "var(--muted)")
10733 dom.SetStyle(reqHdr, "marginBottom", "6px")
10734 dom.AppendChild(settingsPage, reqHdr)
10735
10736 addPolicyCheckbox := func(label string, bit int32) {
10737 row := dom.CreateElement("label")
10738 dom.SetStyle(row, "display", "flex")
10739 dom.SetStyle(row, "alignItems", "center")
10740 dom.SetStyle(row, "gap", "8px")
10741 dom.SetStyle(row, "padding", "4px 0")
10742 dom.SetStyle(row, "fontSize", "13px")
10743 dom.SetStyle(row, "cursor", "pointer")
10744
10745 cb := dom.CreateElement("input")
10746 dom.SetAttribute(cb, "type", "checkbox")
10747 if relayPolicyFlags&bit != 0 {
10748 dom.SetAttribute(cb, "checked", "checked")
10749 }
10750 dom.AddEventListener(cb, "change", dom.RegisterCallback(func() {
10751 if dom.GetProperty(cb, "checked") == "true" {
10752 relayPolicyFlags = relayPolicyFlags | bit
10753 } else {
10754 relayPolicyFlags = relayPolicyFlags &^ bit
10755 }
10756 saveRelayPolicy()
10757 }))
10758 dom.AppendChild(row, cb)
10759
10760 lblEl := dom.CreateElement("span")
10761 dom.SetTextContent(lblEl, label)
10762 dom.AppendChild(row, lblEl)
10763
10764 dom.AppendChild(settingsPage, row)
10765 }
10766
10767 addPolicyCheckbox(t("relays_req_nip42"), policyRequireAuth)
10768 addPolicyCheckbox(t("relays_req_nip70"), policyRequireProtect)
10769 addPolicyCheckbox(t("relays_block_payment"), policyBlockPayment)
10770 addPolicyCheckbox(t("relays_block_restrict"), policyBlockRestricted)
10771
10772 // --- Active relays list ---
10773 activeHdr := dom.CreateElement("div")
10774 dom.SetTextContent(activeHdr, t("relays_active"))
10775 dom.SetStyle(activeHdr, "fontSize", "13px")
10776 dom.SetStyle(activeHdr, "color", "var(--muted)")
10777 dom.SetStyle(activeHdr, "marginTop", "20px")
10778 dom.SetStyle(activeHdr, "marginBottom", "6px")
10779 dom.AppendChild(settingsPage, activeHdr)
10780
10781 for i, url := range relayURLs {
10782 thisURL := url // capture per-iteration for closures
10783 row := dom.CreateElement("div")
10784 dom.SetStyle(row, "padding", "3px 0")
10785 dom.SetStyle(row, "fontSize", "12px")
10786 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
10787 dom.SetStyle(row, "wordBreak", "break-all")
10788 dom.SetStyle(row, "display", "flex")
10789 dom.SetStyle(row, "alignItems", "center")
10790 dom.SetStyle(row, "gap", "6px")
10791
10792 // Block button first so it sits at the left edge.
10793 blockBtn := dom.CreateElement("button")
10794 dom.SetTextContent(blockBtn, "\u00D7")
10795 dom.SetStyle(blockBtn, "background", "transparent")
10796 dom.SetStyle(blockBtn, "border", "1px solid var(--border)")
10797 dom.SetStyle(blockBtn, "color", "var(--fg)")
10798 dom.SetStyle(blockBtn, "cursor", "pointer")
10799 dom.SetStyle(blockBtn, "fontFamily", "'Fira Code', monospace")
10800 dom.SetStyle(blockBtn, "fontSize", "11px")
10801 dom.SetStyle(blockBtn, "width", "18px")
10802 dom.SetStyle(blockBtn, "height", "18px")
10803 dom.SetStyle(blockBtn, "padding", "0")
10804 dom.SetStyle(blockBtn, "lineHeight", "1")
10805 dom.SetStyle(blockBtn, "flexShrink", "0")
10806 dom.AddEventListener(blockBtn, "click", dom.RegisterCallback(func() {
10807 if !dom.Confirm("Block " | thisURL | "?") {
10808 return
10809 }
10810 addToBlocklist(thisURL)
10811 renderSettings()
10812 }))
10813 dom.AppendChild(row, blockBtn)
10814
10815 urlSpan := dom.CreateElement("span")
10816 dom.SetTextContent(urlSpan, thisURL)
10817 dom.SetStyle(urlSpan, "flex", "1")
10818 dom.SetStyle(urlSpan, "overflow", "hidden")
10819 dom.SetStyle(urlSpan, "textOverflow", "ellipsis")
10820 dom.SetStyle(urlSpan, "whiteSpace", "nowrap")
10821 if relayUserPick[i] {
10822 dom.SetStyle(urlSpan, "fontWeight", "bold")
10823 }
10824 dom.AppendChild(row, urlSpan)
10825
10826 // Read / write role buttons.
10827 thisI := i
10828 makeSettingsRoleBtn := func(label string) dom.Element {
10829 b := dom.CreateElement("button")
10830 dom.SetTextContent(b, label)
10831 dom.SetStyle(b, "flexShrink", "0")
10832 dom.SetStyle(b, "fontSize", "10px")
10833 dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
10834 dom.SetStyle(b, "padding", "1px 5px")
10835 dom.SetStyle(b, "borderRadius", "3px")
10836 dom.SetStyle(b, "border", "1px solid var(--border)")
10837 dom.SetStyle(b, "cursor", "pointer")
10838 return b
10839 }
10840 srBtn := makeSettingsRoleBtn("r")
10841 swBtn := makeSettingsRoleBtn("w")
10842 role := "both"
10843 if thisI < len(relayRole) {
10844 role = relayRole[thisI]
10845 }
10846 applyRelayRoleBtns(srBtn, swBtn, role)
10847 capturedI := thisI
10848 dom.AddEventListener(srBtn, "click", dom.RegisterCallback(func() {
10849 if capturedI >= len(relayRole) {
10850 return
10851 }
10852 switch relayRole[capturedI] {
10853 case "both":
10854 relayRole[capturedI] = "write"
10855 case "read":
10856 relayRole[capturedI] = "write"
10857 default:
10858 relayRole[capturedI] = "both"
10859 }
10860 if capturedI < len(relayReadBtns) {
10861 applyRelayRoleBtns(relayReadBtns[capturedI], relayWriteBtns[capturedI], relayRole[capturedI])
10862 }
10863 applyRelayRoleBtns(srBtn, swBtn, relayRole[capturedI])
10864 saveRelayList()
10865 sendWriteRelays()
10866 }))
10867 dom.AddEventListener(swBtn, "click", dom.RegisterCallback(func() {
10868 if capturedI >= len(relayRole) {
10869 return
10870 }
10871 switch relayRole[capturedI] {
10872 case "both":
10873 relayRole[capturedI] = "read"
10874 case "write":
10875 relayRole[capturedI] = "read"
10876 default:
10877 relayRole[capturedI] = "both"
10878 }
10879 if capturedI < len(relayReadBtns) {
10880 applyRelayRoleBtns(relayReadBtns[capturedI], relayWriteBtns[capturedI], relayRole[capturedI])
10881 }
10882 applyRelayRoleBtns(srBtn, swBtn, relayRole[capturedI])
10883 saveRelayList()
10884 sendWriteRelays()
10885 }))
10886 caps := relayCaps[i]
10887 if !hasNIP(caps.supportedNIPs, 42) {
10888 dom.AppendChild(row, makeRelayBadge("!42"))
10889 }
10890 if !hasNIP(caps.supportedNIPs, 70) {
10891 dom.AppendChild(row, makeRelayBadge("!70"))
10892 }
10893 if caps.fetchedOK && hasNIP(caps.supportedNIPs, 50) {
10894 badge := makeRelayBadge("search")
10895 dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
10896 dom.SetStyle(badge, "color", "#0a0")
10897 dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
10898 dom.AppendChild(row, badge)
10899 }
10900 if !caps.fetchedOK {
10901 noDoc := dom.CreateElement("span")
10902 dom.SetTextContent(noDoc, t("relays_no_nip11"))
10903 dom.SetStyle(noDoc, "fontSize", "10px")
10904 dom.SetStyle(noDoc, "color", "var(--muted)")
10905 dom.SetStyle(noDoc, "marginLeft", "6px")
10906 dom.AppendChild(row, noDoc)
10907 }
10908
10909 // r/w buttons pushed to far right.
10910 dom.SetStyle(srBtn, "marginLeft", "auto")
10911 dom.AppendChild(row, srBtn)
10912 dom.AppendChild(row, swBtn)
10913 dom.AppendChild(settingsPage, row)
10914 }
10915
10916 // --- Add relay input ---
10917 addRow := dom.CreateElement("div")
10918 dom.SetStyle(addRow, "display", "flex")
10919 dom.SetStyle(addRow, "alignItems", "center")
10920 dom.SetStyle(addRow, "gap", "6px")
10921 dom.SetStyle(addRow, "marginTop", "8px")
10922 dom.SetStyle(addRow, "position", "relative")
10923
10924 addInput := dom.CreateElement("input")
10925 dom.SetAttribute(addInput, "type", "text")
10926 dom.SetAttribute(addInput, "placeholder", "wss://relay.example.com")
10927 dom.SetStyle(addInput, "flex", "1")
10928 dom.SetStyle(addInput, "padding", "6px 8px")
10929 dom.SetStyle(addInput, "fontSize", "12px")
10930 dom.SetStyle(addInput, "fontFamily", "'Fira Code', monospace")
10931 dom.SetStyle(addInput, "background", "var(--bg)")
10932 dom.SetStyle(addInput, "color", "var(--fg)")
10933 dom.SetStyle(addInput, "border", "1px solid var(--border)")
10934 dom.SetStyle(addInput, "borderRadius", "4px")
10935
10936 addBtn := dom.CreateElement("button")
10937 dom.SetTextContent(addBtn, "+")
10938 dom.SetStyle(addBtn, "padding", "6px 10px")
10939 dom.SetStyle(addBtn, "background", "var(--accent)")
10940 dom.SetStyle(addBtn, "color", "#fff")
10941 dom.SetStyle(addBtn, "border", "none")
10942 dom.SetStyle(addBtn, "borderRadius", "4px")
10943 dom.SetStyle(addBtn, "cursor", "pointer")
10944 dom.SetStyle(addBtn, "fontWeight", "bold")
10945 dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() {
10946 url := dom.GetProperty(addInput, "value")
10947 if url != "" {
10948 addRelayURL(url)
10949 dom.SetProperty(addInput, "value", "")
10950 renderSettings()
10951 }
10952 }))
10953
10954 // Suggestion dropdown button.
10955 sugWrap := dom.CreateElement("div")
10956 dom.SetStyle(sugWrap, "position", "relative")
10957
10958 sugBtn := dom.CreateElement("button")
10959 dom.SetTextContent(sugBtn, "\u25BE")
10960 dom.SetStyle(sugBtn, "padding", "6px 8px")
10961 dom.SetStyle(sugBtn, "background", "var(--bg2)")
10962 dom.SetStyle(sugBtn, "color", "var(--fg)")
10963 dom.SetStyle(sugBtn, "border", "1px solid var(--border)")
10964 dom.SetStyle(sugBtn, "borderRadius", "4px")
10965 dom.SetStyle(sugBtn, "cursor", "pointer")
10966
10967 sugPop := dom.CreateElement("div")
10968 dom.SetStyle(sugPop, "display", "none")
10969 dom.SetStyle(sugPop, "position", "absolute")
10970 dom.SetStyle(sugPop, "bottom", "100%")
10971 dom.SetStyle(sugPop, "right", "0")
10972 dom.SetStyle(sugPop, "marginBottom", "4px")
10973 dom.SetStyle(sugPop, "background", "var(--bg)")
10974 dom.SetStyle(sugPop, "border", "1px solid var(--border)")
10975 dom.SetStyle(sugPop, "borderRadius", "6px")
10976 dom.SetStyle(sugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
10977 dom.SetStyle(sugPop, "zIndex", "1000")
10978 dom.SetStyle(sugPop, "minWidth", "280px")
10979 dom.SetStyle(sugPop, "maxHeight", "200px")
10980 dom.SetStyle(sugPop, "overflowY", "auto")
10981 dom.SetStyle(sugPop, "padding", "4px")
10982
10983 sugOpen := false
10984 dom.AddEventListener(sugBtn, "click", dom.RegisterCallback(func() {
10985 if sugOpen {
10986 dom.SetStyle(sugPop, "display", "none")
10987 sugOpen = false
10988 return
10989 }
10990 // Build sorted suggestions.
10991 clearChildren(sugPop)
10992 sorted := sortRelaysByFreq()
10993 count := 0
10994 for _, entry := range sorted {
10995 u := entry.url
10996 // Skip already-active relays.
10997 already := false
10998 for _, active := range relayURLs {
10999 if active == u {
11000 already = true
11001 break
11002 }
11003 }
11004 if already {
11005 continue
11006 }
11007 sugRow := dom.CreateElement("div")
11008 dom.SetStyle(sugRow, "display", "flex")
11009 dom.SetStyle(sugRow, "alignItems", "center")
11010 dom.SetStyle(sugRow, "justifyContent", "space-between")
11011 dom.SetStyle(sugRow, "padding", "4px 8px")
11012 dom.SetStyle(sugRow, "cursor", "pointer")
11013 dom.SetStyle(sugRow, "borderRadius", "4px")
11014 dom.SetStyle(sugRow, "fontSize", "12px")
11015 dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
11016
11017 urlSpan := dom.CreateElement("span")
11018 dom.SetTextContent(urlSpan, stripScheme(u))
11019 dom.AppendChild(sugRow, urlSpan)
11020
11021 freqSpan := dom.CreateElement("span")
11022 dom.SetTextContent(freqSpan, itoa(entry.freq))
11023 dom.SetStyle(freqSpan, "color", "var(--muted)")
11024 dom.SetStyle(freqSpan, "fontSize", "10px")
11025 dom.AppendChild(sugRow, freqSpan)
11026
11027 dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
11028 dom.SetStyle(sugRow, "background", "var(--bg2)")
11029 }))
11030 dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
11031 dom.SetStyle(sugRow, "background", "transparent")
11032 }))
11033
11034 capURL := u
11035 dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
11036 addRelayURL(capURL)
11037 dom.SetStyle(sugPop, "display", "none")
11038 sugOpen = false
11039 renderSettings()
11040 }))
11041 dom.AppendChild(sugPop, sugRow)
11042 count++
11043 }
11044 if count == 0 {
11045 empty := dom.CreateElement("div")
11046 dom.SetTextContent(empty, "no suggestions yet")
11047 dom.SetStyle(empty, "color", "var(--muted)")
11048 dom.SetStyle(empty, "fontSize", "12px")
11049 dom.SetStyle(empty, "padding", "8px")
11050 dom.AppendChild(sugPop, empty)
11051 }
11052 dom.SetStyle(sugPop, "display", "block")
11053 sugOpen = true
11054 }))
11055
11056 dom.AppendChild(sugWrap, sugBtn)
11057 dom.AppendChild(sugWrap, sugPop)
11058
11059 dom.AppendChild(addRow, addInput)
11060 dom.AppendChild(addRow, addBtn)
11061 dom.AppendChild(addRow, sugWrap)
11062 dom.AppendChild(settingsPage, addRow)
11063
11064 // --- Blocklist ---
11065 blHdr := dom.CreateElement("div")
11066 dom.SetTextContent(blHdr, t("relays_blocklist"))
11067 dom.SetStyle(blHdr, "fontSize", "13px")
11068 dom.SetStyle(blHdr, "color", "var(--muted)")
11069 dom.SetStyle(blHdr, "marginTop", "20px")
11070 dom.SetStyle(blHdr, "marginBottom", "6px")
11071 dom.AppendChild(settingsPage, blHdr)
11072
11073 if len(relayBlocklist) == 0 {
11074 empty := dom.CreateElement("div")
11075 dom.SetTextContent(empty, t("relays_blocklist_empty"))
11076 dom.SetStyle(empty, "fontSize", "12px")
11077 dom.SetStyle(empty, "color", "var(--muted)")
11078 dom.SetStyle(empty, "fontStyle", "italic")
11079 dom.AppendChild(settingsPage, empty)
11080 } else {
11081 for _, url := range relayBlocklist {
11082 thisURL := url // capture per-iteration for closure
11083 row := dom.CreateElement("div")
11084 dom.SetStyle(row, "padding", "3px 0")
11085 dom.SetStyle(row, "fontSize", "12px")
11086 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
11087 dom.SetStyle(row, "wordBreak", "break-all")
11088 dom.SetStyle(row, "display", "flex")
11089 dom.SetStyle(row, "alignItems", "center")
11090 dom.SetStyle(row, "gap", "6px")
11091
11092 unblockBtn := dom.CreateElement("button")
11093 dom.SetTextContent(unblockBtn, "\u00D7")
11094 dom.SetStyle(unblockBtn, "background", "transparent")
11095 dom.SetStyle(unblockBtn, "border", "1px solid var(--border)")
11096 dom.SetStyle(unblockBtn, "color", "var(--fg)")
11097 dom.SetStyle(unblockBtn, "cursor", "pointer")
11098 dom.SetStyle(unblockBtn, "fontFamily", "'Fira Code', monospace")
11099 dom.SetStyle(unblockBtn, "fontSize", "11px")
11100 dom.SetStyle(unblockBtn, "width", "18px")
11101 dom.SetStyle(unblockBtn, "height", "18px")
11102 dom.SetStyle(unblockBtn, "padding", "0")
11103 dom.SetStyle(unblockBtn, "lineHeight", "1")
11104 dom.SetStyle(unblockBtn, "flexShrink", "0")
11105 dom.AddEventListener(unblockBtn, "click", dom.RegisterCallback(func() {
11106 removeFromBlocklist(thisURL)
11107 renderSettings()
11108 }))
11109 dom.AppendChild(row, unblockBtn)
11110
11111 urlSpan := dom.CreateElement("span")
11112 dom.SetTextContent(urlSpan, thisURL)
11113 dom.AppendChild(row, urlSpan)
11114
11115 dom.AppendChild(settingsPage, row)
11116 }
11117 }
11118
11119 // --- Add to blocklist input ---
11120 blAddRow := dom.CreateElement("div")
11121 dom.SetStyle(blAddRow, "display", "flex")
11122 dom.SetStyle(blAddRow, "alignItems", "center")
11123 dom.SetStyle(blAddRow, "gap", "6px")
11124 dom.SetStyle(blAddRow, "marginTop", "8px")
11125 dom.SetStyle(blAddRow, "position", "relative")
11126
11127 blAddInput := dom.CreateElement("input")
11128 dom.SetAttribute(blAddInput, "type", "text")
11129 dom.SetAttribute(blAddInput, "placeholder", "wss://relay.example.com")
11130 dom.SetStyle(blAddInput, "flex", "1")
11131 dom.SetStyle(blAddInput, "padding", "6px 8px")
11132 dom.SetStyle(blAddInput, "fontSize", "12px")
11133 dom.SetStyle(blAddInput, "fontFamily", "'Fira Code', monospace")
11134 dom.SetStyle(blAddInput, "background", "var(--bg)")
11135 dom.SetStyle(blAddInput, "color", "var(--fg)")
11136 dom.SetStyle(blAddInput, "border", "1px solid var(--border)")
11137 dom.SetStyle(blAddInput, "borderRadius", "4px")
11138
11139 blAddBtn := dom.CreateElement("button")
11140 dom.SetTextContent(blAddBtn, "+")
11141 dom.SetStyle(blAddBtn, "padding", "6px 10px")
11142 dom.SetStyle(blAddBtn, "background", "var(--accent)")
11143 dom.SetStyle(blAddBtn, "color", "#fff")
11144 dom.SetStyle(blAddBtn, "border", "none")
11145 dom.SetStyle(blAddBtn, "borderRadius", "4px")
11146 dom.SetStyle(blAddBtn, "cursor", "pointer")
11147 dom.SetStyle(blAddBtn, "fontWeight", "bold")
11148 dom.AddEventListener(blAddBtn, "click", dom.RegisterCallback(func() {
11149 url := dom.GetProperty(blAddInput, "value")
11150 if url != "" {
11151 addToBlocklist(url)
11152 dom.SetProperty(blAddInput, "value", "")
11153 renderSettings()
11154 }
11155 }))
11156
11157 // Suggestion dropdown button for blocklist.
11158 blSugWrap := dom.CreateElement("div")
11159 dom.SetStyle(blSugWrap, "position", "relative")
11160
11161 blSugBtn := dom.CreateElement("button")
11162 dom.SetTextContent(blSugBtn, "\u25BE")
11163 dom.SetStyle(blSugBtn, "padding", "6px 8px")
11164 dom.SetStyle(blSugBtn, "background", "var(--bg2)")
11165 dom.SetStyle(blSugBtn, "color", "var(--fg)")
11166 dom.SetStyle(blSugBtn, "border", "1px solid var(--border)")
11167 dom.SetStyle(blSugBtn, "borderRadius", "4px")
11168 dom.SetStyle(blSugBtn, "cursor", "pointer")
11169
11170 blSugPop := dom.CreateElement("div")
11171 dom.SetStyle(blSugPop, "display", "none")
11172 dom.SetStyle(blSugPop, "position", "absolute")
11173 dom.SetStyle(blSugPop, "bottom", "100%")
11174 dom.SetStyle(blSugPop, "right", "0")
11175 dom.SetStyle(blSugPop, "marginBottom", "4px")
11176 dom.SetStyle(blSugPop, "background", "var(--bg)")
11177 dom.SetStyle(blSugPop, "border", "1px solid var(--border)")
11178 dom.SetStyle(blSugPop, "borderRadius", "6px")
11179 dom.SetStyle(blSugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
11180 dom.SetStyle(blSugPop, "zIndex", "1000")
11181 dom.SetStyle(blSugPop, "minWidth", "280px")
11182 dom.SetStyle(blSugPop, "maxHeight", "200px")
11183 dom.SetStyle(blSugPop, "overflowY", "auto")
11184 dom.SetStyle(blSugPop, "padding", "4px")
11185
11186 blSugOpen := false
11187 dom.AddEventListener(blSugBtn, "click", dom.RegisterCallback(func() {
11188 if blSugOpen {
11189 dom.SetStyle(blSugPop, "display", "none")
11190 blSugOpen = false
11191 return
11192 }
11193 clearChildren(blSugPop)
11194 sorted := sortRelaysByFreq()
11195 count := 0
11196 for _, entry := range sorted {
11197 u := entry.url
11198 if isBlocked(u) {
11199 continue
11200 }
11201 sugRow := dom.CreateElement("div")
11202 dom.SetStyle(sugRow, "display", "flex")
11203 dom.SetStyle(sugRow, "alignItems", "center")
11204 dom.SetStyle(sugRow, "justifyContent", "space-between")
11205 dom.SetStyle(sugRow, "padding", "4px 8px")
11206 dom.SetStyle(sugRow, "cursor", "pointer")
11207 dom.SetStyle(sugRow, "borderRadius", "4px")
11208 dom.SetStyle(sugRow, "fontSize", "12px")
11209 dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
11210
11211 urlSpan := dom.CreateElement("span")
11212 dom.SetTextContent(urlSpan, stripScheme(u))
11213 dom.AppendChild(sugRow, urlSpan)
11214
11215 freqSpan := dom.CreateElement("span")
11216 dom.SetTextContent(freqSpan, itoa(entry.freq))
11217 dom.SetStyle(freqSpan, "color", "var(--muted)")
11218 dom.SetStyle(freqSpan, "fontSize", "10px")
11219 dom.AppendChild(sugRow, freqSpan)
11220
11221 dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
11222 dom.SetStyle(sugRow, "background", "var(--bg2)")
11223 }))
11224 dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
11225 dom.SetStyle(sugRow, "background", "transparent")
11226 }))
11227
11228 capURL := u
11229 dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
11230 addToBlocklist(capURL)
11231 dom.SetStyle(blSugPop, "display", "none")
11232 blSugOpen = false
11233 renderSettings()
11234 }))
11235 dom.AppendChild(blSugPop, sugRow)
11236 count++
11237 }
11238 if count == 0 {
11239 empty := dom.CreateElement("div")
11240 dom.SetTextContent(empty, "no suggestions yet")
11241 dom.SetStyle(empty, "color", "var(--muted)")
11242 dom.SetStyle(empty, "fontSize", "12px")
11243 dom.SetStyle(empty, "padding", "8px")
11244 dom.AppendChild(blSugPop, empty)
11245 }
11246 dom.SetStyle(blSugPop, "display", "block")
11247 blSugOpen = true
11248 }))
11249
11250 dom.AppendChild(blSugWrap, blSugBtn)
11251 dom.AppendChild(blSugWrap, blSugPop)
11252
11253 dom.AppendChild(blAddRow, blAddInput)
11254 dom.AppendChild(blAddRow, blAddBtn)
11255 dom.AppendChild(blAddRow, blSugWrap)
11256 dom.AppendChild(settingsPage, blAddRow)
11257
11258 // Wallet UI at the very bottom of the settings page.
11259 if int32(walletPanel) != 0 {
11260 dom.AppendChild(settingsPage, walletPanel)
11261 renderWalletUI()
11262 }
11263 }
11264
11265 // --- Messaging ---
11266
11267 func initMessaging() {
11268 // Render new-chat button immediately - don't wait for DM_LIST round-trip.
11269 clearChildren(msgListContainer)
11270
11271 if !signer.HasSigner() {
11272 notice := dom.CreateElement("div")
11273 dom.SetStyle(notice, "padding", "24px")
11274 dom.SetStyle(notice, "textAlign", "center")
11275 dom.SetStyle(notice, "color", "var(--muted)")
11276 dom.SetStyle(notice, "fontSize", "13px")
11277 dom.SetStyle(notice, "lineHeight", "1.6")
11278 dom.SetInnerHTML(notice, t("dm_notice"))
11279 dom.AppendChild(msgListContainer, notice)
11280 return
11281 }
11282
11283 renderNewChatRow()
11284
11285 // Init MLS if not already done + request conversation list.
11286 if !marmotInited {
11287 marmotInited = true
11288 routeMsg("[\"MLS_INIT\"," | relayURLsJSON() | "]")
11289 }
11290 routeMsg("[\"MLS_DM_LIST\"]")
11291 }
11292
11293 func renderNewChatRow() {
11294 row := dom.CreateElement("div")
11295 dom.SetStyle(row, "display", "flex")
11296 dom.SetStyle(row, "gap", "6px")
11297 dom.SetStyle(row, "marginBottom", "8px")
11298 dom.SetStyle(row, "alignItems", "center")
11299
11300 // --- Follows group (default visible): [follows ▾] [+] ---
11301 followsGroup := dom.CreateElement("div")
11302 dom.SetStyle(followsGroup, "display", "flex")
11303 dom.SetStyle(followsGroup, "gap", "6px")
11304 dom.SetStyle(followsGroup, "alignItems", "center")
11305
11306 selWrap := dom.CreateElement("div")
11307 dom.SetStyle(selWrap, "position", "relative")
11308
11309 selBtn := dom.CreateElement("button")
11310 dom.SetTextContent(selBtn, t("follows") | " \u25BE")
11311 dom.SetStyle(selBtn, "padding", "8px 12px")
11312 dom.SetStyle(selBtn, "fontFamily", "'Fira Code', monospace")
11313 dom.SetStyle(selBtn, "fontSize", "12px")
11314 dom.SetStyle(selBtn, "background", "var(--bg2)")
11315 dom.SetStyle(selBtn, "border", "1px solid var(--border)")
11316 dom.SetStyle(selBtn, "borderRadius", "4px")
11317 dom.SetStyle(selBtn, "color", "var(--fg)")
11318 dom.SetStyle(selBtn, "cursor", "pointer")
11319
11320 dropdown := dom.CreateElement("div")
11321 dom.SetStyle(dropdown, "display", "none")
11322 dom.SetStyle(dropdown, "position", "absolute")
11323 dom.SetStyle(dropdown, "left", "0")
11324 dom.SetStyle(dropdown, "top", "100%")
11325 dom.SetStyle(dropdown, "marginTop", "4px")
11326 dom.SetStyle(dropdown, "background", "var(--bg)")
11327 dom.SetStyle(dropdown, "border", "1px solid var(--border)")
11328 dom.SetStyle(dropdown, "borderRadius", "6px")
11329 dom.SetStyle(dropdown, "maxHeight", "300px")
11330 dom.SetStyle(dropdown, "overflowY", "auto")
11331 dom.SetStyle(dropdown, "minWidth", "200px")
11332 dom.SetStyle(dropdown, "maxWidth", "calc(100vw - 32px)")
11333 dom.SetStyle(dropdown, "zIndex", "100")
11334 dom.SetStyle(dropdown, "boxShadow", "0 4px 12px rgba(0,0,0,0.3)")
11335
11336 populateFollowsDropdown(dropdown)
11337
11338 dom.SetAttribute(selWrap, "onclick", "event.stopPropagation()")
11339
11340 dropOpen := false
11341 dom.AddEventListener(selBtn, "click", dom.RegisterCallback(func() {
11342 if !dropOpen {
11343 populateFollowsDropdown(dropdown)
11344 dom.SetStyle(dropdown, "display", "block")
11345 dropOpen = true
11346 } else {
11347 dom.SetStyle(dropdown, "display", "none")
11348 dropOpen = false
11349 }
11350 }))
11351
11352 dom.AddEventListener(dom.Body(), "click", dom.RegisterCallback(func() {
11353 dom.SetStyle(dropdown, "display", "none")
11354 dropOpen = false
11355 }))
11356
11357 dom.AppendChild(selWrap, selBtn)
11358 dom.AppendChild(selWrap, dropdown)
11359
11360 addBtn := dom.CreateElement("button")
11361 dom.SetTextContent(addBtn, "+")
11362 dom.SetStyle(addBtn, "padding", "8px 10px")
11363 dom.SetStyle(addBtn, "fontFamily", "'Fira Code', monospace")
11364 dom.SetStyle(addBtn, "fontSize", "14px")
11365 dom.SetStyle(addBtn, "fontWeight", "bold")
11366 dom.SetStyle(addBtn, "background", "var(--bg2)")
11367 dom.SetStyle(addBtn, "border", "1px solid var(--border)")
11368 dom.SetStyle(addBtn, "borderRadius", "4px")
11369 dom.SetStyle(addBtn, "color", "var(--fg)")
11370 dom.SetStyle(addBtn, "cursor", "pointer")
11371 dom.SetStyle(addBtn, "lineHeight", "1")
11372
11373 dom.AppendChild(followsGroup, selWrap)
11374 dom.AppendChild(followsGroup, addBtn)
11375
11376 // --- Input group (hidden until + clicked): [input] [go] [×] ---
11377 inputGroup := dom.CreateElement("div")
11378 dom.SetStyle(inputGroup, "display", "none")
11379 dom.SetStyle(inputGroup, "flex", "1")
11380 dom.SetStyle(inputGroup, "gap", "6px")
11381 dom.SetStyle(inputGroup, "alignItems", "center")
11382
11383 inp := dom.CreateElement("input")
11384 dom.SetAttribute(inp, "type", "text")
11385 dom.SetAttribute(inp, "placeholder", t("npub_placeholder"))
11386 dom.SetStyle(inp, "flex", "1 1 120px")
11387 dom.SetStyle(inp, "minWidth", "0")
11388 dom.SetStyle(inp, "padding", "8px")
11389 dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace")
11390 dom.SetStyle(inp, "fontSize", "12px")
11391 dom.SetStyle(inp, "background", "var(--bg)")
11392 dom.SetStyle(inp, "border", "1px solid var(--border)")
11393 dom.SetStyle(inp, "borderRadius", "4px")
11394 dom.SetStyle(inp, "color", "var(--fg)")
11395
11396 goBtn := dom.CreateElement("button")
11397 dom.SetTextContent(goBtn, t("go"))
11398 dom.SetStyle(goBtn, "padding", "8px 14px")
11399 dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace")
11400 dom.SetStyle(goBtn, "fontSize", "12px")
11401 dom.SetStyle(goBtn, "background", "var(--accent)")
11402 dom.SetStyle(goBtn, "color", "#fff")
11403 dom.SetStyle(goBtn, "border", "none")
11404 dom.SetStyle(goBtn, "borderRadius", "4px")
11405 dom.SetStyle(goBtn, "cursor", "pointer")
11406 dom.SetStyle(goBtn, "flexShrink", "0")
11407
11408 closeBtn := dom.CreateElement("button")
11409 dom.SetTextContent(closeBtn, "\u00D7")
11410 dom.SetStyle(closeBtn, "padding", "8px 10px")
11411 dom.SetStyle(closeBtn, "fontFamily", "'Fira Code', monospace")
11412 dom.SetStyle(closeBtn, "fontSize", "14px")
11413 dom.SetStyle(closeBtn, "fontWeight", "bold")
11414 dom.SetStyle(closeBtn, "background", "var(--bg2)")
11415 dom.SetStyle(closeBtn, "border", "1px solid var(--border)")
11416 dom.SetStyle(closeBtn, "borderRadius", "4px")
11417 dom.SetStyle(closeBtn, "color", "var(--fg)")
11418 dom.SetStyle(closeBtn, "cursor", "pointer")
11419 dom.SetStyle(closeBtn, "lineHeight", "1")
11420
11421 dom.AppendChild(inputGroup, inp)
11422 dom.AppendChild(inputGroup, goBtn)
11423 dom.AppendChild(inputGroup, closeBtn)
11424
11425 // Toggle between follows group and input group.
11426 showInput := func() {
11427 dom.SetStyle(dropdown, "display", "none")
11428 dropOpen = false
11429 dom.SetStyle(followsGroup, "display", "none")
11430 dom.SetStyle(inputGroup, "display", "flex")
11431 dom.SetProperty(inp, "value", "")
11432 dom.Focus(inp)
11433 }
11434 showFollows := func() {
11435 dom.SetStyle(inputGroup, "display", "none")
11436 dom.SetStyle(followsGroup, "display", "flex")
11437 }
11438
11439 dom.AddEventListener(addBtn, "click", dom.RegisterCallback(showInput))
11440 dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(showFollows))
11441
11442 // Submit handler for input + go button.
11443 submitNewChat := func() {
11444 val := dom.GetProperty(inp, "value")
11445 if val == "" {
11446 return
11447 }
11448 var hexPK string
11449 if len(val) == 64 {
11450 hexPK = val
11451 } else if len(val) > 4 && val[:4] == "npub" {
11452 decoded := helpers.DecodeNpub(val)
11453 if decoded == nil {
11454 return
11455 }
11456 hexPK = helpers.HexEncode(decoded)
11457 } else {
11458 return
11459 }
11460 openThread(hexPK)
11461 }
11462
11463 dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat))
11464 dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}")
11465
11466 dom.AppendChild(row, followsGroup)
11467 dom.AppendChild(row, inputGroup)
11468 dom.AppendChild(msgListContainer, row)
11469 }
11470
11471 func populateFollowsDropdown(dropdown dom.Element) {
11472 clearChildren(dropdown)
11473 pks := myFollows
11474 if len(pks) == 0 {
11475 empty := dom.CreateElement("div")
11476 dom.SetStyle(empty, "padding", "12px")
11477 dom.SetStyle(empty, "color", "var(--muted)")
11478 dom.SetStyle(empty, "fontSize", "12px")
11479 dom.SetTextContent(empty, t("no_follows"))
11480 dom.AppendChild(dropdown, empty)
11481 return
11482 }
11483 // Sort by display name (named first, then by name alpha, unnamed last by short hex).
11484 type entry struct {
11485 pk string
11486 name string
11487 }
11488 var named []entry
11489 var unnamed []entry
11490 for _, pk := range pks {
11491 n := authorNames[pk]
11492 if n != "" {
11493 named = append(named, entry{pk, n})
11494 } else {
11495 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
11496 if len(npub) > 16 {
11497 npub = npub[:16] | "..."
11498 }
11499 unnamed = append(unnamed, entry{pk, npub})
11500 }
11501 }
11502 // Simple insertion sort by name.
11503 for i := 1; i < len(named); i++ {
11504 j := i
11505 for j > 0 && toLower(named[j].name) < toLower(named[j-1].name) {
11506 named[j], named[j-1] = named[j-1], named[j]
11507 j--
11508 }
11509 }
11510 var all []entry
11511 for _, e := range named {
11512 all = append(all, e)
11513 }
11514 for _, e := range unnamed {
11515 all = append(all, e)
11516 }
11517 for _, e := range all {
11518 item := dom.CreateElement("div")
11519 dom.SetStyle(item, "padding", "6px 12px")
11520 dom.SetStyle(item, "fontSize", "13px")
11521 dom.SetStyle(item, "cursor", "pointer")
11522 dom.SetStyle(item, "display", "flex")
11523 dom.SetStyle(item, "alignItems", "center")
11524 dom.SetStyle(item, "gap", "8px")
11525
11526 av := dom.CreateElement("img")
11527 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
11528 dom.SetAttribute(av, "width", "24")
11529 dom.SetAttribute(av, "height", "24")
11530 dom.SetStyle(av, "borderRadius", "50%")
11531 dom.SetStyle(av, "objectFit", "cover")
11532 dom.SetStyle(av, "flexShrink", "0")
11533 if pic, ok := authorPics[e.pk]; ok && pic != "" {
11534 setMediaSrc(av, pic)
11535 } else {
11536 dom.SetStyle(av, "background", "var(--bg2)")
11537 }
11538 dom.SetAttribute(av, "onerror", "this.style.display='none'")
11539 dom.AppendChild(item, av)
11540
11541 nameSpan := dom.CreateElement("span")
11542 dom.SetStyle(nameSpan, "overflow", "hidden")
11543 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
11544 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
11545 dom.SetTextContent(nameSpan, e.name)
11546 dom.AppendChild(item, nameSpan)
11547
11548 pk := e.pk
11549 dom.AddEventListener(item, "mouseenter", dom.RegisterCallback(func() {
11550 dom.SetStyle(item, "background", "var(--bg2)")
11551 }))
11552 dom.AddEventListener(item, "mouseleave", dom.RegisterCallback(func() {
11553 dom.SetStyle(item, "background", "transparent")
11554 }))
11555 dom.AddEventListener(item, "click", dom.RegisterCallback(func() {
11556 dom.SetStyle(dropdown, "display", "none")
11557 openThread(pk)
11558 }))
11559 dom.AppendChild(dropdown, item)
11560 }
11561 }
11562
11563 func renderConversationList(listJSON string) {
11564 if msgView != "list" {
11565 return
11566 }
11567 clearChildren(msgListContainer)
11568 renderNewChatRow()
11569
11570 // Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...]
11571 if listJSON == "" || listJSON == "[]" {
11572 empty := dom.CreateElement("div")
11573 dom.SetStyle(empty, "color", "var(--muted)")
11574 dom.SetStyle(empty, "textAlign", "center")
11575 dom.SetStyle(empty, "marginTop", "48px")
11576 dom.SetTextContent(empty, t("no_convos"))
11577 dom.AppendChild(msgListContainer, empty)
11578 return
11579 }
11580
11581 // Walk the JSON array manually - each element is an object.
11582 i := 0
11583 for i < len(listJSON) && listJSON[i] != '[' {
11584 i++
11585 }
11586 i++ // skip '['
11587 for i < len(listJSON) {
11588 // Find next object.
11589 for i < len(listJSON) && listJSON[i] != '{' {
11590 if listJSON[i] == ']' {
11591 return
11592 }
11593 i++
11594 }
11595 if i >= len(listJSON) {
11596 break
11597 }
11598 // Extract the object.
11599 objStart := i
11600 depth := 0
11601 for i < len(listJSON) {
11602 if listJSON[i] == '{' {
11603 depth++
11604 } else if listJSON[i] == '}' {
11605 depth--
11606 if depth == 0 {
11607 i++
11608 break
11609 }
11610 } else if listJSON[i] == '"' {
11611 i++
11612 for i < len(listJSON) && listJSON[i] != '"' {
11613 if listJSON[i] == '\\' {
11614 i++
11615 }
11616 i++
11617 }
11618 }
11619 i++
11620 }
11621 obj := listJSON[objStart:i]
11622
11623 peer := helpers.JsonGetString(obj, "peer")
11624 lastMsg := helpers.JsonGetString(obj, "lastMessage")
11625 lastTs := jsonGetNum(obj, "lastTs")
11626 if peer == "" {
11627 continue
11628 }
11629
11630 renderConversationRow(peer, lastMsg, lastTs)
11631 }
11632 }
11633
11634 func renderConversationRow(peer, lastMsg string, lastTs int64) {
11635 row := dom.CreateElement("div")
11636 dom.SetStyle(row, "display", "flex")
11637 dom.SetStyle(row, "alignItems", "center")
11638 dom.SetStyle(row, "gap", "10px")
11639 dom.SetStyle(row, "padding", "10px 4px")
11640 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
11641 dom.SetStyle(row, "cursor", "pointer")
11642
11643 // Avatar.
11644 av := dom.CreateElement("img")
11645 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
11646 dom.SetAttribute(av, "width", "32")
11647 dom.SetAttribute(av, "height", "32")
11648 dom.SetStyle(av, "borderRadius", "50%")
11649 dom.SetStyle(av, "objectFit", "cover")
11650 dom.SetStyle(av, "flexShrink", "0")
11651 if pic, ok := authorPics[peer]; ok && pic != "" {
11652 setMediaSrc(av, pic)
11653 } else {
11654 dom.SetStyle(av, "background", "var(--bg2)")
11655 }
11656 dom.SetAttribute(av, "onerror", "this.style.display='none'")
11657 dom.AppendChild(row, av)
11658
11659 // Name + preview column.
11660 col := dom.CreateElement("div")
11661 dom.SetStyle(col, "flex", "1")
11662 dom.SetStyle(col, "minWidth", "0")
11663
11664 nameSpan := dom.CreateElement("div")
11665 dom.SetStyle(nameSpan, "fontSize", "14px")
11666 dom.SetStyle(nameSpan, "fontWeight", "bold")
11667 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
11668 dom.SetStyle(nameSpan, "overflow", "hidden")
11669 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
11670 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
11671 if name, ok := authorNames[peer]; ok && name != "" {
11672 dom.SetTextContent(nameSpan, name)
11673 } else {
11674 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
11675 if len(npub) > 20 {
11676 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
11677 } else {
11678 dom.SetTextContent(nameSpan, npub)
11679 }
11680 }
11681 dom.AppendChild(col, nameSpan)
11682
11683 preview := dom.CreateElement("div")
11684 dom.SetStyle(preview, "fontSize", "12px")
11685 dom.SetStyle(preview, "color", "var(--muted)")
11686 dom.SetStyle(preview, "overflow", "hidden")
11687 dom.SetStyle(preview, "textOverflow", "ellipsis")
11688 dom.SetStyle(preview, "whiteSpace", "nowrap")
11689 if len(lastMsg) > 80 {
11690 lastMsg = lastMsg[:80] | "..."
11691 }
11692 dom.SetTextContent(preview, lastMsg)
11693 dom.AppendChild(col, preview)
11694 dom.AppendChild(row, col)
11695
11696 // Timestamp.
11697 if lastTs > 0 {
11698 tsSpan := dom.CreateElement("span")
11699 dom.SetStyle(tsSpan, "fontSize", "11px")
11700 dom.SetStyle(tsSpan, "color", "var(--muted)")
11701 dom.SetStyle(tsSpan, "flexShrink", "0")
11702 dom.SetTextContent(tsSpan, formatTime(lastTs))
11703 dom.AppendChild(row, tsSpan)
11704 }
11705
11706 rowPeer := peer
11707 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
11708 openThread(rowPeer)
11709 }))
11710
11711 // Lazy profile fetch - batched to avoid 50+ simultaneous subscriptions.
11712 if _, cached := authorNames[peer]; !cached && !lookupFetchedK0(peer) {
11713 profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
11714 }
11715
11716 dom.AppendChild(msgListContainer, row)
11717 }
11718
11719 func openThread(peer string) {
11720 msgCurrentConvoID = peer
11721 msgCurrentKind = "dm"
11722 msgView = "thread"
11723
11724 if !navPop {
11725 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
11726 dom.PushState("/msg/" | npub)
11727 }
11728
11729 // Hide list, show thread.
11730 dom.SetStyle(msgListContainer, "display", "none")
11731 dom.SetStyle(msgThreadContainer, "display", "flex")
11732
11733 // Build thread UI.
11734 clearChildren(msgThreadContainer)
11735
11736 // Header: back + avatar + name.
11737 hdr := dom.CreateElement("div")
11738 dom.SetStyle(hdr, "display", "flex")
11739 dom.SetStyle(hdr, "alignItems", "center")
11740 dom.SetStyle(hdr, "gap", "10px")
11741 dom.SetStyle(hdr, "padding", "12px 16px")
11742 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
11743 dom.SetStyle(hdr, "flexShrink", "0")
11744
11745 backBtn := dom.CreateElement("button")
11746 dom.SetInnerHTML(backBtn, "←") // ←
11747 dom.SetStyle(backBtn, "background", "none")
11748 dom.SetStyle(backBtn, "border", "none")
11749 dom.SetStyle(backBtn, "fontSize", "20px")
11750 dom.SetStyle(backBtn, "cursor", "pointer")
11751 dom.SetStyle(backBtn, "color", "var(--fg)")
11752 dom.SetStyle(backBtn, "padding", "0")
11753 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
11754 closeThread()
11755 }))
11756 dom.AppendChild(hdr, backBtn)
11757
11758 // Thread header avatar + name - uses same img-then-span structure
11759 // as note headers so pendingNotes/updateNoteHeader can update them.
11760 threadHdrInner := dom.CreateElement("div")
11761 av := dom.CreateElement("img")
11762 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
11763 dom.SetAttribute(av, "width", "28")
11764 dom.SetAttribute(av, "height", "28")
11765 dom.SetStyle(av, "borderRadius", "50%")
11766 dom.SetStyle(av, "objectFit", "cover")
11767 dom.SetStyle(av, "flexShrink", "0")
11768 if pic, ok := authorPics[peer]; ok && pic != "" {
11769 setMediaSrc(av, pic)
11770 } else {
11771 dom.SetStyle(av, "display", "none")
11772 }
11773 dom.SetAttribute(av, "onerror", "this.style.display='none'")
11774 dom.AppendChild(threadHdrInner, av)
11775
11776 nameSpan := dom.CreateElement("span")
11777 dom.SetStyle(nameSpan, "fontSize", "15px")
11778 dom.SetStyle(nameSpan, "fontWeight", "bold")
11779 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
11780 dom.SetStyle(nameSpan, "overflow", "hidden")
11781 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
11782 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
11783 dom.SetStyle(nameSpan, "minWidth", "0")
11784 if name, ok := authorNames[peer]; ok && len(name) > 0 {
11785 dom.SetTextContent(nameSpan, name)
11786 } else {
11787 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
11788 if len(npub) > 20 {
11789 dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
11790 }
11791 }
11792 dom.AppendChild(threadHdrInner, nameSpan)
11793 dom.SetStyle(threadHdrInner, "display", "flex")
11794 dom.SetStyle(threadHdrInner, "alignItems", "center")
11795 dom.SetStyle(threadHdrInner, "gap", "10px")
11796 dom.SetStyle(threadHdrInner, "flexGrow", "1")
11797 dom.SetStyle(threadHdrInner, "minWidth", "0")
11798 dom.SetStyle(threadHdrInner, "cursor", "pointer")
11799 profilePeer := peer
11800 dom.AddEventListener(threadHdrInner, "click", dom.RegisterCallback(func() {
11801 showProfile(profilePeer)
11802 }))
11803 dom.AppendChild(hdr, threadHdrInner)
11804
11805 // Relay selector button.
11806 dmRelayWrap := dom.CreateElement("div")
11807 dom.SetStyle(dmRelayWrap, "position", "relative")
11808 dom.SetStyle(dmRelayWrap, "display", "flex")
11809 dom.SetStyle(dmRelayWrap, "alignItems", "center")
11810 dom.SetStyle(dmRelayWrap, "gap", "6px")
11811
11812 dmWarnBadge := makeRelayWarnBadge()
11813
11814 dmRelayBtn := dom.CreateElement("button")
11815 dom.SetStyle(dmRelayBtn, "background", "none")
11816 dom.SetStyle(dmRelayBtn, "border", "1px solid var(--border)")
11817 dom.SetStyle(dmRelayBtn, "borderRadius", "4px")
11818 dom.SetStyle(dmRelayBtn, "color", "var(--fg)")
11819 dom.SetStyle(dmRelayBtn, "cursor", "pointer")
11820 dom.SetStyle(dmRelayBtn, "fontSize", "11px")
11821 dom.SetStyle(dmRelayBtn, "padding", "4px 8px")
11822 dom.SetStyle(dmRelayBtn, "fontFamily", "'Fira Code', monospace")
11823 updateRelaySelBtn(dmRelayBtn, false)
11824
11825 dmRelayPop := dom.CreateElement("div")
11826 dom.SetStyle(dmRelayPop, "display", "none")
11827 dom.SetStyle(dmRelayPop, "position", "absolute")
11828 dom.SetStyle(dmRelayPop, "top", "100%")
11829 dom.SetStyle(dmRelayPop, "right", "0")
11830 dom.SetStyle(dmRelayPop, "marginTop", "4px")
11831 dom.SetStyle(dmRelayPop, "background", "var(--bg)")
11832 dom.SetStyle(dmRelayPop, "border", "1px solid var(--border)")
11833 dom.SetStyle(dmRelayPop, "borderRadius", "6px")
11834 dom.SetStyle(dmRelayPop, "padding", "8px")
11835 dom.SetStyle(dmRelayPop, "boxShadow", "0 2px 8px rgba(0,0,0,0.2)")
11836 dom.SetStyle(dmRelayPop, "zIndex", "1000")
11837 dom.SetStyle(dmRelayPop, "minWidth", "200px")
11838 dom.SetStyle(dmRelayPop, "maxWidth", "calc(100vw - 24px)")
11839 dom.SetStyle(dmRelayPop, "maxHeight", "200px")
11840 dom.SetStyle(dmRelayPop, "overflowY", "auto")
11841
11842 dmRelayOpen := false
11843 dom.AddEventListener(dmRelayBtn, "click", dom.RegisterCallback(func() {
11844 if dmRelayOpen {
11845 dom.SetStyle(dmRelayPop, "display", "none")
11846 dmRelayOpen = false
11847 updateRelaySelBtn(dmRelayBtn, false)
11848 } else {
11849 populateRelayPopover(dmRelayPop, func() {
11850 updateRelaySelBtn(dmRelayBtn, true)
11851 updateRelayWarnBadge(dmWarnBadge)
11852 })
11853 dom.SetStyle(dmRelayPop, "display", "block")
11854 dmRelayOpen = true
11855 updateRelaySelBtn(dmRelayBtn, true)
11856 }
11857 }))
11858
11859 dom.AppendChild(dmRelayWrap, dmWarnBadge)
11860 dom.AppendChild(dmRelayWrap, dmRelayBtn)
11861 dom.AppendChild(dmRelayWrap, dmRelayPop)
11862 dom.AppendChild(hdr, dmRelayWrap)
11863
11864 // Delete + ratchet button.
11865 ratchetBtn := dom.CreateElement("button")
11866 dom.SetInnerHTML(ratchetBtn, "🗑") // 🗑
11867 dom.SetAttribute(ratchetBtn, "title", "Delete messages and rotate keys")
11868 dom.SetStyle(ratchetBtn, "background", "none")
11869 dom.SetStyle(ratchetBtn, "border", "none")
11870 dom.SetStyle(ratchetBtn, "cursor", "pointer")
11871 dom.SetStyle(ratchetBtn, "fontSize", "16px")
11872 dom.SetStyle(ratchetBtn, "padding", "4px")
11873 dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() {
11874 if dom.Confirm("Delete all messages and rotate encryption keys?") {
11875 routeMsg("[\"MLS_RATCHET_DM\"," | jstr(peer) | "]")
11876 clearChildren(msgThreadMessages)
11877 }
11878 }))
11879 dom.AppendChild(hdr, ratchetBtn)
11880
11881 dom.AppendChild(msgThreadContainer, hdr)
11882
11883 // Track for live update when profile arrives.
11884 if _, cached := authorNames[peer]; !cached {
11885 pendingNotes[peer] = append(pendingNotes[peer], threadHdrInner)
11886 }
11887
11888 // Message area.
11889 msgThreadMessages = dom.CreateElement("div")
11890 dom.SetStyle(msgThreadMessages, "flex", "1")
11891 dom.SetStyle(msgThreadMessages, "overflowY", "auto")
11892 dom.SetStyle(msgThreadMessages, "padding", "12px 16px")
11893 dom.AppendChild(msgThreadContainer, msgThreadMessages)
11894
11895 // Compose area.
11896 compose := dom.CreateElement("div")
11897 dom.SetStyle(compose, "display", "flex")
11898 dom.SetStyle(compose, "gap", "8px")
11899 dom.SetStyle(compose, "padding", "8px 16px")
11900 dom.SetStyle(compose, "borderTop", "1px solid var(--border)")
11901 dom.SetStyle(compose, "flexShrink", "0")
11902
11903 msgComposeInput = dom.CreateElement("textarea")
11904 dom.SetAttribute(msgComposeInput, "placeholder", t("msg_placeholder"))
11905 dom.SetStyle(msgComposeInput, "flex", "1")
11906 dom.SetStyle(msgComposeInput, "padding", "8px")
11907 dom.SetStyle(msgComposeInput, "fontFamily", "'Fira Code', monospace")
11908 dom.SetStyle(msgComposeInput, "fontSize", "13px")
11909 dom.SetStyle(msgComposeInput, "background", "var(--bg)")
11910 dom.SetStyle(msgComposeInput, "border", "1px solid var(--border)")
11911 dom.SetStyle(msgComposeInput, "borderRadius", "4px")
11912 dom.SetStyle(msgComposeInput, "color", "var(--fg)")
11913 dom.SetStyle(msgComposeInput, "minHeight", "22px")
11914 dom.SetStyle(msgComposeInput, "maxHeight", "120px")
11915 dom.SetStyle(msgComposeInput, "overflowY", "auto")
11916 dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}")
11917 wireBlossomHandlers(msgComposeInput)
11918 wireMentionHandler(msgComposeInput)
11919 dom.AppendChild(compose, msgComposeInput)
11920
11921 sendBtn := dom.CreateElement("button")
11922 dom.SetTextContent(sendBtn, t("send"))
11923 dom.SetStyle(sendBtn, "padding", "8px 16px")
11924 dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace")
11925 dom.SetStyle(sendBtn, "fontSize", "13px")
11926 dom.SetStyle(sendBtn, "background", "var(--accent)")
11927 dom.SetStyle(sendBtn, "color", "#fff")
11928 dom.SetStyle(sendBtn, "border", "none")
11929 dom.SetStyle(sendBtn, "borderRadius", "4px")
11930 dom.SetStyle(sendBtn, "cursor", "pointer")
11931 dom.SetStyle(sendBtn, "alignSelf", "flex-end")
11932 dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() {
11933 sendMessage()
11934 }))
11935 dom.AppendChild(compose, sendBtn)
11936
11937 dom.AppendChild(msgThreadContainer, compose)
11938
11939 // Fetch profile if metadata is missing - fetchedK0 can be true while
11940 // the relay response is still in flight (showProfile resets + re-sets it).
11941 _, nameOK := authorNames[peer]
11942 if !nameOK {
11943 setFetchedK0(peer, false)
11944 profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
11945 }
11946
11947 // Request history.
11948 routeMsg("[\"MLS_DM_HISTORY\"," | jstr(peer) | ",50,0]")
11949 }
11950
11951 func closeThread() {
11952 msgCurrentConvoID = ""
11953 msgCurrentKind = ""
11954 msgView = "list"
11955
11956 dom.SetStyle(msgThreadContainer, "display", "none")
11957 dom.SetStyle(msgListContainer, "display", "block")
11958
11959 if !navPop {
11960 dom.PushState("/msg")
11961 }
11962
11963 // Refresh list.
11964 routeMsg("[\"MLS_DM_LIST\"]")
11965 }
11966
11967 func renderThreadMessages(peer, msgsJSON string) {
11968 if peer != msgCurrentConvoID {
11969 return
11970 }
11971 if msgsJSON == "" || msgsJSON == "[]" {
11972 return
11973 }
11974
11975 // Parse messages array - each element is a DMRecord object.
11976 // IDB returns newest-first; collect then reverse for oldest-at-top.
11977 type dmMsg struct {
11978 from string
11979 content string
11980 ts int64
11981 }
11982 var msgs []dmMsg
11983
11984 i := 0
11985 for i < len(msgsJSON) && msgsJSON[i] != '[' {
11986 i++
11987 }
11988 i++
11989 for i < len(msgsJSON) {
11990 for i < len(msgsJSON) && msgsJSON[i] != '{' {
11991 if msgsJSON[i] == ']' {
11992 goto done
11993 }
11994 i++
11995 }
11996 if i >= len(msgsJSON) {
11997 break
11998 }
11999 objStart := i
12000 depth := 0
12001 for i < len(msgsJSON) {
12002 if msgsJSON[i] == '{' {
12003 depth++
12004 } else if msgsJSON[i] == '}' {
12005 depth--
12006 if depth == 0 {
12007 i++
12008 break
12009 }
12010 } else if msgsJSON[i] == '"' {
12011 i++
12012 for i < len(msgsJSON) && msgsJSON[i] != '"' {
12013 if msgsJSON[i] == '\\' {
12014 i++
12015 }
12016 i++
12017 }
12018 }
12019 i++
12020 }
12021 obj := msgsJSON[objStart:i]
12022
12023 from := helpers.JsonGetString(obj, "from")
12024 content := helpers.JsonGetString(obj, "content")
12025 ts := jsonGetNum(obj, "created_at")
12026 msgs = append(msgs, dmMsg{from, content, ts})
12027 }
12028 done:
12029
12030 // Reverse for oldest-first.
12031 for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 {
12032 msgs[l], msgs[r] = msgs[r], msgs[l]
12033 }
12034
12035 clearChildren(msgThreadMessages)
12036 for _, m := range msgs {
12037 appendBubble(m.from, m.content, m.ts)
12038 }
12039 scrollToBottom()
12040 }
12041
12042 func appendBubble(from, content string, ts int64) {
12043 isSent := from == pubhex
12044
12045 wrap := dom.CreateElement("div")
12046 dom.SetStyle(wrap, "display", "flex")
12047 dom.SetStyle(wrap, "marginBottom", "6px")
12048 if isSent {
12049 dom.SetStyle(wrap, "justifyContent", "flex-end")
12050 }
12051
12052 outer := dom.CreateElement("div")
12053 dom.SetStyle(outer, "maxWidth", "80%")
12054 if isSent {
12055 dom.SetStyle(outer, "textAlign", "right")
12056 }
12057
12058 bubble := dom.CreateElement("div")
12059 dom.SetStyle(bubble, "display", "inline-block")
12060 dom.SetStyle(bubble, "padding", "8px 12px")
12061 dom.SetStyle(bubble, "borderRadius", "12px")
12062 dom.SetStyle(bubble, "fontSize", "14px")
12063 dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
12064 dom.SetStyle(bubble, "lineHeight", "1.4")
12065 dom.SetStyle(bubble, "wordBreak", "break-word")
12066 dom.SetStyle(bubble, "textAlign", "left")
12067 if isSent {
12068 dom.SetStyle(bubble, "background", "var(--accent)")
12069 dom.SetStyle(bubble, "color", "#fff")
12070 } else {
12071 dom.SetStyle(bubble, "background", "var(--bg2)")
12072 dom.SetStyle(bubble, "color", "var(--fg)")
12073 }
12074 setHTMLWithMedia(bubble, renderMarkdown(content))
12075
12076 // Timestamp below bubble.
12077 tsEl := dom.CreateElement("div")
12078 dom.SetStyle(tsEl, "fontSize", "10px")
12079 dom.SetStyle(tsEl, "color", "var(--muted)")
12080 dom.SetStyle(tsEl, "marginTop", "2px")
12081 if isSent {
12082 dom.SetStyle(tsEl, "textAlign", "right")
12083 }
12084 dom.SetTextContent(tsEl, formatTime(ts))
12085 if isSent && ts == 0 {
12086 pendingTsEls = append(pendingTsEls, tsEl)
12087 }
12088
12089 dom.AppendChild(outer, bubble)
12090 dom.AppendChild(outer, tsEl)
12091
12092 // Email quote-reply button for received messages with email headers.
12093 if !isSent {
12094 emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content)
12095 if isEmail {
12096 replyBtn := dom.CreateElement("div")
12097 dom.SetStyle(replyBtn, "fontSize", "11px")
12098 dom.SetStyle(replyBtn, "color", "var(--accent)")
12099 dom.SetStyle(replyBtn, "cursor", "pointer")
12100 dom.SetStyle(replyBtn, "marginTop", "2px")
12101 dom.SetTextContent(replyBtn, "\u21a9 Reply")
12102 dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
12103 quoted := quoteReply(emailFrom, emailSubject, emailBody)
12104 dom.SetProperty(msgComposeInput, "value", quoted)
12105 }))
12106 dom.AppendChild(outer, replyBtn)
12107 }
12108 }
12109
12110 dom.AppendChild(wrap, outer)
12111 dom.AppendChild(msgThreadMessages, wrap)
12112 }
12113
12114 func appendSystemBubble(text string) {
12115 wrap := dom.CreateElement("div")
12116 dom.SetStyle(wrap, "display", "flex")
12117 dom.SetStyle(wrap, "justifyContent", "center")
12118 dom.SetStyle(wrap, "marginBottom", "6px")
12119
12120 bubble := dom.CreateElement("div")
12121 dom.SetStyle(bubble, "maxWidth", "85%")
12122 dom.SetStyle(bubble, "padding", "8px 12px")
12123 dom.SetStyle(bubble, "borderRadius", "8px")
12124 dom.SetStyle(bubble, "fontSize", "12px")
12125 dom.SetStyle(bubble, "fontFamily", "monospace")
12126 dom.SetStyle(bubble, "lineHeight", "1.5")
12127 dom.SetStyle(bubble, "whiteSpace", "pre-wrap")
12128 dom.SetStyle(bubble, "background", "var(--bg2)")
12129 dom.SetStyle(bubble, "color", "var(--muted)")
12130 dom.SetStyle(bubble, "border", "1px solid var(--muted)")
12131 dom.SetTextContent(bubble, text)
12132
12133 dom.AppendChild(wrap, bubble)
12134 dom.AppendChild(msgThreadMessages, wrap)
12135 scrollToBottom()
12136 }
12137
12138 func scrollToBottom() {
12139 dom.SetProperty(msgThreadMessages, "scrollTop", "999999")
12140 }
12141
12142 func sendMessage() {
12143 content := dom.GetProperty(msgComposeInput, "value")
12144 if content == "" || msgCurrentConvoID == "" {
12145 return
12146 }
12147
12148 // Clear input.
12149 dom.SetProperty(msgComposeInput, "value", "")
12150
12151 if msgCurrentKind == "group" {
12152 routeMsg("[\"MLS_SEND_GROUP\"," | jstr(msgCurrentConvoID) | "," | jstr(content) | "]")
12153 } else {
12154 routeMsg("[\"MLS_SEND_DM\"," | jstr(msgCurrentConvoID) | "," | jstr(content) | "]")
12155 }
12156
12157 // Optimistic render (ts=0 - timestamp not shown for "just sent").
12158 appendBubble(pubhex, content, 0)
12159 scrollToBottom()
12160 }
12161
12162 func handleDMReceived(dmJSON string) {
12163 peer := helpers.JsonGetString(dmJSON, "peer")
12164 from := helpers.JsonGetString(dmJSON, "from")
12165 content := helpers.JsonGetString(dmJSON, "content")
12166 ts := jsonGetNum(dmJSON, "created_at")
12167
12168 if from != pubhex {
12169 showNotifDot()
12170 }
12171
12172 if msgView == "thread" && peer == msgCurrentConvoID {
12173 if from == pubhex {
12174 return
12175 }
12176 appendBubble(from, content, ts)
12177 scrollToBottom()
12178 } else if msgView == "list" {
12179 routeMsg("[\"MLS_DM_LIST\"]")
12180 }
12181 }
12182
12183 // --- Notifications ---
12184
12185 func subscribeNotifications() {
12186 if pubhex == "" {
12187 return
12188 }
12189 // Reset Shell-side state.
12190 oldestNotifTs = 0
12191 notifExhausted = false
12192 notifEmptyStrk = 0
12193 notifInitLoad = true
12194 notifEvents = []*nostr.Event{:0}
12195 notifSeen = nil
12196 // Sync state to Notif Worker, then tell it to subscribe.
12197 // Notif Worker owns the relay subscriptions via N_SUB/N_CLOSE.
12198 notif.Send(`["N_SET_PUBKEY",` | jstr(pubhex) | `]`)
12199 notif.Send(`["N_SET_RELAYS",` | readRelayURLsJSON() | `]`)
12200 notif.Send(`["N_READ_TS",` | i64toa(notifReadTs) | `]`)
12201 if len(myMutes) > 0 {
12202 notif.Send(`["N_SET_MUTES",` | buildJSONStrArr(myMutes) | `]`)
12203 }
12204 notif.Send(`["N_SUBSCRIBE"]`)
12205 }
12206
12207 func handleNotifEvent(ev *nostr.Event) {
12208 if notifSeen == nil {
12209 notifSeen = map[string]bool{}
12210 }
12211 if notifSeen[ev.ID] {
12212 return
12213 }
12214 notifSeen[ev.ID] = true
12215 // Advance cursor before filters so muted/self events don't stall pagination.
12216 if oldestNotifTs == 0 || ev.CreatedAt < oldestNotifTs {
12217 oldestNotifTs = ev.CreatedAt
12218 }
12219 self := ev.PubKey == pubhex
12220 muted := isMuted(notifSenderPK(ev))
12221 rtm := repliesToMuted(ev)
12222 if self || muted || rtm {
12223 return
12224 }
12225 if looksLikeJSONSpam(ev.Content) {
12226 return
12227 }
12228 // Queue missing referenced events for batch fetch after EOSE.
12229 if ev.Kind != 1 {
12230 refID := notifRefID(ev)
12231 if refID != "" {
12232 if _, ok := lookupEvent(refID); !ok {
12233 notifMissing = append(notifMissing, refID)
12234 }
12235 }
12236 }
12237 // Remove empty-state placeholder on first event.
12238 if notifEmptyEl != 0 {
12239 dom.RemoveChild(notifContainer, notifEmptyEl)
12240 notifEmptyEl = 0
12241 }
12242 if notifInitLoad {
12243 notifEvents = append(notifEvents, ev)
12244 if len(notifEvents) > notifEventsMax {
12245 notifEvents = notifEvents[len(notifEvents)-notifEventsMax:]
12246 }
12247 if notifBuilt && activePage == "notifications" && notifPassesFilter(ev) {
12248 row := renderNotifRow(ev)
12249 dom.InsertBefore(notifContainer, row, notifLoadMore)
12250 }
12251 } else {
12252 notifEvents = []*nostr.Event{ev} | notifEvents
12253 if len(notifEvents) > notifEventsMax {
12254 notifEvents = notifEvents[:notifEventsMax]
12255 }
12256 if notifBuilt && activePage == "notifications" && notifPassesFilter(ev) {
12257 row := renderNotifRow(ev)
12258 first := dom.FirstElementChild(notifContainer)
12259 if first != 0 {
12260 dom.InsertBefore(notifContainer, row, first)
12261 } else {
12262 dom.AppendChild(notifContainer, row)
12263 }
12264 }
12265 if ev.Kind != 1 {
12266 if notifRefTimer != 0 {
12267 dom.ClearTimeout(notifRefTimer)
12268 }
12269 notifRefTimer = dom.SetTimeout(func() {
12270 notifRefTimer = 0
12271 fetchNotifRefs()
12272 }, 500)
12273 }
12274 }
12275 maybeShowNotifDot(ev)
12276 }
12277
12278 func fetchNotifRefs() {
12279 if len(notifMissing) == 0 {
12280 return
12281 }
12282 // Dedup against eventCache and already-requested.
12283 seen := map[string]bool{}
12284 var ids []string
12285 for _, id := range notifMissing {
12286 if _, ok := lookupEvent(id); ok {
12287 continue
12288 }
12289 if seen[id] {
12290 continue
12291 }
12292 seen[id] = true
12293 ids = append(ids, id)
12294 }
12295 notifMissing = nil
12296 if len(ids) == 0 {
12297 return
12298 }
12299 filter := "{\"ids\":["
12300 for i, id := range ids {
12301 if i > 0 {
12302 filter = filter | ","
12303 }
12304 filter = filter | jstr(id)
12305 }
12306 filter = filter | "]}"
12307 routeMsg(buildProxyMsg("ntf-ref", filter, readRelayURLs()))
12308 }
12309
12310 func handleNotifRefEvent(ev *nostr.Event) {
12311 setEvent(ev.ID, ev)
12312 fillEmbed(ev)
12313 }
12314
12315 func sortNotifEvents() {
12316 for i := 0; i < len(notifEvents); i++ {
12317 for j := i + 1; j < len(notifEvents); j++ {
12318 if notifEvents[j].CreatedAt > notifEvents[i].CreatedAt {
12319 notifEvents[i], notifEvents[j] = notifEvents[j], notifEvents[i]
12320 }
12321 }
12322 }
12323 }
12324
12325 func insertAtTop(row dom.Element) {
12326 first := dom.FirstChild(notifContainer)
12327 if first != 0 {
12328 dom.InsertBefore(notifContainer, row, first)
12329 } else {
12330 dom.InsertBefore(notifContainer, row, notifLoadMore)
12331 }
12332 }
12333
12334 func loadOlderNotifs() {
12335 notifLoading = true
12336 notifMoreGot = 0
12337 dom.ConsoleLog("[ntf-more] loading until=" | i64toa(oldestNotifTs-1))
12338 filter := "{\"kinds\":[1,6,7,9735],\"#p\":[" | jstr(pubhex) | "],\"limit\":20,\"until\":" | i64toa(oldestNotifTs-1) | "}"
12339 routeMsg(buildProxyMsg("ntf-more", filter, readRelayURLs()))
12340 if notifMoreTimer != 0 {
12341 dom.ClearTimeout(notifMoreTimer)
12342 }
12343 notifMoreTimer = dom.SetTimeout(func() {
12344 notifMoreTimer = 0
12345 if notifLoading {
12346 notifLoading = false
12347 if notifMoreGot == 0 {
12348 notifEmptyStrk++
12349 if notifEmptyStrk >= 3 {
12350 notifExhausted = true
12351 }
12352 } else {
12353 notifEmptyStrk = 0
12354 }
12355 if notifExhausted {
12356 dom.SetStyle(notifLoadMore, "display", "none")
12357 } else if oldestNotifTs > 0 {
12358 dom.SetStyle(notifLoadMore, "display", "block")
12359 }
12360 }
12361 }, 10000)
12362 }
12363
12364 func handleNotifMoreEvent(ev *nostr.Event) {
12365 if notifSeen == nil {
12366 notifSeen = map[string]bool{}
12367 }
12368 if notifSeen[ev.ID] {
12369 return
12370 }
12371 notifSeen[ev.ID] = true
12372 // Advance cursor before filters so muted/self events don't stall pagination.
12373 if oldestNotifTs == 0 || ev.CreatedAt < oldestNotifTs {
12374 oldestNotifTs = ev.CreatedAt
12375 }
12376 if ev.PubKey == pubhex {
12377 return
12378 }
12379 if isMuted(notifSenderPK(ev)) {
12380 return
12381 }
12382 if repliesToMuted(ev) {
12383 return
12384 }
12385 notifMoreGot++
12386 if ev.Kind != 1 {
12387 refID := notifRefID(ev)
12388 if refID != "" {
12389 if _, ok := lookupEvent(refID); !ok {
12390 notifMissing = append(notifMissing, refID)
12391 }
12392 }
12393 }
12394 notifEvents = append(notifEvents, ev)
12395 if len(notifEvents) > notifEventsMax {
12396 notifEvents = notifEvents[len(notifEvents)-notifEventsMax:]
12397 }
12398 if activePage == "notifications" && notifLoadMore != 0 && notifPassesFilter(ev) {
12399 row := renderNotifRow(ev)
12400 dom.InsertBefore(notifContainer, row, notifLoadMore)
12401 }
12402 }
12403
12404 func showNotifDot() {
12405 if !notifUnread {
12406 notifUnread = true
12407 dom.SetStyle(notifDot, "display", "block")
12408 }
12409 }
12410
12411 func maybeShowNotifDot(ev *nostr.Event) {
12412 if ev.CreatedAt > notifReadTs && activePage != "notifications" {
12413 showNotifDot()
12414 }
12415 }
12416
12417 func notifPassesFilter(ev *nostr.Event) (ok bool) {
12418 switch notifFilter {
12419 case "mentions":
12420 return ev.Kind == 1
12421 case "reactions":
12422 return ev.Kind == 6 || ev.Kind == 7
12423 case "zaps":
12424 return ev.Kind == 9735
12425 }
12426 return true
12427 }
12428
12429 func renderNotifPage() {
12430 if notifContainer == 0 {
12431 return
12432 }
12433 notifBuilt = true
12434 sortNotifEvents()
12435 clearChildren(notifContainer)
12436
12437 shown := 0
12438 for _, ev := range notifEvents {
12439 if !notifPassesFilter(ev) {
12440 continue
12441 }
12442 shown++
12443 row := renderNotifRow(ev)
12444 dom.AppendChild(notifContainer, row)
12445 }
12446
12447 if shown == 0 {
12448 notifEmptyEl = dom.CreateElement("div")
12449 dom.SetTextContent(notifEmptyEl, t("no_notifications"))
12450 dom.SetStyle(notifEmptyEl, "color", "var(--muted)")
12451 dom.SetStyle(notifEmptyEl, "fontFamily", "'Fira Code', monospace")
12452 dom.SetStyle(notifEmptyEl, "fontSize", "13px")
12453 dom.AppendChild(notifContainer, notifEmptyEl)
12454 } else {
12455 notifEmptyEl = 0
12456 }
12457
12458 notifLoadMore = dom.CreateElement("div")
12459 dom.SetStyle(notifLoadMore, "textAlign", "center")
12460 dom.SetStyle(notifLoadMore, "padding", "16px 0")
12461 if notifExhausted || oldestNotifTs == 0 {
12462 dom.SetStyle(notifLoadMore, "display", "none")
12463 }
12464 lnk := dom.CreateElement("span")
12465 dom.SetTextContent(lnk, "load more")
12466 dom.SetStyle(lnk, "cursor", "pointer")
12467 dom.SetStyle(lnk, "color", "var(--accent)")
12468 dom.SetStyle(lnk, "fontSize", "14px")
12469 dom.SetStyle(lnk, "fontFamily", "'Fira Code', monospace")
12470 dom.AddEventListener(lnk, "click", dom.RegisterCallback(func() {
12471 if !notifLoading && !notifExhausted && oldestNotifTs > 0 {
12472 dom.SetStyle(notifLoadMore, "display", "none")
12473 loadOlderNotifs()
12474 }
12475 }))
12476 dom.AppendChild(notifLoadMore, lnk)
12477 dom.AppendChild(notifContainer, notifLoadMore)
12478 }
12479
12480 // releaseNotifRowCBs releases all Moxie callback IDs registered during notif
12481 // row rendering. Called before rebuild and on page exit to prevent the callback
12482 // table from growing without bound across filter changes and page visits.
12483 func releaseNotifRowCBs() {
12484 for _, id := range notifRowCBs {
12485 dom.ReleaseCallback(id)
12486 }
12487 notifRowCBs = nil
12488 }
12489
12490 // notifRegCB registers a callback and records its ID for later release.
12491 func notifRegCB(fn func()) (n int32) {
12492 id := dom.RegisterCallback(fn)
12493 notifRowCBs = append(notifRowCBs, id)
12494 return id
12495 }
12496
12497 func rebuildNotifRows() {
12498 if notifContainer == 0 {
12499 return
12500 }
12501 releaseNotifRowCBs()
12502 clearChildren(notifContainer)
12503 shown := 0
12504 for _, ev := range notifEvents {
12505 if !notifPassesFilter(ev) {
12506 continue
12507 }
12508 shown++
12509 dom.AppendChild(notifContainer, renderNotifRow(ev))
12510 }
12511 if shown == 0 {
12512 notifEmptyEl = dom.CreateElement("div")
12513 dom.SetTextContent(notifEmptyEl, t("no_notifications"))
12514 dom.SetStyle(notifEmptyEl, "color", "var(--muted)")
12515 dom.SetStyle(notifEmptyEl, "fontFamily", "'Fira Code', monospace")
12516 dom.SetStyle(notifEmptyEl, "fontSize", "13px")
12517 dom.AppendChild(notifContainer, notifEmptyEl)
12518 } else {
12519 notifEmptyEl = 0
12520 }
12521 if notifLoadMore != 0 {
12522 dom.AppendChild(notifContainer, notifLoadMore)
12523 }
12524 }
12525
12526 func markNotifsRead() {
12527 if len(notifEvents) > 0 {
12528 notifReadTs = notifEvents[0].CreatedAt
12529 dom.IDBPut("settings", "notif-read-ts", i64toa(notifReadTs))
12530 // Publish watermark to relay so it syncs to other devices/sessions.
12531 saveAppSettings()
12532 }
12533 notifUnread = false
12534 dom.SetStyle(notifDot, "display", "none")
12535 el := dom.QuerySelectorFrom(notifContainer, "[data-notif-new]")
12536 for el != 0 {
12537 dom.RemoveAttribute(el, "data-notif-new")
12538 dom.SetStyle(el, "borderLeft", "none")
12539 dom.SetStyle(el, "paddingLeft", "0")
12540 dom.SetStyle(el, "fontWeight", "normal")
12541 el = dom.QuerySelectorFrom(notifContainer, "[data-notif-new]")
12542 }
12543 }
12544
12545 // zapAmount extracts the sats amount from a kind 9735 zap receipt.
12546 // Checks the "amount" tag first (millisats), then falls back to bolt11.
12547 func zapAmount(ev *nostr.Event) (s string) {
12548 for _, tag := range ev.Tags {
12549 if len(tag) >= 2 && tag[0] == "amount" {
12550 // Value is in millisats - divide by 1000.
12551 msats := parseI64(tag[1])
12552 if msats > 0 {
12553 return i64toa(msats / 1000)
12554 }
12555 }
12556 }
12557 return ""
12558 }
12559
12560 // zapSenderPK tries to extract the real sender pubkey from the zap request
12561 // embedded in the "description" tag of a kind 9735 receipt.
12562 func zapSenderPK(ev *nostr.Event) (s string) {
12563 for _, tag := range ev.Tags {
12564 if len(tag) >= 2 && tag[0] == "description" {
12565 desc := tag[1]
12566 pk := extractJSONField(desc, "pubkey")
12567 if len(pk) == 64 {
12568 return pk
12569 }
12570 }
12571 }
12572 return ""
12573 }
12574
12575 // extractJSONField finds "key":"value" in a JSON string and returns value.
12576 func extractJSONField(json string, key string) (s string) {
12577 needle := "\"" | key | "\":\""
12578 idx := -1
12579 for i := 0; i+len(needle) <= len(json); i++ {
12580 if json[i:i+len(needle)] == needle {
12581 idx = i + len(needle)
12582 break
12583 }
12584 }
12585 if idx < 0 {
12586 return ""
12587 }
12588 end := idx
12589 for end < len(json) && json[end] != '"' {
12590 end++
12591 }
12592 return json[idx:end]
12593 }
12594
12595 func notifRefID(ev *nostr.Event) (s string) {
12596 for _, tag := range ev.Tags {
12597 if len(tag) >= 2 && tag[0] == "e" {
12598 return tag[1]
12599 }
12600 }
12601 return ""
12602 }
12603
12604 func relativeTime(ts int64) (s string) {
12605 now := dom.NowSeconds()
12606 d := now - ts
12607 if d < 0 {
12608 d = 0
12609 }
12610 if d < 60 {
12611 return "now"
12612 }
12613 if d < 3600 {
12614 return i64toa(d/60) | "m"
12615 }
12616 if d < 86400 {
12617 return i64toa(d/3600) | "h"
12618 }
12619 return i64toa(d/86400) | "d"
12620 }
12621
12622 func notifSenderPK(ev *nostr.Event) (s string) {
12623 if ev.Kind == 9735 {
12624 pk := zapSenderPK(ev)
12625 if pk != "" {
12626 return pk
12627 }
12628 }
12629 return ev.PubKey
12630 }
12631
12632 func renderNotifRow(ev *nostr.Event) (e dom.Element) {
12633 senderPK := notifSenderPK(ev)
12634 unread := ev.CreatedAt > notifReadTs
12635
12636 row := dom.CreateElement("div")
12637 dom.SetStyle(row, "display", "flex")
12638 dom.SetStyle(row, "alignItems", "flex-start")
12639 dom.SetStyle(row, "gap", "6px")
12640 dom.SetStyle(row, "padding", "4px 0")
12641 dom.SetStyle(row, "cursor", "pointer")
12642 dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
12643 dom.SetStyle(row, "fontSize", "12px")
12644 if unread {
12645 dom.SetStyle(row, "borderLeft", "3px solid var(--accent)")
12646 dom.SetStyle(row, "paddingLeft", "6px")
12647 dom.SetStyle(row, "fontWeight", "bold")
12648 dom.SetAttribute(row, "data-notif-new", "1")
12649 }
12650
12651 // Avatar.
12652 ava := dom.CreateElement("img")
12653 pic, picOK := authorPics[senderPK]
12654 if !picOK || pic == "" {
12655 pic = "/smesh-logo.svg"
12656 profile.Send(`["P_RESOLVE_FORCE",` | jstr(senderPK) | `]`)
12657 // Register ava so applyAuthorProfile updates it immediately when loaded.
12658 pendingNotifAvatars[senderPK] = append(pendingNotifAvatars[senderPK], ava)
12659 }
12660 setMediaSrc(ava, pic)
12661 dom.SetStyle(ava, "width", "24px")
12662 dom.SetStyle(ava, "height", "24px")
12663 dom.SetStyle(ava, "borderRadius", "50%")
12664 dom.SetStyle(ava, "flexShrink", "0")
12665 dom.SetStyle(ava, "cursor", "pointer")
12666 dom.SetAttribute(ava, "onclick", "event.stopPropagation()")
12667 pk := senderPK
12668 dom.AddEventListener(ava, "click", notifRegCB(func() {
12669 showProfile(pk)
12670 }))
12671 dom.AppendChild(row, ava)
12672
12673 // Icon + action label.
12674 var icon string
12675 var label string
12676 switch ev.Kind {
12677 case 1:
12678 icon = "\xf0\x9f\x92\xac" // 💬
12679 label = ""
12680 case 6:
12681 icon = "\xf0\x9f\x94\x81" // 🔁
12682 label = ""
12683 case 7:
12684 emoji := ev.Content
12685 if emoji == "" || emoji == "+" {
12686 emoji = "\xe2\x9d\xa4\xef\xb8\x8f" // ❤️
12687 }
12688 icon = emoji
12689 label = ""
12690 case 9735:
12691 icon = "\xe2\x9a\xa1" // ⚡
12692 amt := zapAmount(ev)
12693 if amt != "" {
12694 label = amt | " sats"
12695 }
12696 }
12697
12698 iconEl := dom.CreateElement("span")
12699 dom.SetStyle(iconEl, "flexShrink", "0")
12700 dom.SetTextContent(iconEl, icon)
12701 dom.AppendChild(row, iconEl)
12702
12703 if label != "" {
12704 lblEl := dom.CreateElement("span")
12705 dom.SetStyle(lblEl, "flexShrink", "0")
12706 dom.SetStyle(lblEl, "color", "var(--accent)")
12707 dom.SetTextContent(lblEl, label)
12708 dom.AppendChild(row, lblEl)
12709 }
12710
12711 // Content preview - line-clamped to 10 lines, same font as regular notes.
12712 refID := notifRefID(ev)
12713 var previewText string
12714 if ev.Kind == 1 {
12715 previewText = ev.Content
12716 } else if refID != "" {
12717 if ref, ok := lookupEvent(refID); ok && ref != nil {
12718 previewText = ref.Content
12719 }
12720 }
12721 prev := dom.CreateElement("span")
12722 dom.SetStyle(prev, "flex", "1")
12723 dom.SetStyle(prev, "minWidth", "0")
12724 dom.SetStyle(prev, "overflow", "hidden")
12725 dom.SetStyle(prev, "color", "var(--muted)")
12726 dom.SetStyle(prev, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
12727 dom.SetStyle(prev, "fontSize", "14px")
12728 dom.SetStyle(prev, "lineHeight", "1.5")
12729 dom.SetStyle(prev, "whiteSpace", "nowrap")
12730 dom.SetStyle(prev, "textOverflow", "ellipsis")
12731 dom.SetTextContent(prev, previewText)
12732 dom.AppendChild(row, prev)
12733
12734 // Relative timestamp, right-aligned.
12735 tsEl := dom.CreateElement("span")
12736 dom.SetStyle(tsEl, "flexShrink", "0")
12737 dom.SetStyle(tsEl, "color", "var(--muted)")
12738 dom.SetStyle(tsEl, "fontSize", "11px")
12739 dom.SetTextContent(tsEl, relativeTime(ev.CreatedAt))
12740 dom.AppendChild(row, tsEl)
12741
12742 // Click navigates to the referenced event.
12743 if refID != "" {
12744 tid := refID
12745 dom.AddEventListener(row, "click", notifRegCB(func() {
12746 rootID := tid
12747 if ref, ok := lookupEvent(tid); ok && ref != nil {
12748 if r := getRootID(ref); r != "" {
12749 rootID = r
12750 }
12751 }
12752 showNoteThread(rootID, tid)
12753 }))
12754 }
12755
12756 return row
12757 }
12758
12759 // Wallet panel - implementation lives in wallet.mx.
12760
12761 // --- Email header parsing for quote-reply ---
12762
12763 // parseEmailHeaders checks if content looks like a forwarded email and extracts
12764 // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found.
12765 func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) {
12766 lines := splitLines(content)
12767 headerEnd := -1
12768 for i, line := range lines {
12769 if line == "" {
12770 headerEnd = i
12771 break
12772 }
12773 if hasPrefix(line, "From: ") {
12774 from = line[6:]
12775 } else if hasPrefix(line, "Subject: ") {
12776 subject = line[9:]
12777 } else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") {
12778 // Known header, continue
12779 } else if i == 0 {
12780 return "", "", "", false
12781 }
12782 }
12783 if from == "" && subject == "" {
12784 return "", "", "", false
12785 }
12786 if headerEnd >= 0 && headerEnd+1 < len(lines) {
12787 body = joinLines(lines[headerEnd+1:])
12788 }
12789 return from, subject, body, true
12790 }
12791
12792 func quoteReply(from, subject, body string) (s string) {
12793 out := "To: " | from | "\n"
12794 if subject != "" {
12795 if !hasPrefix(subject, "Re: ") {
12796 subject = "Re: " | subject
12797 }
12798 out = out | "Subject: " | subject | "\n"
12799 }
12800 out = out | "\n\n"
12801 if body != "" {
12802 lines := splitLines(body)
12803 for _, line := range lines {
12804 out = out | "> " | line | "\n"
12805 }
12806 }
12807 return out
12808 }
12809
12810 func splitLines(s string) (ss []string) {
12811 var lines []string
12812 for {
12813 idx := strIndex(s, "\n")
12814 if idx < 0 {
12815 lines = append(lines, s)
12816 return lines
12817 }
12818 lines = append(lines, s[:idx])
12819 s = s[idx+1:]
12820 }
12821 }
12822
12823 func joinLines(lines []string) (s string) {
12824 out := ""
12825 for i, line := range lines {
12826 if i > 0 {
12827 out = out | "\n"
12828 }
12829 out = out | line
12830 }
12831 return out
12832 }
12833
12834 func hasPrefix(s, prefix string) (ok bool) {
12835 return len(s) >= len(prefix) && s[:len(prefix)] == prefix
12836 }
12837
12838 // --- Markdown rendering ---
12839 // All functions use string concatenation and indexOf - no byte-level ops.
12840 // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji.
12841
12842 // renderMarkdown converts note text to safe HTML via markdown-it jsbridge.
12843 // Pre-processes nostr entities and emoji shortcodes with placeholders,
12844 // runs markdown-it for commonmark + unicode-aware URL linkification,
12845 // then restores placeholders and converts media links to embeds.
12846 func renderMarkdown(s string) (sv string) {
12847 // Extract nostr: entities before markdown-it touches the text.
12848 var nostrSlots []string
12849 s = extractNostrEntities(s, &nostrSlots)
12850
12851 // Extract emoji shortcodes.
12852 var emojiSlots []string
12853 s = extractEmojiShortcodes(s, &emojiSlots)
12854
12855 // Extract hashtags (#tag) so markdown-it doesn't strip the #.
12856 var hashSlots []string
12857 s = extractHashtags(s, &hashSlots)
12858
12859 // markdown-it: commonmark, auto-linkify (unicode-aware), breaks.
12860 s = markdown.Render(s)
12861
12862 // Restore hashtag placeholders.
12863 for i, html := range hashSlots {
12864 s = strReplace(s, "\x02HASH" | itoa(i) | "\x02", html)
12865 }
12866
12867 // Restore emoji placeholders.
12868 for i, html := range emojiSlots {
12869 s = strReplace(s, "\x02EMOJI" | itoa(i) | "\x02", html)
12870 }
12871
12872 // Restore nostr entity placeholders.
12873 for i, html := range nostrSlots {
12874 s = strReplace(s, "\x02NOSTR" | itoa(i) | "\x02", html)
12875 }
12876
12877 // Convert <a> tags pointing to image/video URLs into embeds.
12878 s = convertMediaLinks(s)
12879
12880 return s
12881 }
12882
12883 // extractNostrEntities replaces nostr:... entities with placeholders.
12884 func extractNostrEntities(s string, slots *[]string) (sv string) {
12885 out := ""
12886 for {
12887 idx := strIndex(s, "nostr:")
12888 if idx < 0 {
12889 return out | s
12890 }
12891 out = out | s[:idx]
12892 rest := s[idx+6:]
12893 end := 0
12894 for end < len(rest) {
12895 c := rest[end]
12896 if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
12897 end++
12898 } else {
12899 break
12900 }
12901 }
12902 if end < 10 {
12903 out = out | "nostr:"
12904 s = rest
12905 continue
12906 }
12907 entity := rest[:end]
12908 s = rest[end:]
12909 html := renderNostrEntity(entity)
12910 id := len(*slots)
12911 *slots = append(*slots, html)
12912 out = out | "\x02NOSTR" | itoa(id) | "\x02"
12913 }
12914 }
12915
12916 // renderNostrEntity produces the HTML for a single nostr bech32 entity.
12917 func renderNostrEntity(entity string) (s string) {
12918 if len(entity) > 6 && entity[:6] == "naddr1" {
12919 na := helpers.DecodeNaddr(entity)
12920 if na != nil {
12921 coord := helpers.NaddrCoordKey(na.Kind, na.Pubkey, na.D)
12922 for _, r := range na.Relays {
12923 embedRelayHints[coord] = append(embedRelayHints[coord], r)
12924 }
12925 if !lookupFetchedK0(na.Pubkey) {
12926 profile.Send(`["P_RESOLVE",` | jstr(na.Pubkey) | `]`)
12927 }
12928 return makeCoordEmbedPlaceholder(coord, na.Kind, na.Pubkey, na.D)
12929 }
12930 } else if len(entity) > 7 && entity[:7] == "nevent1" {
12931 nev := helpers.DecodeNevent(entity)
12932 if nev != nil {
12933 if len(nev.Relays) > 0 {
12934 embedRelayHints[nev.ID] = nev.Relays
12935 }
12936 return makeEmbedPlaceholder(nev.ID)
12937 }
12938 } else if len(entity) > 5 && entity[:5] == "note1" {
12939 id := helpers.DecodeNote(entity)
12940 if id != nil {
12941 return makeEmbedPlaceholder(helpers.HexEncode(id))
12942 }
12943 } else if len(entity) > 9 && entity[:9] == "nprofile1" {
12944 np := helpers.DecodeNprofile(entity)
12945 if np != nil {
12946 name := np.Pubkey[:8] | "..."
12947 if n, ok := authorNames[np.Pubkey]; ok && n != "" {
12948 name = n
12949 } else {
12950 profile.Send(`["P_RESOLVE",` | jstr(np.Pubkey) | `]`)
12951 }
12952 npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey))
12953 return "<a href=\"/p/" | npub | "\" data-pk=\"" | np.Pubkey | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
12954 }
12955 } else if len(entity) > 5 && entity[:5] == "npub1" {
12956 pk := helpers.DecodeNpub(entity)
12957 if pk != nil {
12958 hex := helpers.HexEncode(pk)
12959 name := hex[:8] | "..."
12960 if n, ok := authorNames[hex]; ok && n != "" {
12961 name = n
12962 } else {
12963 profile.Send(`["P_RESOLVE",` | jstr(hex) | `]`)
12964 }
12965 npub := helpers.EncodeNpub(pk)
12966 return "<a href=\"/p/" | npub | "\" data-pk=\"" | hex | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
12967 }
12968 }
12969 return "nostr:" | entity
12970 }
12971
12972 // extractEmojiShortcodes replaces :name: shortcodes with placeholders.
12973 func extractEmojiShortcodes(s string, slots *[]string) (sv string) {
12974 out := ""
12975 i := 0
12976 for i < len(s) {
12977 if s[i] == ':' {
12978 end := -1
12979 valid := true
12980 for j := i + 1; j < len(s) && j < i+40; j++ {
12981 c := s[j]
12982 if c == ':' {
12983 end = j
12984 break
12985 }
12986 if c == ' ' || c == '\n' || c == '\r' || c == '<' {
12987 valid = false
12988 break
12989 }
12990 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
12991 valid = false
12992 break
12993 }
12994 }
12995 if valid && end > i+1 {
12996 name := s[i+1 : end]
12997 code := ":" | name | ":"
12998 var html string
12999 if emoji, ok := emojiByName[code]; ok {
13000 html = emoji
13001 } else {
13002 html = "<span style=\"font-size:0;line-height:0\">" | code | "</span>"
13003 }
13004 id := len(*slots)
13005 *slots = append(*slots, html)
13006 out = out | "\x02EMOJI" | itoa(id) | "\x02"
13007 i = end + 1
13008 continue
13009 }
13010 }
13011 b := s[i]
13012 n := 1
13013 if b >= 0xF0 {
13014 n = 4
13015 } else if b >= 0xE0 {
13016 n = 3
13017 } else if b >= 0xC0 {
13018 n = 2
13019 }
13020 if i+n > len(s) {
13021 n = len(s) - i
13022 }
13023 out = out | s[i : i+n]
13024 i += n
13025 }
13026 return out
13027 }
13028
13029 // convertMediaLinks finds <a> tags with image/video URLs and replaces them.
13030 func convertMediaLinks(s string) (sv string) {
13031 out := ""
13032 for {
13033 idx := strIndex(s, "<a ")
13034 if idx < 0 {
13035 return out | s
13036 }
13037 out = out | s[:idx]
13038 s = s[idx:]
13039 // Find closing </a>.
13040 endTag := strIndex(s, "</a>")
13041 if endTag < 0 {
13042 return out | s
13043 }
13044 full := s[:endTag+4]
13045 s = s[endTag+4:]
13046 // Extract href value.
13047 hrefIdx := strIndex(full, "href=\"")
13048 if hrefIdx < 0 {
13049 out = out | full
13050 continue
13051 }
13052 hrefStart := hrefIdx + 6
13053 hrefEnd := strIndex(full[hrefStart:], "\"")
13054 if hrefEnd < 0 {
13055 out = out | full
13056 continue
13057 }
13058 href := full[hrefStart : hrefStart+hrefEnd]
13059 if len(href) > 8 && strIndex(href[8:], "http") >= 0 {
13060 out = out | full
13061 continue
13062 }
13063 if ytID := youTubeVideoID(href); ytID != "" {
13064 // Strip surrounding <p> when the YouTube link is the sole paragraph.
13065 const pOpen = "<p style=\"margin:0 0 1em 0\">"
13066 if hasSuffix(out, pOpen) {
13067 out = out[:len(out)-len(pOpen)]
13068 if hasPrefix(s, "</p>") {
13069 s = s[4:]
13070 }
13071 }
13072 thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
13073 // data-invidious-id triggers the Invidious player when clicked.
13074 // The fallback href still opens YouTube in a new tab.
13075 out = out | "<div data-invidious-id=\"" | escapeHTMLAttr(ytID) | "\" data-yt-href=\"" | escapeHTMLAttr(href) | "\" style=\"display:block;margin:4px 0;cursor:pointer\">" |
13076 "<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\">" |
13077 "<span data-ytid=\"" | escapeHTMLAttr(ytID) | "\" style=\"display:block;font-size:13px;color:var(--accent);word-break:break-word\"></span>" |
13078 "</div>"
13079 } else if isBandcampURL(href) {
13080 // Bandcamp link: show as a plain link with a "▶ campion" button alongside.
13081 out = out | "<div style=\"margin:4px 0\">" |
13082 "<a href=\"" | escapeHTMLAttr(href) | "\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent);word-break:break-all\">" | escapeHTMLAttr(href) | "</a>" |
13083 " <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>" |
13084 "</div>"
13085 } else if isImageURL(href) {
13086 orig := "\" data-orig-url=\"" | escapeHTMLAttr(href) | "\""
13087 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\">"
13088 } else if isVideoURL(href) {
13089 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>"
13090 } else {
13091 out = out | full
13092 }
13093 }
13094 }
13095
13096 // truncateByLine cuts `text` at the end of the line containing position `target`.
13097 // Returns (truncated, true) if content was removed. Returns (text, false) otherwise.
13098 // This avoids mid-line cuts where only a few words would remain hidden.
13099 func truncateByLine(text string, target int32) (string, bool) {
13100 if len(text) <= target {
13101 return text, false
13102 }
13103 cut := target
13104 for cut < len(text) && text[cut] != '\n' {
13105 cut++
13106 }
13107 if cut >= len(text) {
13108 return text, false
13109 }
13110 return text[:cut], true
13111 }
13112
13113 // strReplace replaces all occurrences of old with new using indexOf.
13114 func strReplace(s, old, nw string) (result string) {
13115 out := ""
13116 for {
13117 idx := strIndex(s, old)
13118 if idx < 0 {
13119 return out | s
13120 }
13121 out = out | s[:idx] | nw
13122 s = s[idx+len(old):]
13123 }
13124 }
13125
13126 func isImageURL(url string) (ok bool) {
13127 u := toLower(url)
13128 return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") ||
13129 hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg")
13130 }
13131
13132 // migratedRelayTypes is the set of message types routed to the relay-proxy
13133 // worker. Anything else still flows to the legacy SW.
13134 //
13135 var migratedRelayTypes = map[string]bool{
13136 "REQ": true,
13137 "CLOSE": true,
13138 "EVENT": true,
13139 "PUBLISH_TO": true,
13140 "PROXY": true,
13141 "SET_PUBKEY": true,
13142 "SET_WRITE_RELAYS": true,
13143 "ENC_KEY": true,
13144 }
13145
13146 // mlsWorkerTypes are routed to the MLS Worker.
13147 var mlsWorkerTypes = map[string]bool{
13148 "MLS_INIT": true,
13149 "MLS_SEND_DM": true,
13150 "MLS_SEND_GROUP": true,
13151 "MLS_RATCHET_DM": true,
13152 "MLS_DM_LIST": true,
13153 "MLS_DM_HISTORY": true,
13154 }
13155
13156 // swTypes are routed to the Service Worker.
13157 var swTypes = map[string]bool{
13158 "activate-update": true,
13159 }
13160
13161 // routeMsg dispatches a JSON-array message to the appropriate worker.
13162 func routeMsg(msg string) {
13163 if len(msg) >= 4 && msg[0] == '[' && msg[1] == '"' {
13164 end := 2
13165 for end < len(msg) && msg[end] != '"' {
13166 end++
13167 }
13168 typ := msg[2:end]
13169 if migratedRelayTypes[typ] {
13170 relayproxy.Send(msg)
13171 return
13172 }
13173 if mlsWorkerTypes[typ] {
13174 mlsw.Send(msg)
13175 return
13176 }
13177 if swTypes[typ] {
13178 dom.PostToSW(msg)
13179 return
13180 }
13181 dom.ConsoleLog("routeMsg: UNROUTED message type: " | typ)
13182 return
13183 }
13184 if msg == "activate-update" {
13185 dom.PostToSW(msg)
13186 return
13187 }
13188 n := len(msg)
13189 if n > 60 { n = 60 }
13190 dom.ConsoleLog("routeMsg: UNROUTED non-array message: " | msg[:n])
13191 }
13192
13193 // setHTMLWithMedia sets innerHTML and processes media links and lightboxes.
13194 // makeShowMore creates the show-more/less toggle span (initially hidden).
13195 // attachShowMore must be called after the note is in the DOM to decide
13196 // whether to reveal it based on actual rendered height.
13197 func makeShowMore(content dom.Element) (e dom.Element) {
13198 more := dom.CreateElement("span")
13199 dom.SetTextContent(more, t("show_more"))
13200 dom.SetStyle(more, "color", "var(--accent)")
13201 dom.SetStyle(more, "cursor", "pointer")
13202 dom.SetStyle(more, "fontSize", "13px")
13203 dom.SetStyle(more, "display", "none")
13204 expanded := false
13205 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
13206 expanded = !expanded
13207 if expanded {
13208 dom.SetAttribute(content, "data-expanded", "1")
13209 dom.SetStyle(content, "overflow", "visible")
13210 dom.SetStyle(content, "maxHeight", "none")
13211 dom.SetTextContent(more, t("show_less"))
13212 } else {
13213 dom.RemoveAttribute(content, "data-expanded")
13214 dom.SetStyle(content, "overflow", "hidden")
13215 dom.SetStyle(content, "maxHeight", "18em")
13216 dom.SetTextContent(more, t("show_more"))
13217 }
13218 }))
13219 return more
13220 }
13221
13222 func attachShowMore(content, more dom.Element) {
13223 c := content
13224 m := more
13225 check := func() {
13226 if dom.GetAttribute(c, "data-expanded") == "1" {
13227 return
13228 }
13229 scrollH := parseI64(dom.GetProperty(c, "scrollHeight"))
13230 clientH := parseI64(dom.GetProperty(c, "clientHeight"))
13231 if clientH > 0 && scrollH <= clientH {
13232 dom.SetStyle(m, "display", "none")
13233 } else if scrollH > clientH {
13234 dom.SetStyle(m, "display", "inline-flex")
13235 }
13236 }
13237 dom.ObserveResize(c, dom.RegisterCallback(check))
13238 dom.SetTimeout(check, 200)
13239 }
13240
13241 func isBandcampURL(url string) (ok bool) {
13242 // Matches *.bandcamp.com/* URLs.
13243 i := strIndex(url, "://")
13244 if i < 0 {
13245 return false
13246 }
13247 host := url[i+3:]
13248 slash := strIndex(host, "/")
13249 if slash >= 0 {
13250 host = host[:slash]
13251 }
13252 return hasSuffix(host, ".bandcamp.com") || host == "bandcamp.com"
13253 }
13254
13255 func setHTMLWithMedia(root dom.Element, html string) {
13256 dom.SetInnerHTML(root, html)
13257 flushYtTitles(root)
13258 attachImageLightboxes(root)
13259 attachYTLightboxes(root)
13260 attachCampionLinks(root)
13261 attachHashtagLinks(root)
13262 }
13263
13264 // ytTitleCache maps YouTube video ID -> fetched title.
13265 var ytTitleCache = map[string]string{}
13266
13267 // flushYtTitles finds every [data-ytid] span and fills it with the video title.
13268 func flushYtTitles(root dom.Element) {
13269 el := dom.QuerySelectorFrom(root, "[data-ytid]")
13270 for el != 0 {
13271 ytID := dom.GetAttribute(el, "data-ytid")
13272 dom.RemoveAttribute(el, "data-ytid")
13273 if ytID == "" {
13274 el = dom.QuerySelectorFrom(root, "[data-ytid]")
13275 continue
13276 }
13277 if title, ok := ytTitleCache[ytID]; ok {
13278 dom.SetTextContent(el, title)
13279 el = dom.QuerySelectorFrom(root, "[data-ytid]")
13280 continue
13281 }
13282 capturedEl := el
13283 capturedID := ytID
13284 oembedURL := "https://www.youtube.com/oembed?url=" |
13285 "https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D" | ytID |
13286 "&format=json"
13287 dom.FetchText(oembedURL, func(body string) {
13288 title := helpers.JsonGetString(body, "title")
13289 if title == "" {
13290 title = capturedID
13291 }
13292 ytTitleCache[capturedID] = title
13293 dom.SetTextContent(capturedEl, title)
13294 })
13295 el = dom.QuerySelectorFrom(root, "[data-ytid]")
13296 }
13297 }
13298
13299 // attachImageLightboxes finds every img[data-orig-url] in root, removes the
13300 // attribute (so the loop terminates), and wires a click handler that opens
13301 // the full-screen lightbox. Called from setHTMLWithMedia after innerHTML is set.
13302 func attachImageLightboxes(root dom.Element) {
13303 el := dom.QuerySelectorFrom(root, "[data-orig-url]")
13304 for el != 0 {
13305 url := dom.GetAttribute(el, "data-orig-url")
13306 dom.RemoveAttribute(el, "data-orig-url")
13307 if url != "" {
13308 capturedURL := url
13309 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
13310 showImageLightbox(capturedURL)
13311 }))
13312 }
13313 el = dom.QuerySelectorFrom(root, "[data-orig-url]")
13314 }
13315 }
13316
13317 // attachYTLightboxes wires click handlers on [data-invidious-id] divs.
13318 // Clicking replaces the thumbnail inline with an Invidious embed iframe.
13319 func attachYTLightboxes(root dom.Element) {
13320 el := dom.QuerySelectorFrom(root, "[data-invidious-id]")
13321 for el != 0 {
13322 ytID := dom.GetAttribute(el, "data-invidious-id")
13323 ytHref := dom.GetAttribute(el, "data-yt-href")
13324 dom.RemoveAttribute(el, "data-invidious-id")
13325 dom.RemoveAttribute(el, "data-yt-href")
13326 if ytID != "" {
13327 capturedEl := el
13328 capturedID := ytID
13329 capturedHref := ytHref
13330 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
13331 if invidiousURL != "" {
13332 inlineInvidiousEmbed(capturedEl, capturedID)
13333 } else {
13334 dom.WindowOpen(capturedHref)
13335 }
13336 }))
13337 }
13338 el = dom.QuerySelectorFrom(root, "[data-invidious-id]")
13339 }
13340 }
13341
13342 // inlineInvidiousEmbed replaces the thumbnail wrapper div's content with an
13343 // Invidious embed iframe sized at 16:9, plus a small control row below.
13344 // Stamps data-inv-ytid on the wrapper so stopInlineMedia can restore it.
13345 func inlineInvidiousEmbed(wrapper dom.Element, ytID string) {
13346 dom.ReleaseChildren(wrapper)
13347 dom.SetInnerHTML(wrapper, "")
13348 dom.SetStyle(wrapper, "cursor", "default")
13349 dom.SetAttribute(wrapper, "data-inv-ytid", ytID)
13350 dom.SetStyle(wrapper, "maxWidth", "100%")
13351
13352 iframe := dom.CreateElement("iframe")
13353 dom.SetAttribute(iframe, "src", invidiousURL | "/embed/" | ytID | "?autoplay=1")
13354 dom.SetAttribute(iframe, "allowfullscreen", "true")
13355 dom.SetAttribute(iframe, "credentialless", "")
13356 dom.SetStyle(iframe, "width", "100%")
13357 dom.SetStyle(iframe, "aspectRatio", "16/9")
13358 dom.SetStyle(iframe, "border", "none")
13359 dom.SetStyle(iframe, "display", "block")
13360 dom.SetStyle(iframe, "borderRadius", "8px")
13361 dom.SetStyle(iframe, "background", "#000")
13362 dom.AppendChild(wrapper, iframe)
13363
13364 // Control row: "open in invidious ↗" on the right.
13365 bar := dom.CreateElement("div")
13366 dom.SetStyle(bar, "display", "flex")
13367 dom.SetStyle(bar, "justifyContent", "flex-end")
13368 dom.SetStyle(bar, "marginTop", "2px")
13369
13370 link := dom.CreateElement("span")
13371 dom.SetTextContent(link, "open in invidious \xe2\x86\x97")
13372 dom.SetStyle(link, "fontSize", "10px")
13373 dom.SetStyle(link, "color", "var(--accent)")
13374 dom.SetStyle(link, "cursor", "pointer")
13375 dom.SetStyle(link, "opacity", "0.7")
13376 capturedID := ytID
13377 dom.AddEventListener(link, "click", dom.RegisterCallback(func() {
13378 dom.WindowOpen(invidiousURL | "/watch?v=" | capturedID)
13379 }))
13380 dom.AppendChild(bar, link)
13381 dom.AppendChild(wrapper, bar)
13382
13383 }
13384
13385 // attachCampionLinks wires click handlers on [data-campion-url] spans.
13386 // Opens Campion in a new tab (window.open) to avoid COEP iframe conflicts.
13387 func attachCampionLinks(root dom.Element) {
13388 el := dom.QuerySelectorFrom(root, "[data-campion-url]")
13389 for el != 0 {
13390 bcURL := dom.GetAttribute(el, "data-campion-url")
13391 dom.RemoveAttribute(el, "data-campion-url")
13392 if bcURL != "" {
13393 capturedURL := bcURL
13394 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
13395 if campionURL != "" {
13396 dom.WindowOpen(campionURL | "?url=" | dom.EncodeURIComponent(capturedURL))
13397 } else {
13398 dom.WindowOpen(capturedURL)
13399 }
13400 }))
13401 }
13402 el = dom.QuerySelectorFrom(root, "[data-campion-url]")
13403 }
13404 }
13405
13406 // zoomStr converts a zoom level in 1/4x steps to a CSS scale() string.
13407 // Level 4 = 1.0x. Level 1 = 0.25x. Level 20 = 5.0x.
13408 func zoomStr(level int32) (s string) {
13409 whole := level / 4
13410 frac := (level % 4) * 25
13411 if frac == 0 {
13412 return itoa(whole)
13413 }
13414 fs := itoa(frac)
13415 for len(fs) > 1 && fs[len(fs)-1] == '0' {
13416 fs = fs[:len(fs)-1]
13417 }
13418 return itoa(whole) | "." | fs
13419 }
13420
13421 func zoomCtrlBtn(label string) (e dom.Element) {
13422 btn := dom.CreateElement("span")
13423 dom.SetTextContent(btn, label)
13424 dom.SetStyle(btn, "cursor", "pointer")
13425 dom.SetStyle(btn, "fontSize", "28px")
13426 dom.SetStyle(btn, "fontWeight", "bold")
13427 dom.SetStyle(btn, "color", "#fff")
13428 dom.SetStyle(btn, "userSelect", "none")
13429 dom.SetStyle(btn, "padding", "0 6px")
13430 dom.SetStyle(btn, "lineHeight", "1")
13431 return btn
13432 }
13433
13434 // showImageLightbox opens a full-screen modal for the given remote image URL.
13435 // Top-right: zoom [+] [-] [×close] bar. Bottom-left: clipboard button.
13436 // All controls are position:absolute inside the position:fixed scrim.
13437 func showImageLightbox(url string) {
13438 zoomLevel := 4 // 1.0x
13439
13440 scrim := dom.CreateElement("div")
13441 dom.SetStyle(scrim, "position", "fixed")
13442 dom.SetStyle(scrim, "inset", "0")
13443 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.88)")
13444 dom.SetStyle(scrim, "zIndex", "2000")
13445 dom.SetStyle(scrim, "display", "flex")
13446 dom.SetStyle(scrim, "alignItems", "center")
13447 dom.SetStyle(scrim, "justifyContent", "center")
13448 dom.SetStyle(scrim, "overflow", "hidden")
13449
13450 img := dom.CreateElement("img")
13451 dom.SetAttribute(img, "referrerpolicy", "no-referrer")
13452 dom.SetStyle(img, "maxWidth", "90vw")
13453 dom.SetStyle(img, "maxHeight", "90vh")
13454 dom.SetStyle(img, "objectFit", "contain")
13455 dom.SetStyle(img, "display", "block")
13456 dom.SetStyle(img, "transformOrigin", "center center")
13457 setMediaSrc(img, url)
13458
13459 // Click the image to copy it as a PNG blob to the system clipboard.
13460 capturedImg2 := img
13461 dom.AddEventListener(img, "click", dom.RegisterCallback(func() {
13462 dom.CopyImageToClipboard(capturedImg2, func(ok bool) {
13463 if ok {
13464 overlay := dom.CreateElement("div")
13465 dom.SetTextContent(overlay, "image copied")
13466 dom.SetStyle(overlay, "position", "absolute")
13467 dom.SetStyle(overlay, "top", "50%")
13468 dom.SetStyle(overlay, "left", "50%")
13469 dom.SetStyle(overlay, "transform", "translate(-50%,-50%)")
13470 dom.SetStyle(overlay, "background", "rgba(0,0,0,0.75)")
13471 dom.SetStyle(overlay, "color", "#fff")
13472 dom.SetStyle(overlay, "padding", "12px 24px")
13473 dom.SetStyle(overlay, "borderRadius", "8px")
13474 dom.SetStyle(overlay, "fontSize", "18px")
13475 dom.SetStyle(overlay, "pointerEvents", "none")
13476 dom.AppendChild(scrim, overlay)
13477 capturedOverlay := overlay
13478 dom.SetTimeout(func() {
13479 dom.RemoveChild(scrim, capturedOverlay)
13480 }, 1200)
13481 }
13482 })
13483 }))
13484 dom.SetStyle(img, "cursor", "copy")
13485 dom.AppendChild(scrim, img)
13486
13487 // Clipboard button - absolute bottom-left, copies the remote URL.
13488 clipBtn := dom.CreateElement("div")
13489 dom.SetTextContent(clipBtn, "\xf0\x9f\x93\x8b") // 📋
13490 dom.SetStyle(clipBtn, "position", "absolute")
13491 dom.SetStyle(clipBtn, "bottom", "20px")
13492 dom.SetStyle(clipBtn, "left", "20px")
13493 dom.SetStyle(clipBtn, "cursor", "pointer")
13494 dom.SetStyle(clipBtn, "fontSize", "32px")
13495 dom.SetStyle(clipBtn, "background", "rgba(0,0,0,0.6)")
13496 dom.SetStyle(clipBtn, "borderRadius", "10px")
13497 dom.SetStyle(clipBtn, "padding", "8px 12px")
13498 dom.SetStyle(clipBtn, "lineHeight", "1")
13499 dom.SetStyle(clipBtn, "userSelect", "none")
13500 capturedURL := url
13501 capturedClip := clipBtn
13502 dom.AddEventListener(clipBtn, "click", dom.RegisterCallback(func() {
13503 dom.WritePrimarySelection(capturedURL)
13504 dom.WriteClipboard(capturedURL, func(ok bool) {
13505 if ok {
13506 dom.SetTextContent(capturedClip, "\xe2\x9c\x93") // ✓
13507 dom.SetTimeout(func() {
13508 dom.SetTextContent(capturedClip, "\xf0\x9f\x93\x8b")
13509 }, 1500)
13510 }
13511 })
13512 }))
13513 dom.AppendChild(scrim, clipBtn)
13514
13515 // Zoom + close bar - absolute top-right. × closes the lightbox.
13516 zoomBar := dom.CreateElement("div")
13517 dom.SetStyle(zoomBar, "position", "absolute")
13518 dom.SetStyle(zoomBar, "top", "12px")
13519 dom.SetStyle(zoomBar, "right", "16px")
13520 dom.SetStyle(zoomBar, "display", "flex")
13521 dom.SetStyle(zoomBar, "gap", "4px")
13522 dom.SetStyle(zoomBar, "alignItems", "center")
13523 dom.SetStyle(zoomBar, "background", "rgba(0,0,0,0.6)")
13524 dom.SetStyle(zoomBar, "borderRadius", "10px")
13525 dom.SetStyle(zoomBar, "padding", "6px 12px")
13526
13527 capturedImg := img
13528 applyZoom := func() {
13529 dom.SetStyle(capturedImg, "transform", "scale(" | zoomStr(zoomLevel) | ")")
13530 }
13531
13532 plusBtn := zoomCtrlBtn("+")
13533 dom.AddEventListener(plusBtn, "click", dom.RegisterCallback(func() {
13534 if zoomLevel < 20 {
13535 zoomLevel++
13536 }
13537 applyZoom()
13538 }))
13539 dom.AppendChild(zoomBar, plusBtn)
13540
13541 minusBtn := zoomCtrlBtn("-")
13542 dom.AddEventListener(minusBtn, "click", dom.RegisterCallback(func() {
13543 if zoomLevel > 1 {
13544 zoomLevel--
13545 }
13546 applyZoom()
13547 }))
13548 dom.AppendChild(zoomBar, minusBtn)
13549
13550 // × closes (replaces separate close button).
13551 closeBtn := zoomCtrlBtn("\xc3\x97") // ×
13552 closeFn := dom.RegisterCallback(func() {
13553 dom.RemoveChild(dom.Body(), scrim)
13554 })
13555 dom.AddEventListener(closeBtn, "click", closeFn)
13556 dom.AppendChild(zoomBar, closeBtn)
13557
13558 dom.AppendChild(scrim, zoomBar)
13559 dom.AddSelfEventListener(scrim, "click", closeFn)
13560 dom.AppendChild(dom.Body(), scrim)
13561 }
13562
13563 // noteClipText formats a single event as a markdown section for clipboard.
13564 // Format: ### name (npub) - timestamp\n\ncontent\n\n---\n\n
13565 func noteClipText(ev *nostr.Event) (s string) {
13566 name := authorNames[ev.PubKey]
13567 npub := helpers.EncodeNpub(helpers.HexDecode(ev.PubKey))
13568 if name == "" {
13569 if len(npub) > 20 {
13570 name = npub[:12] | "..." | npub[len(npub)-4:]
13571 } else {
13572 name = npub
13573 }
13574 }
13575 ts := formatTime(ev.CreatedAt)
13576 return "### " | name | " (" | npub | ") - " | ts | "\n\n" | ev.Content | "\n\n---\n\n"
13577 }
13578
13579 // collectBranch builds the children map from threadEvents and collects the
13580 // event at startID plus all its descendants (DFS, chronological). Used by
13581 // per-note copy buttons in thread view.
13582 func collectBranch(startID string) (ss []*nostr.Event) {
13583 ch := map[string][]string{}
13584 for id, ev := range threadEvents {
13585 parentID := getReplyID(ev)
13586 if parentID == "" || parentID == id {
13587 continue
13588 }
13589 if threadEvents[parentID] == nil && parentID != threadRootID {
13590 if referencesEvent(ev, threadRootID) {
13591 parentID = threadRootID
13592 }
13593 }
13594 ch[parentID] = append(ch[parentID], id)
13595 }
13596 for pid := range ch {
13597 sortByCreatedAt(ch[pid])
13598 }
13599 var result []*nostr.Event
13600 var dfs func(id string)
13601 dfs = func(id string) {
13602 ev, ok := threadEvents[id]
13603 if !ok {
13604 return
13605 }
13606 result = append(result, ev)
13607 for _, childID := range ch[id] {
13608 dfs(childID)
13609 }
13610 }
13611 dfs(startID)
13612 return result
13613 }
13614
13615 // noteCopyBtn returns a 📋 span styled like the other header micro-buttons.
13616 func noteCopyBtn() (e dom.Element) {
13617 btn := dom.CreateElement("span")
13618 dom.SetTextContent(btn, "\xf0\x9f\x93\x8b") // 📋
13619 dom.SetStyle(btn, "fontSize", "10px")
13620 dom.SetStyle(btn, "cursor", "pointer")
13621 dom.SetStyle(btn, "opacity", "0.6")
13622 dom.SetStyle(btn, "marginRight", "4px")
13623 dom.SetStyle(btn, "userSelect", "none")
13624 capturedBtn := btn
13625 dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
13626 dom.SetStyle(capturedBtn, "opacity", "1")
13627 }))
13628 dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
13629 dom.SetStyle(capturedBtn, "opacity", "0.6")
13630 }))
13631 return btn
13632 }
13633
13634 // noteCopyFeedback briefly shows ✓ on the copy button after a successful write.
13635 func noteCopyFeedback(btn dom.Element) {
13636 dom.SetTextContent(btn, "\xe2\x9c\x93") // ✓
13637 dom.SetStyle(btn, "opacity", "1")
13638 capturedBtn := btn
13639 dom.SetTimeout(func() {
13640 dom.SetTextContent(capturedBtn, "\xf0\x9f\x93\x8b")
13641 dom.SetStyle(capturedBtn, "opacity", "0.6")
13642 }, 1200)
13643 }
13644
13645 // prettyNoteJSON returns the event serialised as indented JSON with tabs.
13646 func prettyNoteJSON(ev *nostr.Event) (s string) {
13647 s = "{\n"
13648 s = s | "\t\"id\": \"" | ev.ID | "\",\n"
13649 s = s | "\t\"pubkey\": \"" | ev.PubKey | "\",\n"
13650 s = s | "\t\"created_at\": " | i64toa(ev.CreatedAt) | ",\n"
13651 s = s | "\t\"kind\": " | itoa(int32(ev.Kind)) | ",\n"
13652 s = s | "\t\"tags\": [\n"
13653 for i, tag := range ev.Tags {
13654 s = s | "\t\t["
13655 for j, v := range tag {
13656 if j > 0 {
13657 s = s | ", "
13658 }
13659 s = s | "\"" | jsonEsc(v) | "\""
13660 }
13661 s = s | "]"
13662 if i < len(ev.Tags)-1 {
13663 s = s | ","
13664 }
13665 s = s | "\n"
13666 }
13667 s = s | "\t],\n"
13668 s = s | "\t\"content\": \"" | jsonEsc(ev.Content) | "\",\n"
13669 s = s | "\t\"sig\": \"" | ev.Sig | "\"\n"
13670 s = s | "}"
13671 return s
13672 }
13673
13674 // setMediaSrc sets elem's src to the /proxy/... same-origin path for the URL.
13675 // The browser's HTTP cache (Cache-Control: public, max-age=86400 from the
13676 // relay) serves cached images synchronously without any worker round-trip.
13677 func setMediaSrc(elem dom.Element, url string) {
13678 if len(url) == 0 {
13679 return
13680 }
13681 dom.SetAttribute(elem, "src", mediaProxyPath(url))
13682 }
13683
13684 // mediaProxyPath converts an absolute URL to a same-origin /proxy/ path.
13685 // Same-origin and data: URLs are returned unchanged.
13686 func mediaProxyPath(url string) (s string) {
13687 if len(url) == 0 || url[0] == '/' {
13688 return url
13689 }
13690 if len(url) >= 5 && url[:5] == "data:" {
13691 return url
13692 }
13693 if len(url) >= 8 && url[:8] == "https://" {
13694 return "/proxy/" | url[8:]
13695 }
13696 if len(url) >= 7 && url[:7] == "http://" {
13697 return "/proxy/" | url[7:]
13698 }
13699 return url
13700 }
13701
13702 func isVideoURL(url string) (ok bool) {
13703 u := toLower(url)
13704 return hasSuffix(u, ".mp4") || hasSuffix(u, ".webm") || hasSuffix(u, ".mov") ||
13705 hasSuffix(u, ".ogg") || hasSuffix(u, ".ogv")
13706 }
13707
13708 // youTubeVideoID extracts the YouTube video ID from a URL.
13709 // Returns "" if the URL is not a YouTube link.
13710 // Handles youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID, youtube.com/embed/ID.
13711 func youTubeVideoID(url string) (s string) {
13712 u := toLower(url)
13713 // youtu.be/ID
13714 if hasPrefix(u, "https://youtu.be/") || hasPrefix(u, "http://youtu.be/") {
13715 rest := url[strIndex(url, "youtu.be/")+9:]
13716 return youTubeExtractID(rest)
13717 }
13718 // youtube.com variants
13719 if strIndex(u, "youtube.com/") < 0 {
13720 return ""
13721 }
13722 // /shorts/ID
13723 si := strIndex(u, "/shorts/")
13724 if si >= 0 {
13725 rest := url[si+8:]
13726 return youTubeExtractID(rest)
13727 }
13728 // /embed/ID
13729 ei := strIndex(u, "/embed/")
13730 if ei >= 0 {
13731 rest := url[ei+7:]
13732 return youTubeExtractID(rest)
13733 }
13734 // ?v=ID or &v=ID
13735 vi := strIndex(u, "v=")
13736 if vi >= 0 {
13737 rest := url[vi+2:]
13738 return youTubeExtractID(rest)
13739 }
13740 return ""
13741 }
13742
13743 // youTubeExtractID returns the video ID: the segment up to the first '&', '?', '#', '/' or end.
13744 func youTubeExtractID(s string) (sv string) {
13745 end := len(s)
13746 for i := 0; i < len(s); i++ {
13747 c := s[i]
13748 if c == '&' || c == '?' || c == '#' || c == '/' {
13749 end = i
13750 break
13751 }
13752 }
13753 id := s[:end]
13754 if len(id) < 5 || len(id) > 20 {
13755 return ""
13756 }
13757 return id
13758 }
13759
13760 func escapeHTMLAttr(s string) (sv string) {
13761 out := ""
13762 start := 0
13763 for i := 0; i < len(s); i++ {
13764 var esc string
13765 switch s[i] {
13766 case '"':
13767 esc = """
13768 case '\'':
13769 esc = "'"
13770 case '&':
13771 esc = "&"
13772 case '<':
13773 esc = "<"
13774 case '>':
13775 esc = ">"
13776 default:
13777 continue
13778 }
13779 out = out | s[start:i] | esc
13780 start = i + 1
13781 }
13782 return out | s[start:]
13783 }
13784
13785 func escapeHTML(s string) (sv string) {
13786 out := ""
13787 start := 0
13788 for i := 0; i < len(s); i++ {
13789 var esc string
13790 switch s[i] {
13791 case '&':
13792 esc = "&"
13793 case '<':
13794 esc = "<"
13795 case '>':
13796 esc = ">"
13797 default:
13798 continue
13799 }
13800 out = out | s[start:i] | esc
13801 start = i + 1
13802 }
13803 return out | s[start:]
13804 }
13805
13806 func hasSuffix(s, suffix string) (ok bool) {
13807 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
13808 }
13809
13810
13811 func updateInlineProfileLinks(pk string) {
13812 name := authorNames[pk]
13813 if name == "" {
13814 return
13815 }
13816 // Find and update all <a data-pk="..."> links for this pubkey.
13817 // Loop with QuerySelector since there's no QuerySelectorAll.
13818 for {
13819 el := dom.QuerySelector("a[data-pk=\"" | pk | "\"]")
13820 if el == 0 {
13821 break
13822 }
13823 dom.SetTextContent(el, name)
13824 // Mark as done so we don't match it again.
13825 dom.SetAttribute(el, "data-pk", "")
13826 }
13827 }
13828
13829 func embedEOSECleanup(subID string) {
13830 ids, ok := embedSubIDs[subID]
13831 if !ok {
13832 return
13833 }
13834 delete(embedSubIDs, subID)
13835 for _, hexID := range ids {
13836 if _, still := embedCallbacks[hexID]; !still {
13837 continue
13838 }
13839 // Check if any other sub is still pending for this ID.
13840 otherPending := false
13841 for _, otherIDs := range embedSubIDs {
13842 for _, oid := range otherIDs {
13843 if oid == hexID {
13844 otherPending = true
13845 break
13846 }
13847 }
13848 if otherPending {
13849 break
13850 }
13851 }
13852 if otherPending {
13853 continue
13854 }
13855 // No subs left - mark as not found.
13856 delete(embedRequested, hexID)
13857 elemIDs := embedCallbacks[hexID]
13858 delete(embedCallbacks, hexID)
13859 for _, elemID := range elemIDs {
13860 el := dom.GetElementById(elemID)
13861 if el == 0 {
13862 continue
13863 }
13864 dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
13865 dom.SetStyle(el, "opacity", "0.5")
13866 }
13867 }
13868 }
13869
13870 // fillCoordEmbed matches an arriving event to its coord placeholders and fills them.
13871 func fillCoordEmbed(ev *nostr.Event) {
13872 // Build the coord key from the event's kind, pubkey, and d-tag.
13873 d := ""
13874 for _, tag := range ev.Tags {
13875 if len(tag) >= 2 && tag[0] == "d" {
13876 d = tag[1]
13877 break
13878 }
13879 }
13880 coord := helpers.NaddrCoordKey(uint32(ev.Kind), ev.PubKey, d)
13881 elemIDs, ok := embedCoordCBs[coord]
13882 if !ok {
13883 // Also check old generation.
13884 elemIDs, ok = embedCoordCBsOld[coord]
13885 if !ok {
13886 return
13887 }
13888 delete(embedCoordCBsOld, coord)
13889 } else {
13890 delete(embedCoordCBs, coord)
13891 }
13892
13893 name := ev.PubKey[:8] | "..."
13894 if n, ok2 := authorNames[ev.PubKey]; ok2 && n != "" {
13895 name = n
13896 }
13897 // For kind 30023, use the title tag; otherwise truncate content.
13898 title := ""
13899 if ev.Kind == uint32(30023) {
13900 for _, tag := range ev.Tags {
13901 if len(tag) >= 2 && tag[0] == "title" {
13902 title = tag[1]
13903 break
13904 }
13905 }
13906 }
13907 text := title
13908 if text == "" {
13909 text = ev.Content
13910 nl := -1
13911 for i := 0; i < len(text); i++ {
13912 if text[i] == '\n' {
13913 nl = i
13914 break
13915 }
13916 }
13917 if nl >= 0 {
13918 text = text[:nl]
13919 }
13920 }
13921
13922 headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
13923 if pic, ok2 := authorPics[ev.PubKey]; ok2 && pic != "" {
13924 headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
13925 "style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
13926 }
13927 headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
13928
13929 capturedEv := ev
13930 for _, elemID := range elemIDs {
13931 el := dom.GetElementById(elemID)
13932 if el == 0 {
13933 continue
13934 }
13935 clearChildren(el)
13936 dom.SetStyle(el, "opacity", "1")
13937 dom.SetStyle(el, "cursor", "pointer")
13938
13939 hdr := dom.CreateElement("div")
13940 dom.SetInnerHTML(hdr, headerHTML)
13941 dom.AppendChild(el, hdr)
13942
13943 body := dom.CreateElement("div")
13944 dom.SetStyle(body, "fontSize", "13px")
13945 dom.SetStyle(body, "lineHeight", "1.4")
13946 dom.SetInnerHTML(body, renderEmbedText(text))
13947 dom.AppendChild(el, body)
13948
13949 thisEl := el
13950 dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
13951 showNoteThread(capturedEv.ID, capturedEv.ID)
13952 }))
13953 }
13954 }
13955
13956 // embedCoordEOSECleanup marks unfound coord embeds as not found.
13957 func embedCoordEOSECleanup(subID string) {
13958 coord, ok := embedCoordSubIDs[subID]
13959 if !ok {
13960 return
13961 }
13962 delete(embedCoordSubIDs, subID)
13963 // Check if any other coord sub is still pending for this coord.
13964 for _, other := range embedCoordSubIDs {
13965 if other == coord {
13966 return
13967 }
13968 }
13969 elemIDs, ok := embedCoordCBs[coord]
13970 if !ok {
13971 return
13972 }
13973 delete(embedCoordCBs, coord)
13974 for _, elemID := range elemIDs {
13975 el := dom.GetElementById(elemID)
13976 if el == 0 {
13977 continue
13978 }
13979 dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
13980 dom.SetStyle(el, "opacity", "0.5")
13981 }
13982 }
13983
13984 func makeCoordEmbedPlaceholder(coord string, kind uint32, pubkey, d string) (s string) {
13985 embedCounter++
13986 elemID := "emb-" | itoa(embedCounter)
13987 embedCoordCBs[coord] = append(embedCoordCBs[coord], elemID)
13988 label := "Loading..."
13989 if kind == uint32(30023) {
13990 label = "Loading article..."
13991 }
13992 return "<div id=\"" | elemID | "\" data-coord=\"" | escapeHTMLAttr(coord) | "\" " |
13993 "style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
13994 "background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
13995 "<em>" | label | "</em></div>"
13996 }
13997
13998 func makeEmbedPlaceholder(hexID string) (s string) {
13999 embedCounter++
14000 elemID := "emb-" | itoa(embedCounter)
14001 embedCallbacks[hexID] = append(embedCallbacks[hexID], elemID)
14002 return "<div id=\"" | elemID | "\" data-eid=\"" | hexID | "\" " |
14003 "style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
14004 "background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
14005 "<em>Loading note...</em></div>"
14006 }
14007
14008 func resolveEmbeds() {
14009 if len(embedCallbacks) == 0 && len(embedCoordCBs) == 0 {
14010 return
14011 }
14012 if len(embedCallbacks) == 0 {
14013 for coord := range embedCoordCBs {
14014 resolveCoordEmbed(coord)
14015 }
14016 return
14017 }
14018 // Fill any embeds already in cache before requesting.
14019 for hexID := range embedCallbacks {
14020 if ev, ok := lookupEvent(hexID); ok && ev != nil {
14021 fillEmbed(ev)
14022 }
14023 }
14024 if len(embedCallbacks) == 0 {
14025 return
14026 }
14027 // Only request IDs we haven't already sent to PROXY.
14028 // If embedRequested is true but the event isn't in cache, clear the
14029 // flag so we re-fetch - the event may have been evicted by rotation.
14030 var ids []string
14031 for hexID := range embedCallbacks {
14032 if embedRequested[hexID] {
14033 if _, ok := lookupEvent(hexID); ok {
14034 continue
14035 }
14036 delete(embedRequested, hexID)
14037 }
14038 ids = append(ids, hexID)
14039 embedRequested[hexID] = true
14040 }
14041 if len(ids) == 0 {
14042 return
14043 }
14044 filter := "{\"ids\":["
14045 for i, id := range ids {
14046 if i > 0 {
14047 filter = filter | ","
14048 }
14049 filter = filter | jstr(id)
14050 }
14051 filter = filter | "]}"
14052
14053 // REQ checks the local IDB cache.
14054 embedCounter++
14055 reqID := "emb-r-" | itoa(embedCounter)
14056 embedSubIDs[reqID] = ids
14057 routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
14058
14059 // PROXY fetches from relays - combine user relays + nevent relay hints.
14060 seen := map[string]bool{}
14061 var urls []string
14062 for _, u := range readRelayURLs() {
14063 if !seen[u] {
14064 seen[u] = true
14065 urls = append(urls, u)
14066 }
14067 }
14068 for _, id := range ids {
14069 hints := embedRelayHints[id]
14070 if len(hints) == 0 {
14071 hints = embedRelayHintsOld[id]
14072 delete(embedRelayHintsOld, id)
14073 } else {
14074 delete(embedRelayHints, id)
14075 }
14076 for _, u := range hints {
14077 if !seen[u] {
14078 seen[u] = true
14079 urls = append(urls, u)
14080 }
14081 }
14082 }
14083 if len(urls) > 0 {
14084 proxyID := "emb-p-" | itoa(embedCounter)
14085 embedSubIDs[proxyID] = ids
14086 routeMsg(buildProxyMsg(proxyID, filter, urls))
14087 }
14088
14089 // Coord embeds: one PROXY sub per pending coord.
14090 for coord := range embedCoordCBs {
14091 resolveCoordEmbed(coord)
14092 }
14093 }
14094
14095 // resolveCoordEmbed fires a PROXY fetch for one naddr coordinate.
14096 func resolveCoordEmbed(coord string) {
14097 // Parse "kind:pubkey:d" - split on first two colons.
14098 ci := -1
14099 for i := 0; i < len(coord); i++ {
14100 if coord[i] == ':' {
14101 ci = i
14102 break
14103 }
14104 }
14105 if ci < 0 {
14106 return
14107 }
14108 kindStr := coord[:ci]
14109 rest := coord[ci+1:]
14110 pi := -1
14111 for i := 0; i < len(rest); i++ {
14112 if rest[i] == ':' {
14113 pi = i
14114 break
14115 }
14116 }
14117 if pi < 0 {
14118 return
14119 }
14120 pubkey := rest[:pi]
14121 d := rest[pi+1:]
14122
14123 filter := "{\"kinds\":[" | kindStr | "],\"authors\":[" | jstr(pubkey) | "],\"#d\":[" | jstr(d) | "]}"
14124
14125 // Relay hints from the naddr TLV.
14126 coordRelays := embedRelayHints[coord]
14127 if len(coordRelays) > 0 {
14128 delete(embedRelayHints, coord)
14129 } else {
14130 coordRelays = embedRelayHintsOld[coord]
14131 delete(embedRelayHintsOld, coord)
14132 }
14133 var urls []string
14134 seen := map[string]bool{}
14135 for _, u := range readRelayURLs() {
14136 if !seen[u] {
14137 seen[u] = true
14138 urls = append(urls, u)
14139 }
14140 }
14141 for _, u := range coordRelays {
14142 if !seen[u] {
14143 seen[u] = true
14144 urls = append(urls, u)
14145 }
14146 }
14147
14148 embedCounter++
14149 if len(urls) > 0 {
14150 proxyID := "emb-c-" | itoa(embedCounter)
14151 embedCoordSubIDs[proxyID] = coord
14152 routeMsg(buildProxyMsg(proxyID, filter, urls))
14153 }
14154 reqID := "emb-cr-" | itoa(embedCounter)
14155 embedCoordSubIDs[reqID] = coord
14156 routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
14157 }
14158
14159 func renderEmbedText(s string) (sv string) {
14160 s = markdown.Render(s)
14161 s = convertMediaLinks(s)
14162 return s
14163 }
14164
14165 func fillEmbed(ev *nostr.Event) {
14166 elemIDs, ok := embedCallbacks[ev.ID]
14167 if !ok {
14168 return
14169 }
14170 delete(embedCallbacks, ev.ID)
14171 embedCacheCount++
14172 if embedCacheCount > rotateEmbedMax {
14173 rotateEmbedCaches()
14174 }
14175
14176 name := ev.PubKey[:8] | "..."
14177 if n, ok := authorNames[ev.PubKey]; ok && n != "" {
14178 name = n
14179 }
14180 headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
14181 if pic, ok := authorPics[ev.PubKey]; ok && pic != "" {
14182 headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
14183 "style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
14184 }
14185 headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
14186
14187 firstNL := -1
14188 for i := 0; i < len(ev.Content); i++ {
14189 if ev.Content[i] == '\n' {
14190 firstNL = i
14191 break
14192 }
14193 }
14194 truncated := firstNL >= 0 && firstNL < len(ev.Content)-1
14195 text := ev.Content
14196 if truncated {
14197 text = text[:firstNL]
14198 }
14199
14200 embedEvID := ev.ID
14201 embedRootID := getRootID(ev)
14202 if embedRootID == "" {
14203 embedRootID = embedEvID
14204 }
14205
14206 for _, elemID := range elemIDs {
14207 el := dom.GetElementById(elemID)
14208 if el == 0 {
14209 continue
14210 }
14211 clearChildren(el)
14212 dom.SetStyle(el, "opacity", "1")
14213 dom.SetStyle(el, "cursor", "pointer")
14214
14215 hdr := dom.CreateElement("div")
14216 dom.SetInnerHTML(hdr, headerHTML)
14217 dom.AppendChild(el, hdr)
14218
14219 body := dom.CreateElement("div")
14220 dom.SetStyle(body, "fontSize", "13px")
14221 dom.SetStyle(body, "lineHeight", "1.4")
14222 dom.SetInnerHTML(body, renderEmbedText(text))
14223 dom.AppendChild(el, body)
14224
14225 thisEl := el
14226 dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
14227 showNoteThread(embedRootID, embedEvID)
14228 }))
14229 }
14230
14231 if _, ok := authorNames[ev.PubKey]; !ok {
14232 if !lookupFetchedK0(ev.PubKey) {
14233 profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
14234 }
14235 }
14236 }
14237
14238 // jsonGetNum extracts a numeric value for a given key from a JSON object.
14239 func jsonGetNum(s, key string) (n int64) {
14240 needle := "\"" | key | "\":"
14241 idx := strIndex(s, needle)
14242 if idx < 0 {
14243 return 0
14244 }
14245 idx += len(needle)
14246 // Skip whitespace.
14247 for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') {
14248 idx++
14249 }
14250 if idx >= len(s) {
14251 return 0
14252 }
14253 for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' {
14254 n = n*10 + int64(s[idx]-'0')
14255 idx++
14256 }
14257 return n
14258 }
14259
14260 // jsonEsc escapes a string for embedding in a JSON value.
14261 // defaultServiceURL derives a service subdomain URL from the current page origin.
14262 // wss://smesh.lol + "invidious" → https://invidious.smesh.lol
14263 func defaultServiceURL(sub string) (s string) {
14264 local := localRelayURL()
14265 var host string
14266 if len(local) >= 6 && local[:6] == "wss://" {
14267 host = local[6:]
14268 } else if len(local) >= 5 && local[:5] == "ws://" {
14269 host = local[5:]
14270 } else {
14271 return ""
14272 }
14273 // Only derive for non-localhost origins.
14274 if host == "localhost" || len(host) > 9 && host[:9] == "127.0.0.1" {
14275 return ""
14276 }
14277 return "https://" | sub | "." | host
14278 }
14279
14280 // defaultBlossomURL derives the blossom server URL from the current page origin.
14281 // wss://smesh.lol → https://smesh.lol/blossom
14282 // --- Search ---
14283
14284 func activateSearchBar(btn dom.Element) {
14285 if !searchBarActive {
14286 searchReturnPage = activePage // remember where to return on manual close
14287 }
14288 searchBarActive = true
14289 dom.SetStyle(btn, "background", "var(--accent)")
14290 dom.SetStyle(btn, "color", "#fff")
14291 dom.SetStyle(btn, "borderRadius", "4px")
14292 dom.SetStyle(feedSelectEl, "display", "none")
14293 dom.SetStyle(pageTitleEl, "display", "none")
14294 dom.SetStyle(notifBarGroup, "display", "none")
14295 dom.SetStyle(topBarRight, "display", "none")
14296 dom.SetStyle(searchInputEl, "display", "block")
14297 dom.SetStyle(searchInputEl, "flex", "1")
14298 dom.SetStyle(searchSubmitBtn, "display", "block")
14299 dom.Focus(searchInputEl)
14300 }
14301
14302 // deactivateSearchBar restores the bar appearance only.
14303 // Callers are responsible for any page navigation.
14304 func deactivateSearchBar(btn dom.Element) {
14305 searchBarActive = false
14306 dom.SetStyle(btn, "background", "transparent")
14307 dom.SetStyle(btn, "color", "var(--muted)")
14308 dom.SetStyle(btn, "borderRadius", "4px")
14309 dom.SetStyle(topBarRight, "display", "flex")
14310 dom.SetStyle(searchInputEl, "display", "none")
14311 dom.SetStyle(searchInputEl, "flex", "")
14312 dom.SetStyle(searchSubmitBtn, "display", "none")
14313 }
14314
14315 // closeSearch is called when the user explicitly closes search (clicks 🔍 again).
14316 // Deactivates the bar and returns to the previous page.
14317 func closeSearch(btn dom.Element) {
14318 deactivateSearchBar(btn)
14319 ret := searchReturnPage
14320 if ret == "" || ret == "search" {
14321 ret = "feed"
14322 }
14323 searchReturnPage = ""
14324 activePage = "" // force switchPage to run
14325 switchPage(ret)
14326 }
14327
14328 // executeSearch submits a search query: #tag searches by t-tag, other text
14329 // runs NIP-50 full-text search. Switches to the search page and subscribes.
14330 func executeSearch(q string) {
14331 q = trimStr(q)
14332 if q == "" {
14333 return
14334 }
14335 searchCurrentQ = q
14336 searchResults = []*nostr.Event{:0}
14337 searchSeen = map[string]bool{}
14338
14339 switchPage("search")
14340 // URL reflects the current query.
14341 dom.ReplaceState("/search?q=" | dom.EncodeURIComponent(q))
14342
14343 // Build filter and choose target relays.
14344 var filter string
14345 var searchRelayList []string
14346 if len(q) > 1 && q[0] == '#' {
14347 // Tag search works on all relays; cap at 5 to limit event burst.
14348 tag := q[1:]
14349 filter = "{\"kinds\":[1,1111],\"#t\":[" | jstr(tag) | "],\"limit\":10}"
14350 searchRelayList = relayURLs
14351 if len(searchRelayList) > 5 {
14352 searchRelayList = searchRelayList[:5]
14353 }
14354 } else {
14355 // Full-text search: only NIP-50 relays, capped at 3 to bound
14356 // the event burst through the relay-proxy WASM.
14357 filter = "{\"kinds\":[1,1111],\"search\":" | jstr(q) | ",\"limit\":10}"
14358 searchRelayList = nip50Relays()
14359 if len(searchRelayList) > 3 {
14360 searchRelayList = searchRelayList[:3]
14361 }
14362 }
14363
14364 // Build filter bar (sticky, above results) then show loading.
14365 clearChildren(searchContainer)
14366 buildSearchFilterBar()
14367
14368 loading := dom.CreateElement("div")
14369 dom.SetStyle(loading, "color", "var(--muted)")
14370 dom.SetStyle(loading, "fontSize", "14px")
14371 dom.SetStyle(loading, "padding", "16px")
14372 dom.SetStyle(loading, "fontFamily", "'Fira Code', monospace")
14373 if len(searchRelayList) == 0 {
14374 dom.SetTextContent(loading, "no full-text search relays available \xe2\x80\x94 try #hashtag")
14375 dom.AppendChild(searchContainer, loading)
14376 return
14377 }
14378 relayWord := "relays"
14379 if len(searchRelayList) == 1 {
14380 relayWord = "relay"
14381 }
14382 dom.SetTextContent(loading, "searching " | itoa(len(searchRelayList)) | " " | relayWord | "...")
14383 dom.AppendChild(searchContainer, loading)
14384
14385 routeMsg("[\"CLOSE\",\"search\"]")
14386 routeMsg(buildProxyMsg("search", filter, searchRelayList))
14387 }
14388
14389 func buildSearchFilterBar() {
14390 // Rebuild the filter bar (recreate buttons so state labels are fresh).
14391 if searchFilterBarEl != 0 {
14392 dom.RemoveChild(searchPage, searchFilterBarEl)
14393 }
14394 bar := dom.CreateElement("div")
14395 searchFilterBarEl = bar
14396 dom.SetAttribute(bar, "class", "search-filter-bar")
14397 dom.SetStyle(bar, "display", "flex")
14398 dom.SetStyle(bar, "gap", "8px")
14399 dom.SetStyle(bar, "padding", "8px 12px")
14400 dom.SetStyle(bar, "borderBottom", "1px solid var(--border)")
14401 dom.SetStyle(bar, "flexWrap", "wrap")
14402 dom.SetStyle(bar, "alignItems", "center")
14403 dom.SetStyle(bar, "position", "sticky")
14404 dom.SetStyle(bar, "top", "0")
14405 dom.SetStyle(bar, "background", "var(--bg2)")
14406 dom.SetStyle(bar, "zIndex", "10")
14407
14408 followsBtn := searchFilterBtn("follows only", searchFollowsOnly)
14409 dom.AddEventListener(followsBtn, "click", dom.RegisterCallback(func() {
14410 searchFollowsOnly = !searchFollowsOnly
14411 applySearchFilterBtn(followsBtn, searchFollowsOnly)
14412 renderSearchResults()
14413 }))
14414 dom.AppendChild(bar, followsBtn)
14415
14416 sortBtn := searchFilterBtn("newest first", searchSortDesc)
14417 dom.SetAttribute(sortBtn, "data-sort", "1")
14418 dom.AddEventListener(sortBtn, "click", dom.RegisterCallback(func() {
14419 searchSortDesc = !searchSortDesc
14420 if searchSortDesc {
14421 dom.SetTextContent(sortBtn, "newest first")
14422 } else {
14423 dom.SetTextContent(sortBtn, "oldest first")
14424 }
14425 applySearchFilterBtn(sortBtn, true)
14426 renderSearchResults()
14427 }))
14428 dom.AppendChild(bar, sortBtn)
14429
14430 nsfwBtn := searchFilterBtn("nsfw", searchNSFW)
14431 dom.AddEventListener(nsfwBtn, "click", dom.RegisterCallback(func() {
14432 searchNSFW = !searchNSFW
14433 applySearchFilterBtn(nsfwBtn, searchNSFW)
14434 renderSearchResults()
14435 }))
14436 dom.AppendChild(bar, nsfwBtn)
14437
14438 // Insert filter bar BEFORE searchContainer in searchPage.
14439 dom.InsertBefore(searchPage, bar, searchContainer)
14440 }
14441
14442 func searchFilterBtn(label string, active bool) (e dom.Element) {
14443 btn := dom.CreateElement("button")
14444 dom.SetTextContent(btn, label)
14445 dom.SetStyle(btn, "padding", "4px 12px")
14446 dom.SetStyle(btn, "borderRadius", "4px")
14447 dom.SetStyle(btn, "fontSize", "12px")
14448 dom.SetStyle(btn, "cursor", "pointer")
14449 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
14450 applySearchFilterBtn(btn, active)
14451 return btn
14452 }
14453
14454 func applySearchFilterBtn(btn dom.Element, active bool) {
14455 if active {
14456 dom.SetStyle(btn, "background", "var(--accent)")
14457 dom.SetStyle(btn, "color", "#fff")
14458 dom.SetStyle(btn, "border", "none")
14459 } else {
14460 dom.SetStyle(btn, "background", "transparent")
14461 dom.SetStyle(btn, "color", "var(--muted)")
14462 dom.SetStyle(btn, "border", "1px solid var(--border)")
14463 }
14464 }
14465
14466 func handleSearchEvent(ev *nostr.Event) {
14467 if ev.Kind != 1 && ev.Kind != 1111 {
14468 return
14469 }
14470 if searchSeen[ev.ID] {
14471 return
14472 }
14473 searchSeen[ev.ID] = true
14474 if isMuted(ev.PubKey) {
14475 return
14476 }
14477 if looksLikeJSONSpam(ev.Content) {
14478 return
14479 }
14480 searchResults = append(searchResults, ev)
14481 setEvent(ev.ID, ev)
14482 }
14483
14484 const searchPageSize = 25 // results rendered per page / scroll load
14485
14486 // filteredSearchResults returns searchResults with current filters applied and sorted.
14487 func filteredSearchResults() (ss []*nostr.Event) {
14488 var filtered []*nostr.Event
14489 for _, ev := range searchResults {
14490 if searchFollowsOnly && !isFollowing(ev.PubKey) {
14491 continue
14492 }
14493 if !searchNSFW {
14494 if ev.Tags.GetFirst([]byte("content-warning")) != nil {
14495 continue
14496 }
14497 }
14498 filtered = append(filtered, ev)
14499 }
14500 for i := 0; i < len(filtered); i++ {
14501 for j := i + 1; j < len(filtered); j++ {
14502 var swap bool
14503 if searchSortDesc {
14504 swap = filtered[j].CreatedAt > filtered[i].CreatedAt
14505 } else {
14506 swap = filtered[j].CreatedAt < filtered[i].CreatedAt
14507 }
14508 if swap {
14509 filtered[i], filtered[j] = filtered[j], filtered[i]
14510 }
14511 }
14512 }
14513 return filtered
14514 }
14515
14516 // renderSearchResults rebuilds the result list from scratch, rendering only
14517 // the first searchPageSize results. Remaining results load on scroll.
14518 func renderSearchResults() {
14519 searchRendered = 0
14520 clearChildren(searchContainer)
14521
14522 filtered := filteredSearchResults()
14523 if len(filtered) == 0 {
14524 empty := dom.CreateElement("div")
14525 dom.SetTextContent(empty, "no results")
14526 dom.SetStyle(empty, "color", "var(--muted)")
14527 dom.SetStyle(empty, "fontSize", "14px")
14528 dom.SetStyle(empty, "padding", "16px")
14529 dom.SetStyle(empty, "fontFamily", "'Fira Code', monospace")
14530 dom.AppendChild(searchContainer, empty)
14531 return
14532 }
14533
14534 end := len(filtered)
14535 if end > searchPageSize {
14536 end = searchPageSize
14537 }
14538 for i := 0; i < end; i++ {
14539 dom.AppendChild(searchContainer, renderSearchResult(filtered[i]))
14540 }
14541 searchRendered = end
14542 }
14543
14544 // appendSearchResults renders the next batch of results (called on scroll).
14545 func appendSearchResults() {
14546 filtered := filteredSearchResults()
14547 if searchRendered >= len(filtered) {
14548 return
14549 }
14550 end := searchRendered + searchPageSize
14551 if end > len(filtered) {
14552 end = len(filtered)
14553 }
14554 for i := searchRendered; i < end; i++ {
14555 dom.AppendChild(searchContainer, renderSearchResult(filtered[i]))
14556 }
14557 searchRendered = end
14558 }
14559
14560 func renderSearchResult(ev *nostr.Event) (e dom.Element) {
14561 row := dom.CreateElement("div")
14562 dom.SetStyle(row, "display", "flex")
14563 dom.SetStyle(row, "flexDirection", "column")
14564 dom.SetStyle(row, "padding", "10px 12px")
14565 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
14566 dom.SetStyle(row, "cursor", "pointer")
14567 dom.SetStyle(row, "gap", "4px")
14568
14569 // Header: avatar + name + timestamp.
14570 header := dom.CreateElement("div")
14571 dom.SetStyle(header, "display", "flex")
14572 dom.SetStyle(header, "alignItems", "center")
14573 dom.SetStyle(header, "gap", "8px")
14574
14575 avatar := dom.CreateElement("img")
14576 dom.SetAttribute(avatar, "width", "20")
14577 dom.SetAttribute(avatar, "height", "20")
14578 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
14579 dom.SetStyle(avatar, "borderRadius", "50%")
14580 dom.SetStyle(avatar, "objectFit", "cover")
14581 dom.SetStyle(avatar, "flexShrink", "0")
14582 pk := ev.PubKey
14583 if pic, ok := authorPics[pk]; ok && pic != "" {
14584 setMediaSrc(avatar, pic)
14585 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
14586 } else {
14587 dom.SetStyle(avatar, "display", "none")
14588 }
14589 dom.AppendChild(header, avatar)
14590
14591 name := dom.CreateElement("span")
14592 dom.SetStyle(name, "fontSize", "14px")
14593 dom.SetStyle(name, "fontWeight", "bold")
14594 dom.SetStyle(name, "color", "var(--fg)")
14595 dom.SetStyle(name, "flex", "1")
14596 dom.SetStyle(name, "overflow", "hidden")
14597 dom.SetStyle(name, "textOverflow", "ellipsis")
14598 dom.SetStyle(name, "whiteSpace", "nowrap")
14599 if n, ok := authorNames[pk]; ok && n != "" {
14600 dom.SetTextContent(name, n)
14601 } else {
14602 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
14603 if len(npub) > 20 {
14604 dom.SetTextContent(name, npub[:12] | "..." | npub[len(npub)-4:])
14605 }
14606 }
14607 dom.AppendChild(header, name)
14608
14609 ts := dom.CreateElement("span")
14610 dom.SetTextContent(ts, formatTime(ev.CreatedAt))
14611 dom.SetStyle(ts, "fontSize", "11px")
14612 dom.SetStyle(ts, "color", "var(--muted)")
14613 dom.SetStyle(ts, "flexShrink", "0")
14614 dom.AppendChild(header, ts)
14615 dom.AppendChild(row, header)
14616
14617 // Two-line content preview (plain text, no markdown rendering).
14618 preview := dom.CreateElement("div")
14619 dom.SetStyle(preview, "fontSize", "13px")
14620 dom.SetStyle(preview, "color", "var(--fg)")
14621 dom.SetStyle(preview, "lineHeight", "1.4")
14622 dom.SetStyle(preview, "overflow", "hidden")
14623 dom.SetStyle(preview, "display", "-webkit-box")
14624 dom.SetStyle(preview, "-webkit-line-clamp", "2")
14625 dom.SetStyle(preview, "-webkit-box-orient", "vertical")
14626 dom.SetStyle(preview, "wordBreak", "break-word")
14627 content := ev.Content
14628 if len(content) > 300 {
14629 content = content[:300] | "..."
14630 }
14631 dom.SetTextContent(preview, content)
14632 dom.AppendChild(row, preview)
14633
14634 // Click opens thread.
14635 evID := ev.ID
14636 evRootID := getRootID(ev)
14637 if evRootID == "" {
14638 evRootID = evID
14639 }
14640 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
14641 showNoteThread(evRootID, evID)
14642 }))
14643
14644 if _, cached := authorNames[pk]; !cached {
14645 pendingNotes[pk] = append(pendingNotes[pk], header)
14646 if !lookupFetchedK0(pk) {
14647 profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
14648 }
14649 }
14650
14651 return row
14652 }
14653
14654 // extractHashtags replaces #tag tokens with placeholders so markdown-it
14655 // preserves them. Restored after rendering as clickable search spans.
14656 func extractHashtags(s string, slots *[]string) (sv string) {
14657 out := ""
14658 i := 0
14659 for i < len(s) {
14660 if s[i] != '#' {
14661 out = out | string([]byte{s[i]})
14662 i++
14663 continue
14664 }
14665 // Must be preceded by whitespace/start/newline to be a hashtag.
14666 if i > 0 {
14667 prev := s[i-1]
14668 if prev != ' ' && prev != '\n' && prev != '\t' && prev != '\r' {
14669 out = out | "#"
14670 i++
14671 continue
14672 }
14673 }
14674 // Collect tag characters: letters, digits, underscores.
14675 j := i + 1
14676 for j < len(s) {
14677 c := s[j]
14678 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
14679 j++
14680 } else {
14681 break
14682 }
14683 }
14684 if j == i+1 {
14685 // No tag characters after #.
14686 out = out | "#"
14687 i++
14688 continue
14689 }
14690 tag := s[i+1 : j]
14691 idx := len(*slots)
14692 span := "<span data-search-tag=\"" | escapeHTMLAttr(tag) | "\" style=\"color:var(--accent);cursor:pointer\">#" | tag | "</span>"
14693 *slots = append(*slots, span)
14694 out = out | "\x02HASH" | itoa(idx) | "\x02"
14695 i = j
14696 }
14697 return out
14698 }
14699
14700 // attachHashtagLinks wires click handlers on [data-search-tag] spans.
14701 func attachHashtagLinks(root dom.Element) {
14702 el := dom.QuerySelectorFrom(root, "[data-search-tag]")
14703 for el != 0 {
14704 tag := dom.GetAttribute(el, "data-search-tag")
14705 dom.RemoveAttribute(el, "data-search-tag")
14706 if tag != "" {
14707 capturedTag := tag
14708 dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14709 dom.SetProperty(searchInputEl, "value", "#" | capturedTag)
14710 executeSearch("#" | capturedTag)
14711 }))
14712 }
14713 el = dom.QuerySelectorFrom(root, "[data-search-tag]")
14714 }
14715 }
14716
14717 // trimStr trims leading and trailing whitespace from s.
14718 func trimStr(s string) (sv string) {
14719 start := 0
14720 for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
14721 start++
14722 }
14723 end := len(s)
14724 for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
14725 end--
14726 }
14727 return s[start:end]
14728 }
14729
14730 // nip50Relays returns the subset of configured relay URLs that advertise NIP-50
14731 // (full-text search) in their NIP-11 document.
14732 func nip50Relays() (ss []string) {
14733 var out []string
14734 for i, url := range relayURLs {
14735 if i < len(relayCaps) && hasNIP(relayCaps[i].supportedNIPs, 50) {
14736 out = append(out, url)
14737 }
14738 }
14739 return out
14740 }
14741
14742 // looksLikeJSONSpam returns true if the content is a raw JSON object or array
14743 // larger than 200 bytes. Catches protocol spam posted as kind 1 notes.
14744 func looksLikeJSONSpam(content string) (ok bool) {
14745 i := 0
14746 for i < len(content) {
14747 c := content[i]
14748 if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
14749 i++
14750 } else {
14751 break
14752 }
14753 }
14754 if i >= len(content) {
14755 return false
14756 }
14757 open := content[i]
14758 if open != '{' && open != '[' {
14759 return false
14760 }
14761 // Scan for matching close bracket (up to 2000 chars).
14762 limit := len(content)
14763 if limit > 2000 {
14764 limit = 2000
14765 }
14766 depth := 0
14767 inStr := false
14768 j := i
14769 for j < limit {
14770 c := content[j]
14771 if inStr {
14772 if c == '\\' && j+1 < limit {
14773 j += 2
14774 continue
14775 }
14776 if c == '"' {
14777 inStr = false
14778 }
14779 } else {
14780 if c == '"' {
14781 inStr = true
14782 } else if c == '{' || c == '[' {
14783 depth++
14784 } else if c == '}' || c == ']' {
14785 depth--
14786 if depth == 0 {
14787 return j-i+1 > 200
14788 }
14789 }
14790 }
14791 j++
14792 }
14793 return false
14794 }
14795
14796 func defaultBlossomURL() (s string) {
14797 local := localRelayURL()
14798 var base string
14799 if len(local) >= 6 && local[:6] == "wss://" {
14800 base = "https://" | local[6:]
14801 } else if len(local) >= 5 && local[:5] == "ws://" {
14802 base = "http://" | local[5:]
14803 } else {
14804 return ""
14805 }
14806 return base | "/blossom"
14807 }
14808
14809 // parseJSONStringArray parses a JSON array of strings like ["a","b","c"].
14810 func parseJSONStringArray(s string) (ss []string) {
14811 var out []string
14812 i := 0
14813 for i < len(s) && s[i] != '[' {
14814 i++
14815 }
14816 if i >= len(s) {
14817 return nil
14818 }
14819 i++ // skip '['
14820 for i < len(s) {
14821 for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\n') {
14822 i++
14823 }
14824 if i >= len(s) || s[i] == ']' {
14825 break
14826 }
14827 if s[i] != '"' {
14828 break
14829 }
14830 i++
14831 start := i
14832 for i < len(s) && s[i] != '"' {
14833 if s[i] == '\\' {
14834 i++
14835 }
14836 i++
14837 }
14838 if i <= len(s) {
14839 out = append(out, s[start:i])
14840 }
14841 if i < len(s) {
14842 i++ // skip closing '"'
14843 }
14844 }
14845 return out
14846 }
14847
14848 func jsonEsc(s string) (sv string) {
14849 s = strReplace(s, "\\", "\\\\")
14850 s = strReplace(s, "\"", "\\\"")
14851 s = strReplace(s, "\n", "\\n")
14852 s = strReplace(s, "\r", "\\r")
14853 s = strReplace(s, "\t", "\\t")
14854 return s
14855 }
14856
14857 // strIndex finds substring in string. Returns -1 if not found.
14858 func strIndex(s, sub string) (n int32) {
14859 sl := len(sub)
14860 for i := 0; i <= len(s)-sl; i++ {
14861 if s[i:i+sl] == sub {
14862 return i
14863 }
14864 }
14865 return -1
14866 }
14867
14868 // --- Helpers ---
14869
14870 // normalizeURL strips trailing slashes and lowercases the scheme+host.
14871 func normalizeURL(u string) (s string) {
14872 for len(u) > 0 && u[len(u)-1] == '/' {
14873 u = u[:len(u)-1]
14874 }
14875 // Lowercase scheme and host (before first / after ://).
14876 if len(u) > 6 && u[:6] == "wss://" {
14877 rest := u[6:]
14878 slash := strIndex(rest, "/")
14879 if slash < 0 {
14880 return u[:6] | toLower(rest)
14881 }
14882 return u[:6] | toLower(rest[:slash]) | rest[slash:]
14883 }
14884 if len(u) > 5 && u[:5] == "ws://" {
14885 rest := u[5:]
14886 slash := strIndex(rest, "/")
14887 if slash < 0 {
14888 return u[:5] | toLower(rest)
14889 }
14890 return u[:5] | toLower(rest[:slash]) | rest[slash:]
14891 }
14892 return u
14893 }
14894
14895 func toLower(s string) (sv string) {
14896 b := []byte{:len(s)}
14897 for i := 0; i < len(s); i++ {
14898 c := s[i]
14899 if c >= 'A' && c <= 'Z' {
14900 c += 32
14901 }
14902 b[i] = c
14903 }
14904 return string(b)
14905 }
14906
14907 func showQRModal(npubStr string) {
14908 svg := qrSVG(npubStr, 5)
14909 if svg == "" {
14910 return
14911 }
14912 scrim := dom.CreateElement("div")
14913 dom.SetStyle(scrim, "position", "fixed")
14914 dom.SetStyle(scrim, "inset", "0")
14915 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
14916 dom.SetStyle(scrim, "display", "flex")
14917 dom.SetStyle(scrim, "alignItems", "center")
14918 dom.SetStyle(scrim, "justifyContent", "center")
14919 dom.SetStyle(scrim, "zIndex", "9999")
14920 dom.SetStyle(scrim, "cursor", "pointer")
14921 dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
14922 dom.RemoveChild(dom.Body(), scrim)
14923 }))
14924
14925 card := dom.CreateElement("div")
14926 dom.SetStyle(card, "background", "white")
14927 dom.SetStyle(card, "borderRadius", "16px")
14928 dom.SetStyle(card, "padding", "24px")
14929 dom.SetStyle(card, "display", "flex")
14930 dom.SetStyle(card, "flexDirection", "column")
14931 dom.SetStyle(card, "alignItems", "center")
14932 dom.SetStyle(card, "cursor", "default")
14933 dom.SetAttribute(card, "onclick", "event.stopPropagation()")
14934 dom.SetInnerHTML(card, svg)
14935
14936 dom.AppendChild(scrim, card)
14937 dom.AppendChild(dom.Body(), scrim)
14938 }
14939
14940 func clearChildren(el dom.Element) {
14941 dom.ReleaseChildren(el)
14942 dom.SetInnerHTML(el, "")
14943 }
14944
14945 func removeChildrenByPK(parent dom.Element, pk string) {
14946 if parent == 0 {
14947 return
14948 }
14949 var toRemove []dom.Element
14950 child := dom.FirstElementChild(parent)
14951 for child != 0 {
14952 if dom.GetProperty(child, "smeshPK") == pk {
14953 toRemove = append(toRemove, child)
14954 }
14955 child = dom.NextSibling(child)
14956 }
14957 for _, el := range toRemove {
14958 dom.RemoveChild(parent, el)
14959 }
14960 }
14961
14962 func itoa(n int32) (s string) {
14963 if n == 0 {
14964 return "0"
14965 }
14966 neg := false
14967 if n < 0 {
14968 neg = true
14969 n = -n
14970 }
14971 var b [20]byte
14972 i := len(b)
14973 for n > 0 {
14974 i--
14975 b[i] = byte('0' + n%10)
14976 n /= 10
14977 }
14978 if neg {
14979 i--
14980 b[i] = '-'
14981 }
14982 return string(b[i:])
14983 }
14984