package main import ( "common/helpers" "common/jsbridge/dom" "common/jsbridge/localstorage" "common/jsbridge/signer" "common/nostr" ) const ( lsKeyPubkey = "smesh-pubkey" lsKeyTheme = "smesh-theme" ) var ( pubkey []byte pubhex string isDark bool // Profile data from kind 0. profileName string profilePic string profileTs int64 // DOM refs that need updating after creation. avatarEl dom.Element nameEl dom.Element feedContainer dom.Element feedLoader dom.Element statusEl dom.Element popoverEl dom.Element themeBtn dom.Element pageTitleEl dom.Element feedPage dom.Element msgPage dom.Element profilePage dom.Element sidebarFeed dom.Element sidebarMsg dom.Element activePage string profileViewPK string // App root — content goes here, not body (snackbar stays outside). root dom.Element // Messaging UI state. msgListContainer dom.Element // conversation list view msgThreadContainer dom.Element // thread view (header + messages + compose) msgThreadMessages dom.Element // scrollable message area msgComposeInput dom.Element // textarea msgCurrentPeer string // hex pubkey of open thread, "" when on list msgView string // "list" or "thread" marmotInited bool pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation // Relay tracking — parallel slices, grown dynamically. relayURLs []string relayDots []dom.Element relayLabels []dom.Element relayUserPick []bool // true = from user's kind 10002 eventCount int popoverOpen bool // Author profile cache. authorNames map[string]string // pubkey hex -> display name authorPics map[string]string // pubkey hex -> avatar URL authorContent map[string]string // pubkey hex -> full kind 0 content JSON authorTs map[string]int64 // pubkey hex -> created_at of cached kind 0 authorRelays map[string][]string // pubkey hex -> relay URLs from kind 10002 pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile fetchedK0 map[string]bool // pubkey hex -> already tried kind 0 fetch fetchedK10k map[string]bool // pubkey hex -> already tried kind 10002 fetch seenEvents map[string]bool // event ID -> already rendered authorSubPK map[string]string // subID -> pubkey hex for author profile subs // Relay frequency — how many kind 10002 lists include each relay URL. relayFreq map[string]int idbLoaded bool retryRound int // metadata retry round counter retryTimer int // debounce timer for batch retries fetchQueue []string // pubkeys queued for batch profile fetch fetchTimer int // debounce timer for fetch queue // QR modal. logoSVGCache string // Profile page tab state. profileTab string profileTabContent dom.Element profileTabBtns map[string]dom.Element authorFollows map[string][]string authorMutes map[string][]string profileNotesSeen map[string]bool activeProfileNoteSub string // History/routing. navPop bool // true during popstate — suppresses pushState ) const orlyRelay = "wss://relay.orly.dev" var defaultRelays = []string{ orlyRelay, "wss://nostr.wine", "wss://nostr.land", } func isLocalDev() bool { h := dom.Hostname() return h == "localhost" || (len(h) > 4 && h[:4] == "127.") } func main() { dom.ConsoleLog("starting smesh " + version) if isLocalDev() { defaultRelays = append(defaultRelays, "ws://localhost:3334") dom.ConsoleLog("dev mode: added local relay ws://localhost:3334") } themePref := localstorage.GetItem(lsKeyTheme) if themePref != "" { isDark = themePref == "dark" } else { isDark = dom.PrefersDark() } if isDark { dom.AddClass(dom.Body(), "dark") } root = dom.GetElementById("app-root") dom.SetAttribute(root, "data-version", version) stored := localstorage.GetItem(lsKeyPubkey) if stored != "" { pubhex = stored pubkey = helpers.HexDecode(stored) showApp() } else { showLogin() } } // --- Theme --- func toggleTheme() { body := dom.Body() isDark = !isDark if isDark { dom.AddClass(body, "dark") localstorage.SetItem(lsKeyTheme, "dark") } else { dom.RemoveClass(body, "dark") localstorage.SetItem(lsKeyTheme, "light") } updateThemeIcon() } func updateThemeIcon() { if isDark { dom.SetInnerHTML(themeBtn, "☀️") // ☀️ emoji sun } else { dom.SetInnerHTML(themeBtn, "🌙") // 🌙 } } // --- Login screen --- func showLogin() { clearChildren(root) wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "alignItems", "center") dom.SetStyle(wrap, "justifyContent", "center") dom.SetStyle(wrap, "height", "100vh") dom.SetStyle(wrap, "flexDirection", "column") // Smesh loader animation. loader := dom.CreateElement("div") dom.SetStyle(loader, "width", "180px") dom.SetStyle(loader, "height", "180px") dom.SetStyle(loader, "marginBottom", "16px") dom.FetchText("./smesh-loader.svg", func(svg string) { dom.SetInnerHTML(loader, svg) }) dom.AppendChild(wrap, loader) // Title. h1 := dom.CreateElement("h1") dom.SetTextContent(h1, "smesh") dom.SetStyle(h1, "color", "var(--accent)") dom.SetStyle(h1, "fontSize", "48px") dom.SetStyle(h1, "marginBottom", "4px") dom.AppendChild(wrap, h1) verTag := dom.CreateElement("span") dom.SetTextContent(verTag, version) dom.SetStyle(verTag, "color", "var(--muted)") dom.SetStyle(verTag, "fontSize", "12px") dom.AppendChild(wrap, verTag) sub := dom.CreateElement("p") dom.SetTextContent(sub, "nostr client \u2014 tinygo \u2192 javascript") dom.SetStyle(sub, "color", "var(--muted)") dom.SetStyle(sub, "marginBottom", "32px") dom.AppendChild(wrap, sub) // Error message. errEl := dom.CreateElement("div") dom.SetStyle(errEl, "color", "#e55") dom.SetStyle(errEl, "fontSize", "13px") dom.SetStyle(errEl, "marginBottom", "12px") dom.SetStyle(errEl, "minHeight", "18px") dom.AppendChild(wrap, errEl) // Login button. btn := dom.CreateElement("button") dom.SetTextContent(btn, "login with extension") dom.SetAttribute(btn, "type", "button") dom.SetStyle(btn, "padding", "10px 32px") dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(btn, "fontSize", "14px") dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#000") dom.SetStyle(btn, "border", "none") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(btn, "cursor", "pointer") dom.AppendChild(wrap, btn) dom.AppendChild(root, wrap) cb := dom.RegisterCallback(func() { if !signer.HasSigner() { dom.SetTextContent(errEl, "install a NIP-07 extension (nos2x, Alby, etc)") return } dom.SetTextContent(btn, "requesting...") signer.GetPublicKey(func(hex string) { if hex == "" { dom.SetTextContent(errEl, "login failed or was denied") dom.SetTextContent(btn, "login with extension") return } pubhex = hex pubkey = helpers.HexDecode(hex) localstorage.SetItem(lsKeyPubkey, pubhex) clearChildren(root) showApp() }) }) dom.AddEventListener(btn, "click", cb) } // --- Sidebar --- const svgFeed = `` const svgChat = `` func makeSidebarIcon(svgHTML string, active bool) dom.Element { btn := dom.CreateElement("button") dom.SetStyle(btn, "width", "36px") dom.SetStyle(btn, "height", "36px") dom.SetStyle(btn, "border", "none") dom.SetStyle(btn, "borderRadius", "6px") dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "display", "flex") dom.SetStyle(btn, "alignItems", "center") dom.SetStyle(btn, "justifyContent", "center") dom.SetStyle(btn, "padding", "0") dom.SetStyle(btn, "color", "var(--fg)") if active { dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#000") } else { dom.SetStyle(btn, "background", "transparent") } dom.SetInnerHTML(btn, svgHTML) return btn } func switchPage(name string) { if name == activePage { return } closeProfileNoteSub() if activePage == "profile" { profileViewPK = "" } activePage = name dom.SetTextContent(pageTitleEl, name) // Hide all pages. dom.SetStyle(feedPage, "display", "none") dom.SetStyle(msgPage, "display", "none") dom.SetStyle(profilePage, "display", "none") // Clear sidebar highlights. dom.SetStyle(sidebarFeed, "background", "transparent") dom.SetStyle(sidebarFeed, "color", "var(--fg)") dom.SetStyle(sidebarMsg, "background", "transparent") dom.SetStyle(sidebarMsg, "color", "var(--fg)") switch name { case "feed": dom.SetStyle(feedPage, "display", "block") dom.SetStyle(sidebarFeed, "background", "var(--accent)") dom.SetStyle(sidebarFeed, "color", "#000") if !navPop { dom.PushState("/") } case "messaging": dom.SetStyle(msgPage, "display", "block") dom.SetStyle(sidebarMsg, "background", "var(--accent)") dom.SetStyle(sidebarMsg, "color", "#000") dom.PostToSW("[\"PAGE\",\"messaging\"]") initMessaging() if !navPop { dom.PushState("/msg") } case "profile": dom.SetStyle(profilePage, "display", "block") // Profile URL is pushed by showProfile, not here. } } // navigateToPath handles URL-based routing for back/forward and initial load. // fullPath may include a hash fragment, e.g. "/p/npub1...#follows". func navigateToPath(fullPath string) { path := fullPath hash := "" for i := 0; i < len(fullPath); i++ { if fullPath[i] == '#' { path = fullPath[:i] hash = fullPath[i+1:] break } } if path == "/" || path == "/feed" || path == "" { switchPage("feed") } else if path == "/msg" { switchPage("messaging") if msgView == "thread" { closeThread() } } else if len(path) > 5 && path[:5] == "/msg/" { pk := npubToHex(path[5:]) if pk != "" { switchPage("messaging") openThread(pk) } } else if len(path) > 3 && path[:3] == "/p/" { pk := npubToHex(path[3:]) if pk != "" { showProfile(pk) if hash != "" { selectProfileTab(hash, pk) } } } } func npubToHex(npub string) string { b := helpers.DecodeNpub(npub) if b == nil { return "" } return helpers.HexEncode(b) } func initRouter() { dom.OnPopState(func(path string) { navPop = true navigateToPath(path) navPop = false }) // Navigate to initial URL if not root. path := dom.GetPath() if path != "/" && path != "" { navPop = true navigateToPath(path) navPop = false } else { dom.ReplaceState("/") } } func makeProtoBtn(label string) dom.Element { btn := dom.CreateElement("button") dom.SetTextContent(btn, label) dom.SetStyle(btn, "padding", "6px 16px") dom.SetStyle(btn, "border", "none") dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(btn, "fontSize", "12px") dom.SetStyle(btn, "cursor", "default") dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--fg)") return btn } // --- Main app --- func showApp() { // Init profile cache maps. authorNames = make(map[string]string) authorPics = make(map[string]string) authorContent = make(map[string]string) authorTs = make(map[string]int64) authorRelays = make(map[string][]string) pendingNotes = make(map[string][]dom.Element) fetchedK0 = make(map[string]bool) fetchedK10k = make(map[string]bool) relayFreq = make(map[string]int) authorSubPK = make(map[string]string) seenEvents = make(map[string]bool) authorFollows = make(map[string][]string) authorMutes = make(map[string][]string) profileNotesSeen = make(map[string]bool) profileTabBtns = make(map[string]dom.Element) // Set up SW communication. dom.OnSWMessage(onSWMessage) dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]") // Load cached profiles from IndexedDB. dom.IDBGetAll("profiles", func(key, val string) { name := helpers.JsonGetString(val, "name") pic := helpers.JsonGetString(val, "picture") if name != "" { authorNames[key] = name } if pic != "" { authorPics[key] = pic } }, func() { idbLoaded = true // Update note headers rendered before IDB finished loading. for pk, headers := range pendingNotes { name := authorNames[pk] pic := authorPics[pk] if name != "" { for _, h := range headers { updateNoteHeader(h, name, pic) } delete(pendingNotes, pk) fetchedK0[pk] = true // don't fetch, already cached } } }) // === Top bar === bar := dom.CreateElement("div") dom.SetStyle(bar, "display", "flex") dom.SetStyle(bar, "alignItems", "center") dom.SetStyle(bar, "padding", "8px 16px") dom.SetStyle(bar, "height", "48px") dom.SetStyle(bar, "boxSizing", "border-box") dom.SetStyle(bar, "background", "var(--bg2)") dom.SetStyle(bar, "position", "fixed") dom.SetStyle(bar, "top", "0") dom.SetStyle(bar, "left", "0") dom.SetStyle(bar, "right", "0") dom.SetStyle(bar, "zIndex", "100") // Left: page title. left := dom.CreateElement("div") dom.SetStyle(left, "display", "flex") dom.SetStyle(left, "alignItems", "center") dom.SetStyle(left, "flex", "1") dom.SetStyle(left, "minWidth", "0") pageTitleEl = dom.CreateElement("span") dom.SetStyle(pageTitleEl, "fontSize", "18px") dom.SetStyle(pageTitleEl, "fontWeight", "bold") dom.SetTextContent(pageTitleEl, "feed") dom.AppendChild(left, pageTitleEl) dom.AppendChild(bar, left) // Center: dendrite logo. logo := dom.CreateElement("div") dom.SetStyle(logo, "width", "32px") dom.SetStyle(logo, "height", "32px") dom.SetStyle(logo, "flexShrink", "0") dom.FetchText("./smesh-loader.svg", func(svg string) { logoSVGCache = svg dom.SetInnerHTML(logo, svg) svgEl := dom.FirstChild(logo) if svgEl != 0 { dom.SetAttribute(svgEl, "width", "100%") dom.SetAttribute(svgEl, "height", "100%") } }) dom.AppendChild(bar, logo) // Right: theme toggle + logout. right := dom.CreateElement("div") dom.SetStyle(right, "display", "flex") dom.SetStyle(right, "alignItems", "center") dom.SetStyle(right, "gap", "8px") dom.SetStyle(right, "flex", "1") dom.SetStyle(right, "justifyContent", "flex-end") themeBtn = dom.CreateElement("button") dom.SetStyle(themeBtn, "background", "transparent") dom.SetStyle(themeBtn, "border", "none") dom.SetStyle(themeBtn, "borderRadius", "50%") dom.SetStyle(themeBtn, "width", "32px") dom.SetStyle(themeBtn, "height", "32px") dom.SetStyle(themeBtn, "fontSize", "16px") dom.SetStyle(themeBtn, "cursor", "pointer") dom.SetStyle(themeBtn, "padding", "0") dom.SetStyle(themeBtn, "display", "flex") dom.SetStyle(themeBtn, "alignItems", "center") dom.SetStyle(themeBtn, "justifyContent", "center") dom.SetStyle(themeBtn, "lineHeight", "1") updateThemeIcon() dom.AddEventListener(themeBtn, "click", dom.RegisterCallback(func() { toggleTheme() })) dom.AppendChild(right, themeBtn) logout := dom.CreateElement("button") dom.SetTextContent(logout, "logout") dom.SetStyle(logout, "fontFamily", "'Fira Code', monospace") dom.SetStyle(logout, "fontSize", "12px") dom.SetStyle(logout, "background", "transparent") dom.SetStyle(logout, "border", "none") dom.SetStyle(logout, "color", "var(--fg)") dom.SetStyle(logout, "borderRadius", "4px") dom.SetStyle(logout, "height", "32px") dom.SetStyle(logout, "padding", "0 16px") dom.SetStyle(logout, "cursor", "pointer") dom.AddEventListener(logout, "click", dom.RegisterCallback(func() { doLogout() })) dom.AppendChild(right, logout) dom.AppendChild(bar, right) dom.AppendChild(root, bar) // === Main layout: sidebar + content === mainLayout := dom.CreateElement("div") dom.SetStyle(mainLayout, "position", "fixed") dom.SetStyle(mainLayout, "top", "48px") dom.SetStyle(mainLayout, "bottom", "36px") dom.SetStyle(mainLayout, "left", "0") dom.SetStyle(mainLayout, "right", "0") dom.SetStyle(mainLayout, "display", "flex") // Sidebar. sidebar := dom.CreateElement("div") dom.SetStyle(sidebar, "width", "44px") dom.SetStyle(sidebar, "flexShrink", "0") dom.SetStyle(sidebar, "background", "var(--bg2)") dom.SetStyle(sidebar, "display", "flex") dom.SetStyle(sidebar, "flexDirection", "column") dom.SetStyle(sidebar, "alignItems", "center") dom.SetStyle(sidebar, "paddingTop", "8px") dom.SetStyle(sidebar, "gap", "4px") sidebarFeed = makeSidebarIcon(svgFeed, true) dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() { switchPage("feed") })) dom.AppendChild(sidebar, sidebarFeed) sidebarMsg = makeSidebarIcon(svgChat, false) dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() { switchPage("messaging") })) dom.AppendChild(sidebar, sidebarMsg) dom.AppendChild(mainLayout, sidebar) // Content area. contentArea := dom.CreateElement("div") dom.SetStyle(contentArea, "flex", "1") dom.SetStyle(contentArea, "overflowY", "auto") // Feed page. feedPage = dom.CreateElement("div") dom.SetStyle(feedPage, "padding", "16px") // Loading spinner — shown until first feed event arrives. feedLoader = dom.CreateElement("div") dom.SetStyle(feedLoader, "display", "flex") dom.SetStyle(feedLoader, "flexDirection", "column") dom.SetStyle(feedLoader, "alignItems", "center") dom.SetStyle(feedLoader, "justifyContent", "center") dom.SetStyle(feedLoader, "padding", "64px 0") loaderImg := dom.CreateElement("div") dom.SetStyle(loaderImg, "width", "120px") dom.SetStyle(loaderImg, "height", "120px") dom.FetchText("./smesh-loader.svg", func(svg string) { dom.SetInnerHTML(loaderImg, svg) svgEl := dom.FirstChild(loaderImg) if svgEl != 0 { dom.SetAttribute(svgEl, "width", "100%") dom.SetAttribute(svgEl, "height", "100%") } }) dom.AppendChild(feedLoader, loaderImg) loaderText := dom.CreateElement("div") dom.SetTextContent(loaderText, "connecting...") dom.SetStyle(loaderText, "marginTop", "16px") dom.SetStyle(loaderText, "color", "var(--muted)") dom.SetStyle(loaderText, "fontSize", "14px") dom.AppendChild(feedLoader, loaderText) dom.AppendChild(feedPage, feedLoader) feedContainer = dom.CreateElement("div") dom.AppendChild(feedPage, feedContainer) dom.AppendChild(contentArea, feedPage) // Messaging page. msgPage = dom.CreateElement("div") dom.SetStyle(msgPage, "padding", "16px") dom.SetStyle(msgPage, "display", "none") dom.SetStyle(msgPage, "position", "relative") dom.SetStyle(msgPage, "height", "100%") dom.SetStyle(msgPage, "boxSizing", "border-box") // Conversation list view. msgListContainer = dom.CreateElement("div") dom.AppendChild(msgPage, msgListContainer) // Thread view (hidden by default). msgThreadContainer = dom.CreateElement("div") dom.SetStyle(msgThreadContainer, "display", "none") dom.SetStyle(msgThreadContainer, "flexDirection", "column") dom.SetStyle(msgThreadContainer, "position", "absolute") dom.SetStyle(msgThreadContainer, "top", "0") dom.SetStyle(msgThreadContainer, "left", "0") dom.SetStyle(msgThreadContainer, "right", "0") dom.SetStyle(msgThreadContainer, "bottom", "0") dom.SetStyle(msgThreadContainer, "background", "var(--bg)") dom.AppendChild(msgPage, msgThreadContainer) msgView = "list" dom.AppendChild(contentArea, msgPage) // Profile page. profilePage = dom.CreateElement("div") dom.SetStyle(profilePage, "display", "none") dom.AppendChild(contentArea, profilePage) dom.AppendChild(mainLayout, contentArea) dom.AppendChild(root, mainLayout) activePage = "feed" // === Bottom status bar === bottomBar := dom.CreateElement("div") dom.SetStyle(bottomBar, "position", "fixed") dom.SetStyle(bottomBar, "bottom", "0") dom.SetStyle(bottomBar, "left", "0") dom.SetStyle(bottomBar, "right", "0") dom.SetStyle(bottomBar, "height", "36px") dom.SetStyle(bottomBar, "display", "flex") dom.SetStyle(bottomBar, "alignItems", "center") dom.SetStyle(bottomBar, "padding", "0 12px") dom.SetStyle(bottomBar, "gap", "8px") dom.SetStyle(bottomBar, "background", "var(--bg2)") dom.SetStyle(bottomBar, "fontSize", "12px") dom.SetStyle(bottomBar, "color", "var(--fg)") dom.SetStyle(bottomBar, "zIndex", "100") // Avatar + name in clickable box. userBtn := dom.CreateElement("div") dom.SetStyle(userBtn, "display", "flex") dom.SetStyle(userBtn, "alignItems", "center") dom.SetStyle(userBtn, "gap", "6px") dom.SetStyle(userBtn, "padding", "4px 10px") dom.SetStyle(userBtn, "border", "none") dom.SetStyle(userBtn, "borderRadius", "4px") dom.SetStyle(userBtn, "cursor", "pointer") avatarEl = dom.CreateElement("img") dom.SetAttribute(avatarEl, "width", "20") dom.SetAttribute(avatarEl, "height", "20") dom.SetStyle(avatarEl, "borderRadius", "50%") dom.SetStyle(avatarEl, "objectFit", "cover") dom.SetStyle(avatarEl, "display", "none") dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'") dom.AppendChild(userBtn, avatarEl) nameEl = dom.CreateElement("span") dom.SetStyle(nameEl, "fontSize", "12px") dom.SetStyle(nameEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameEl, "fontWeight", "bold") dom.SetStyle(nameEl, "overflow", "hidden") dom.SetStyle(nameEl, "textOverflow", "ellipsis") dom.SetStyle(nameEl, "whiteSpace", "nowrap") dom.SetStyle(nameEl, "maxWidth", "120px") npubStr := helpers.EncodeNpub(pubkey) if len(npubStr) > 20 { dom.SetTextContent(nameEl, npubStr[:12]+"..."+npubStr[len(npubStr)-4:]) } dom.AppendChild(userBtn, nameEl) dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() { showProfile(pubhex) })) dom.AppendChild(bottomBar, userBtn) sep := dom.CreateElement("span") dom.SetTextContent(sep, "|") dom.SetStyle(sep, "color", "var(--muted)") dom.AppendChild(bottomBar, sep) statusEl = dom.CreateElement("span") dom.SetTextContent(statusEl, "connecting...") dom.SetStyle(statusEl, "cursor", "pointer") dom.AppendChild(bottomBar, statusEl) dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() { togglePopover() })) ver := dom.CreateElement("span") dom.SetTextContent(ver, "smesh "+version) dom.SetStyle(ver, "marginLeft", "auto") dom.SetStyle(ver, "color", "var(--accent)") dom.AppendChild(bottomBar, ver) dom.AppendChild(root, bottomBar) // === Relay popover (hidden) === popoverEl = dom.CreateElement("div") dom.SetStyle(popoverEl, "position", "fixed") dom.SetStyle(popoverEl, "bottom", "37px") dom.SetStyle(popoverEl, "left", "44px") dom.SetStyle(popoverEl, "right", "0") dom.SetStyle(popoverEl, "background", "var(--bg2)") dom.SetStyle(popoverEl, "borderTop", "1px solid var(--border)") dom.SetStyle(popoverEl, "padding", "12px 16px") dom.SetStyle(popoverEl, "fontSize", "12px") dom.SetStyle(popoverEl, "display", "none") dom.SetStyle(popoverEl, "zIndex", "99") dom.AppendChild(root, popoverEl) // Add default relays. for _, url := range defaultRelays { addRelay(url, false) } // Tell SW about relays and subscribe. sendWriteRelays() subscribeProfile() subscribeFeed() // Wire up browser history navigation. initRouter() } // addRelay adds a relay to the list and creates its popover row. // userPick=true means it came from the user's kind 10002 relay list. func addRelay(url string, userPick bool) { url = normalizeURL(url) // Dedup. for i, u := range relayURLs { if u == url { if userPick && !relayUserPick[i] { relayUserPick[i] = true dom.SetStyle(relayLabels[i], "fontWeight", "bold") } return } } relayURLs = append(relayURLs, url) relayUserPick = append(relayUserPick, userPick) // Popover row. row := dom.CreateElement("div") dom.SetStyle(row, "padding", "3px 0") dot := dom.CreateElement("span") dom.SetTextContent(dot, "\u25CF") dom.SetStyle(dot, "color", "#5b5") dom.SetStyle(dot, "marginRight", "8px") relayDots = append(relayDots, dot) dom.AppendChild(row, dot) label := dom.CreateElement("span") dom.SetTextContent(label, url) if userPick { dom.SetStyle(label, "fontWeight", "bold") } relayLabels = append(relayLabels, label) dom.AppendChild(row, label) dom.AppendChild(popoverEl, row) updateStatus() } func togglePopover() { popoverOpen = !popoverOpen if popoverOpen { dom.SetStyle(popoverEl, "display", "block") } else { dom.SetStyle(popoverEl, "display", "none") } } func subscribeProfile() { proxy := make([]string, len(discoveryRelays), len(discoveryRelays)+len(relayURLs)) copy(proxy, discoveryRelays) for _, u := range relayURLs { proxy = appendUnique(proxy, u) } dom.PostToSW(buildProxyMsg("prof", "{\"authors\":["+jstr(pubhex)+"],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}", proxy)) } func subscribeFeed() { dom.PostToSW(buildProxyMsg("feed", "{\"kinds\":[1],\"limit\":20}", relayURLs)) } func sendWriteRelays() { msg := "[\"SET_WRITE_RELAYS\",[" for i, url := range relayURLs { if i > 0 { msg += "," } msg += jstr(url) } dom.PostToSW(msg + "]]") } func buildProxyMsg(subID, filterJSON string, urls []string) string { msg := "[\"PROXY\"," + jstr(subID) + "," + filterJSON + ",[" for i, url := range urls { if i > 0 { msg += "," } msg += jstr(url) } return msg + "]]" } func jstr(s string) string { return "\"" + jsonEsc(s) + "\"" } // scheduleTabRetry schedules a retry for any pending profile fetches after // the follows/mutes tab renders. Independent of retryRound so it works even // after the feed's retry budget is exhausted. func scheduleTabRetry() { dom.SetTimeout(func() { var missing []string for pk := range pendingNotes { if _, ok := authorNames[pk]; !ok { missing = append(missing, pk) } } if len(missing) == 0 { return } for _, pk := range missing { fetchedK0[pk] = false } for _, pk := range missing { queueProfileFetch(pk) } }, 5000) } // --- SW message handling --- func onSWMessage(raw string) { if raw == "update-available" { dom.PostToSW("activate-update") return } if raw == "reload" { dom.LocationReload() return } if len(raw) < 5 || raw[0] != '[' { return } typ, pos := nextStr(raw, 1) switch typ { case "EVENT": subID, pos2 := nextStr(raw, pos) evJSON := extractValue(raw, pos2) if evJSON == "" { return } ev := nostr.ParseEvent(evJSON) if ev == nil { return } dispatchEvent(subID, ev) case "EOSE": subID, _ := nextStr(raw, pos) dispatchEOSE(subID) case "DM_LIST": listJSON := extractValue(raw, pos) renderConversationList(listJSON) case "DM_HISTORY": peer, pos2 := nextStr(raw, pos) msgsJSON := extractValue(raw, pos2) renderThreadMessages(peer, msgsJSON) case "DM_RECEIVED": dmJSON := extractValue(raw, pos) handleDMReceived(dmJSON) case "DM_SENT": tsStr := nextNum(raw, pos) var ts int64 for i := 0; i < len(tsStr); i++ { if tsStr[i] >= '0' && tsStr[i] <= '9' { ts = ts*10 + int64(tsStr[i]-'0') } } if ts > 0 && len(pendingTsEls) > 0 { dom.SetTextContent(pendingTsEls[0], formatTime(ts)) pendingTsEls = pendingTsEls[1:] } case "DM_HISTORY_CLEARED": // Messages already cleared optimistically on ratchet button click. peer, _ := nextStr(raw, pos) dom.ConsoleLog("[mls] history cleared for " + peer) case "MLS_GROUPS": // Store for future use. case "MLS_STATUS": text, _ := nextStr(raw, pos) dom.ConsoleLog("[mls] " + text) case "SW_LOG": origin, pos2 := nextStr(raw, pos) logMsg, _ := nextStr(raw, pos2) dom.ConsoleLog("[" + origin + "] " + logMsg) return case "CRYPTO_REQ": handleCryptoReq(raw, pos) case "NEED_IDENTITY": if pubhex != "" { dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]") } resubscribe() case "RESUB": resubscribe() } } func resubscribe() { sendWriteRelays() subscribeProfile() subscribeFeed() if activePage == "messaging" { initMessaging() } } func dispatchEvent(subID string, ev *nostr.Event) { if subID == "prof" { handleProfileEvent(ev) } else if subID == "feed" { if seenEvents[ev.ID] { return } seenEvents[ev.ID] = true eventCount++ if feedLoader != 0 { dom.RemoveChild(feedPage, feedLoader) feedLoader = 0 } renderNote(ev) } else if len(subID) > 3 && subID[:3] == "ap-" { if ev.Kind == 0 { applyAuthorProfile(ev.PubKey, ev) } else if ev.Kind == 3 { var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorFollows[ev.PubKey] = pks refreshProfileTab(ev.PubKey) } else if ev.Kind == 10002 { recordRelayFreq(ev) } else if ev.Kind == 10000 { var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorMutes[ev.PubKey] = pks refreshProfileTab(ev.PubKey) } } else if len(subID) > 3 && subID[:3] == "pn-" { if profileNotesSeen[ev.ID] { return } profileNotesSeen[ev.ID] = true renderProfileNote(ev) } } func dispatchEOSE(subID string) { if subID == "feed" { if feedLoader != 0 { dom.RemoveChild(feedPage, feedLoader) feedLoader = 0 } updateStatus() retryMissingProfiles() } else if len(subID) > 9 && subID[:9] == "ap-batch-" { // Delay CLOSE: server-side _proxy fan-out to external relays takes 5-15s. // Keep sub alive so late-arriving events flow through pushToMatchingSubs. closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 15000) // Debounce: schedule follow-up retry 10s after last batch EOSE (max 3 rounds). if retryRound <= 3 { if retryTimer != 0 { dom.ClearTimeout(retryTimer) } retryTimer = dom.SetTimeout(func() { retryTimer = 0 retryMissingProfiles() }, 10000) } } else if len(subID) > 3 && subID[:3] == "ap-" { closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 15000) pk, ok := authorSubPK[subID] if !ok { return } delete(authorSubPK, subID) if _, got := authorNames[pk]; !got { if rels, ok := authorRelays[pk]; ok && len(rels) > 0 && !fetchedK10k[pk] { fetchedK10k[pk] = true fetchedK0[pk] = false fetchAuthorProfile(pk) } } } } // handleCryptoReq processes CRYPTO_REQ from the SW, calling the NIP-07 // extension and posting CRYPTO_RESULT back. // Format: ["CRYPTO_REQ", id, "method", "peerPubkey", "data"] func handleCryptoReq(raw string, pos int) { // id is a bare number, not a quoted string. idStr := nextNum(raw, pos) // Skip past the number and comma to find the method string. pos2 := pos for pos2 < len(raw) && raw[pos2] != ',' { pos2++ } pos2++ method, pos3 := nextStr(raw, pos2) peer, pos4 := nextStr(raw, pos3) data, _ := nextStr(raw, pos4) dom.ConsoleLog("crypto: " + method + " #" + idStr) sendResult := func(result, errMsg string) { if errMsg != "" { dom.ConsoleLog("crypto: " + method + " #" + idStr + " ERR=" + errMsg) } else { dom.ConsoleLog("crypto: " + method + " #" + idStr + " OK") } dom.PostToSW("[\"CRYPTO_RESULT\"," + idStr + "," + jstr(result) + "," + jstr(errMsg) + "]") } switch method { case "signEvent": signer.SignEvent(data, func(signed string) { if signed == "" { sendResult("", "sign failed") } else { sendResult(signed, "") } }) case "nip04.decrypt": signer.Nip04Decrypt(peer, data, func(plain string) { if plain == "" { sendResult("", "decrypt failed") } else { sendResult(plain, "") } }) case "nip04.encrypt": signer.Nip04Encrypt(peer, data, func(ct string) { if ct == "" { sendResult("", "encrypt failed") } else { sendResult(ct, "") } }) case "nip44.decrypt": signer.Nip44Decrypt(peer, data, func(plain string) { if plain == "" { sendResult("", "decrypt failed") } else { sendResult(plain, "") } }) case "nip44.encrypt": signer.Nip44Encrypt(peer, data, func(ct string) { if ct == "" { sendResult("", "encrypt failed") } else { sendResult(ct, "") } }) default: sendResult("", "unknown method: "+method) } } // nextNum extracts a bare number from s starting at pos, returning it as a string. func nextNum(s string, pos int) string { for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') { pos++ } start := pos for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' { pos++ } return s[start:pos] } // nextStr extracts the next quoted string from s starting at pos. func nextStr(s string, pos int) (string, int) { for pos < len(s) && s[pos] != '"' { pos++ } if pos >= len(s) { return "", pos } pos++ var buf []byte hasEsc := false start := pos for pos < len(s) { if s[pos] == '\\' && pos+1 < len(s) { hasEsc = true buf = append(buf, s[start:pos]...) pos++ switch s[pos] { case '"', '\\', '/': buf = append(buf, s[pos]) case 'n': buf = append(buf, '\n') case 't': buf = append(buf, '\t') case 'r': buf = append(buf, '\r') default: buf = append(buf, '\\', s[pos]) } pos++ start = pos continue } if s[pos] == '"' { break } pos++ } if pos >= len(s) { return "", pos } var val string if hasEsc { buf = append(buf, s[start:pos]...) val = string(buf) } else { val = s[start:pos] } pos++ for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') { pos++ } return val, pos } // extractValue extracts a JSON object/array value starting at pos. func extractValue(s string, pos int) string { for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') { pos++ } if pos >= len(s) { return "" } if s[pos] != '{' && s[pos] != '[' { return "" } start := pos depth := 0 for pos < len(s) { c := s[pos] if c == '{' || c == '[' { depth++ } if c == '}' || c == ']' { depth-- if depth == 0 { return s[start : pos+1] } } if c == '"' { pos++ for pos < len(s) && s[pos] != '"' { if s[pos] == '\\' { pos++ } pos++ } } pos++ } return s[start:] } func handleProfileEvent(ev *nostr.Event) { switch ev.Kind { case 0: if ev.CreatedAt <= profileTs { return } profileTs = ev.CreatedAt authorContent[pubhex] = ev.Content name := helpers.JsonGetString(ev.Content, "name") if name == "" { name = helpers.JsonGetString(ev.Content, "display_name") } pic := helpers.JsonGetString(ev.Content, "picture") if name != "" { profileName = name authorNames[pubhex] = name dom.SetTextContent(nameEl, name) } if pic != "" { profilePic = pic authorPics[pubhex] = pic dom.SetAttribute(avatarEl, "src", pic) dom.SetStyle(avatarEl, "display", "block") } if profileViewPK == pubhex { renderProfilePage(pubhex) } case 3: var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorFollows[pubhex] = pks refreshProfileTab(pubhex) case 10000: var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorMutes[pubhex] = pks refreshProfileTab(pubhex) case 10002: // NIP-65 relay list — add user's preferred relays. recordRelayFreq(ev) for _, tag := range ev.Tags.GetAll("r") { url := tag.Value() if url != "" { addRelay(url, true) } } sendWriteRelays() subscribeFeed() case 10050: // DM inbox relay list — stored for future use. _ = ev.Tags.GetAll("relay") } } func updateStatus() { dom.SetTextContent(statusEl, itoa(len(relayURLs))+" relays | "+itoa(eventCount)+" events") } // --- Feed rendering --- func renderNote(ev *nostr.Event) { note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 0") // Author header: avatar + name. header := dom.CreateElement("div") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "alignItems", "center") dom.SetStyle(header, "gap", "8px") dom.SetStyle(header, "marginBottom", "4px") dom.SetStyle(header, "cursor", "pointer") headerPK := ev.PubKey dom.AddEventListener(header, "click", dom.RegisterCallback(func() { showProfile(headerPK) })) avatar := dom.CreateElement("img") dom.SetAttribute(avatar, "width", "24") dom.SetAttribute(avatar, "height", "24") dom.SetStyle(avatar, "borderRadius", "50%") dom.SetStyle(avatar, "objectFit", "cover") dom.SetStyle(avatar, "flexShrink", "0") nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "18px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "color", "var(--fg)") pk := ev.PubKey if pic, ok := authorPics[pk]; ok && pic != "" { dom.SetAttribute(avatar, "src", pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:]) } } dom.AppendChild(header, avatar) dom.AppendChild(header, nameSpan) dom.AppendChild(note, header) // Track header for update when profile arrives; trigger fetch if not yet started. if _, cached := authorNames[pk]; !cached { pendingNotes[pk] = append(pendingNotes[pk], header) if !fetchedK0[pk] { queueProfileFetch(pk) } } // Content. content := dom.CreateElement("div") dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(content, "fontSize", "14px") dom.SetStyle(content, "lineHeight", "1.5") dom.SetStyle(content, "wordBreak", "break-word") text := ev.Content truncated := len(text) > 500 if truncated { text = text[:500] + "..." } dom.SetInnerHTML(content, renderMarkdown(text)) dom.AppendChild(note, content) if truncated { more := dom.CreateElement("span") dom.SetTextContent(more, "show more") dom.SetStyle(more, "color", "var(--accent)") dom.SetStyle(more, "cursor", "pointer") dom.SetStyle(more, "fontSize", "13px") dom.SetStyle(more, "display", "inline-block") dom.SetStyle(more, "marginTop", "4px") fullContent := ev.Content expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetInnerHTML(content, renderMarkdown(fullContent)) dom.SetTextContent(more, "show less") } else { dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"...")) dom.SetTextContent(more, "show more") } })) dom.AppendChild(note, more) } // Prepend (newest first). first := dom.FirstChild(feedContainer) if first != 0 { dom.InsertBefore(feedContainer, note, first) } else { dom.AppendChild(feedContainer, note) } } var profileSubCounter int // topRelays returns the n most frequently seen relay URLs from kind 10002 events. func topRelays(n int) []string { if relayFreq == nil { return nil } // Simple selection sort — n is small. type kv struct { url string count int } var all []kv for url, count := range relayFreq { all = append(all, kv{url, count}) } // Sort descending by count. for i := 0; i < len(all); i++ { for j := i + 1; j < len(all); j++ { if all[j].count > all[i].count { all[i], all[j] = all[j], all[i] } } } var result []string for i := 0; i < len(all) && i < n; i++ { result = append(result, all[i].url) } return result } // recordRelayFreq records relay URLs from a kind 10002 event into the frequency table. func recordRelayFreq(ev *nostr.Event) { tags := ev.Tags.GetAll("r") if tags == nil { return } var urls []string for _, tag := range tags { u := tag.Value() if u != "" { urls = append(urls, u) if _, ok := relayFreq[u]; ok { relayFreq[u] = relayFreq[u] + 1 } else { relayFreq[u] = 1 } } } if len(urls) > 0 { authorRelays[ev.PubKey] = urls } } // discoveryRelays are well-known relays that aggregate profile metadata. // Prioritized first in _proxy lists since they have the highest hit rate. var discoveryRelays = []string{ "wss://purplepag.es", "wss://relay.nostr.band", "wss://relay.damus.io", "wss://nos.lol", } // buildProxy builds a _proxy relay list for a pubkey. // Discovery relays first, then author-specific relays if known. func buildProxy(pk string) []string { out := make([]string, len(discoveryRelays)) copy(out, discoveryRelays) for _, u := range relayURLs { out = appendUnique(out, u) } if rels, ok := authorRelays[pk]; ok { for _, r := range rels { out = appendUnique(out, r) } } top := topRelays(4) for _, r := range top { out = appendUnique(out, r) } return out } func appendUnique(list []string, val string) []string { for _, v := range list { if v == val { return list } } return append(list, val) } // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY. func fetchAuthorProfile(pk string) { if fetchedK0[pk] { return } fetchedK0[pk] = true profileSubCounter++ subID := "ap-" + itoa(profileSubCounter) authorSubPK[subID] = pk proxyRelays := buildProxy(pk) dom.PostToSW(buildProxyMsg(subID, "{\"authors\":["+jstr(pk)+"],\"kinds\":[0,3,10002,10000],\"limit\":6}", proxyRelays)) } // queueProfileFetch adds a pubkey to the batch fetch queue with a debounce. // After 300ms of no new additions, flushFetchQueue sends one batched PROXY request. func queueProfileFetch(pk string) { if fetchedK0[pk] { return } fetchedK0[pk] = true fetchQueue = append(fetchQueue, pk) if fetchTimer != 0 { dom.ClearTimeout(fetchTimer) } fetchTimer = dom.SetTimeout(func() { fetchTimer = 0 flushFetchQueue() }, 300) } // flushFetchQueue sends all queued pubkeys as chunked batch PROXY requests. func flushFetchQueue() { if len(fetchQueue) == 0 { return } queue := fetchQueue fetchQueue = nil proxy := make([]string, len(discoveryRelays)) copy(proxy, discoveryRelays) for _, u := range relayURLs { proxy = appendUnique(proxy, u) } for _, pk := range queue { if rels, ok := authorRelays[pk]; ok { for _, r := range rels { proxy = appendUnique(proxy, r) } } } top := topRelays(4) for _, r := range top { proxy = appendUnique(proxy, r) } const batchSize = 100 for i := 0; i < len(queue); i += batchSize { end := i + batchSize if end > len(queue) { end = len(queue) } chunk := queue[i:end] authors := "[" for j, pk := range chunk { if j > 0 { authors += "," } authors += jstr(pk) } authors += "]" profileSubCounter++ subID := "ap-batch-q-" + itoa(profileSubCounter) dom.PostToSW(buildProxyMsg(subID, "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}", proxy)) // Also query feed relays directly — they have the kind 1 notes // so they almost certainly have kind 0 for the same authors. // Uses the SW's existing WebSocket connections, bypasses server proxy. profileSubCounter++ dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter), "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}", relayURLs)) } } // retryMissingProfiles batches pubkeys that still lack a name into chunked // PROXY requests through the orly relay. Fetches all metadata kinds so // relay lists from kind 10002 enable second-hop discovery. func retryMissingProfiles() { var missing []string for pk := range pendingNotes { if _, ok := authorNames[pk]; !ok { missing = append(missing, pk) } } if len(missing) == 0 { return } // Reset fetchedK0 for still-missing profiles so individual re-fetches // can fire if new relay info appears from other profiles' kind 10002. for _, pk := range missing { fetchedK0[pk] = false } // Discovery relays first, then user relays + discovered relays. proxy := make([]string, len(discoveryRelays)) copy(proxy, discoveryRelays) for _, u := range relayURLs { proxy = appendUnique(proxy, u) } top := topRelays(8) for _, u := range top { proxy = appendUnique(proxy, u) } const batchSize = 100 batchNum := 0 for i := 0; i < len(missing); i += batchSize { end := i + batchSize if end > len(missing) { end = len(missing) } chunk := missing[i:end] authors := "[" for j, pk := range chunk { if j > 0 { authors += "," } authors += jstr(pk) } authors += "]" subID := "ap-batch-" + itoa(retryRound) + "-" + itoa(batchNum) batchNum++ dom.PostToSW(buildProxyMsg(subID, "{\"authors\":"+authors+",\"kinds\":[0,10002],\"limit\":"+itoa(len(chunk)*2)+"}", proxy)) // Direct query to feed relays. profileSubCounter++ dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter), "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}", relayURLs)) } retryRound++ } // applyAuthorProfile updates cache and all pending note headers for a pubkey. func applyAuthorProfile(pk string, ev *nostr.Event) { if ev.CreatedAt <= authorTs[pk] { return } authorTs[pk] = ev.CreatedAt authorContent[pk] = ev.Content name := helpers.JsonGetString(ev.Content, "name") if name == "" { name = helpers.JsonGetString(ev.Content, "display_name") } pic := helpers.JsonGetString(ev.Content, "picture") if name != "" { authorNames[pk] = name } if pic != "" { authorPics[pk] = pic } // Cache to IndexedDB. if name != "" || pic != "" { dom.IDBPut("profiles", pk, "{\"name\":\""+jsonEsc(name)+"\",\"picture\":\""+jsonEsc(pic)+"\"}") } // Update logged-in user's header too. if pk == pubhex { if name != "" { profileName = name dom.SetTextContent(nameEl, name) } if pic != "" { profilePic = pic dom.SetAttribute(avatarEl, "src", pic) dom.SetStyle(avatarEl, "display", "block") } } // Update all pending note headers. if headers, ok := pendingNotes[pk]; ok && name != "" { for _, h := range headers { updateNoteHeader(h, name, pic) } delete(pendingNotes, pk) } // Re-render profile page if viewing this author. if profileViewPK == pk { renderProfilePage(pk) } } // updateNoteHeader fills in avatar+name on a note's author header div. func updateNoteHeader(header dom.Element, name, pic string) { // First child is , second is . img := dom.FirstChild(header) if img == 0 { return } span := dom.NextSibling(img) if pic != "" { dom.SetAttribute(img, "src", pic) dom.SetAttribute(img, "onerror", "this.style.display='none'") dom.SetStyle(img, "display", "") } if name != "" { dom.SetTextContent(span, name) } } // --- Profile page --- func showProfile(pk string) { profileViewPK = pk // Ensure we have full kind 0 content. If not, fetch it. if _, ok := authorContent[pk]; !ok { fetchedK0[pk] = false fetchAuthorProfile(pk) } renderProfilePage(pk) // Use the author's name as page title, fall back to "profile". title := "profile" if name, ok := authorNames[pk]; ok && name != "" { title = name } activePage = "" // force switchPage to run switchPage("profile") dom.SetTextContent(pageTitleEl, title) if !navPop { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) dom.PushState("/p/" + npub) } } func verifyNip05(nip05, pubkeyHex string, badge dom.Element) { at := -1 for i := 0; i < len(nip05); i++ { if nip05[i] == '@' { at = i break } } if at < 1 || at >= len(nip05)-1 { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️ return } local := nip05[:at] domain := nip05[at+1:] url := "https://" + domain + "/.well-known/nostr.json?name=" + local dom.FetchText(url, func(body string) { if body == "" { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️ return } namesObj := helpers.JsonGetString(body, "names") if namesObj == "" { // names might be an object not a string — extract manually namesStart := -1 key := "\"names\"" for i := 0; i < len(body)-len(key); i++ { if body[i:i+len(key)] == key { namesStart = i + len(key) break } } if namesStart < 0 { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") return } // Skip colon and whitespace to find the object for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') { namesStart++ } if namesStart >= len(body) || body[namesStart] != '{' { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") return } // Find matching brace depth := 0 end := namesStart for end < len(body) { if body[end] == '{' { depth++ } else if body[end] == '}' { depth-- if depth == 0 { end++ break } } end++ } namesObj = body[namesStart:end] } got := helpers.JsonGetString(namesObj, local) if got == pubkeyHex { dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅ } else { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️ } }) } func renderProfilePage(pk string) { savedTab := profileTab clearChildren(profilePage) closeProfileNoteSub() profileNotesSeen = make(map[string]bool) content := authorContent[pk] name := authorNames[pk] pic := authorPics[pk] about := helpers.JsonGetString(content, "about") website := helpers.JsonGetString(content, "website") nip05 := helpers.JsonGetString(content, "nip05") lud16 := helpers.JsonGetString(content, "lud16") banner := helpers.JsonGetString(content, "banner") // Banner — full width, 200px, cover. if banner != "" { bannerEl := dom.CreateElement("img") dom.SetAttribute(bannerEl, "src", banner) dom.SetStyle(bannerEl, "width", "100%") dom.SetStyle(bannerEl, "height", "240px") dom.SetStyle(bannerEl, "objectFit", "cover") dom.SetStyle(bannerEl, "objectPosition", "center") dom.SetStyle(bannerEl, "display", "block") dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'") dom.AppendChild(profilePage, bannerEl) } // User info card — glass effect, overlapping banner. card := dom.CreateElement("div") dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 85%, transparent)") dom.SetStyle(card, "backdropFilter", "blur(8px)") dom.SetStyle(card, "borderRadius", "8px") dom.SetStyle(card, "padding", "16px") if banner != "" { dom.SetStyle(card, "margin", "-48px 16px 0") } else { dom.SetStyle(card, "margin", "16px") } dom.SetStyle(card, "position", "relative") dom.SetStyle(card, "width", "fit-content") dom.SetStyle(card, "maxWidth", "calc(100% - 32px)") // Top row: avatar + info. topRow := dom.CreateElement("div") dom.SetStyle(topRow, "display", "flex") dom.SetStyle(topRow, "gap", "16px") dom.SetStyle(topRow, "alignItems", "flex-start") // Compute npub early — needed for avatar QR click and npub row. npubBytes := helpers.HexDecode(pk) npubStr := helpers.EncodeNpub(npubBytes) if pic != "" { av := dom.CreateElement("img") dom.SetAttribute(av, "src", pic) dom.SetAttribute(av, "width", "64") dom.SetAttribute(av, "height", "64") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") dom.SetStyle(av, "border", "3px solid var(--bg)") dom.SetStyle(av, "cursor", "pointer") dom.SetAttribute(av, "onerror", "this.style.display='none'") avNpub := npubStr dom.AddEventListener(av, "click", dom.RegisterCallback(func() { showQRModal(avNpub) })) dom.AppendChild(topRow, av) } info := dom.CreateElement("div") dom.SetStyle(info, "minWidth", "0") dom.SetStyle(info, "flex", "1") dom.SetStyle(info, "overflow", "hidden") if name != "" { nameSpan := dom.CreateElement("div") dom.SetTextContent(nameSpan, name) dom.SetStyle(nameSpan, "fontSize", "20px") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "cursor", "pointer") nameNpub := npubStr dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() { showQRModal(nameNpub) })) dom.AppendChild(info, nameSpan) } if nip05 != "" { nip05Row := dom.CreateElement("div") dom.SetStyle(nip05Row, "display", "flex") dom.SetStyle(nip05Row, "alignItems", "center") dom.SetStyle(nip05Row, "gap", "4px") nip05Text := dom.CreateElement("span") dom.SetTextContent(nip05Text, nip05) dom.SetStyle(nip05Text, "color", "var(--muted)") dom.SetStyle(nip05Text, "fontSize", "13px") dom.AppendChild(nip05Row, nip05Text) nip05Badge := dom.CreateElement("span") dom.SetStyle(nip05Badge, "fontSize", "14px") dom.AppendChild(nip05Row, nip05Badge) dom.AppendChild(info, nip05Row) // Async NIP-05 validation. verifyNip05(nip05, pk, nip05Badge) } // npub (full length) with copy + qr buttons. npubRow := dom.CreateElement("div") dom.SetStyle(npubRow, "display", "flex") dom.SetStyle(npubRow, "alignItems", "flex-start") dom.SetStyle(npubRow, "gap", "6px") dom.SetStyle(npubRow, "marginTop", "2px") npubEl := dom.CreateElement("span") dom.SetStyle(npubEl, "color", "var(--muted)") dom.SetStyle(npubEl, "fontSize", "12px") dom.SetStyle(npubEl, "wordBreak", "break-all") dom.SetTextContent(npubEl, npubStr) dom.AppendChild(npubRow, npubEl) copyBtn := dom.CreateElement("span") dom.SetTextContent(copyBtn, "copy") dom.SetStyle(copyBtn, "color", "var(--accent)") dom.SetStyle(copyBtn, "fontSize", "11px") dom.SetStyle(copyBtn, "cursor", "pointer") dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText('"+npubStr+"').then(()=>{this.textContent='copied!'});setTimeout(()=>{this.textContent='copy'},1500)") dom.AppendChild(npubRow, copyBtn) qrBtn := dom.CreateElement("span") dom.SetTextContent(qrBtn, "qr") dom.SetStyle(qrBtn, "color", "var(--accent)") dom.SetStyle(qrBtn, "fontSize", "11px") dom.SetStyle(qrBtn, "cursor", "pointer") npubForQR := npubStr dom.AddEventListener(qrBtn, "click", dom.RegisterCallback(func() { showQRModal(npubForQR) })) dom.AppendChild(npubRow, qrBtn) dom.AppendChild(info, npubRow) // Website + lightning inline. if website != "" || lud16 != "" { metaRow := dom.CreateElement("div") dom.SetStyle(metaRow, "display", "flex") dom.SetStyle(metaRow, "gap", "12px") dom.SetStyle(metaRow, "marginTop", "6px") dom.SetStyle(metaRow, "fontSize", "12px") if website != "" { wEl := dom.CreateElement("span") dom.SetStyle(wEl, "color", "var(--accent)") dom.SetStyle(wEl, "wordBreak", "break-all") dom.SetTextContent(wEl, website) dom.AppendChild(metaRow, wEl) } if lud16 != "" { lEl := dom.CreateElement("span") dom.SetStyle(lEl, "color", "var(--muted)") dom.SetStyle(lEl, "wordBreak", "break-all") dom.SetTextContent(lEl, "\xE2\x9A\xA1 "+lud16) dom.AppendChild(metaRow, lEl) } dom.AppendChild(info, metaRow) } dom.AppendChild(topRow, info) dom.AppendChild(card, topRow) // Message button — only for other users. if pk != pubhex { msgBtn := dom.CreateElement("button") dom.SetTextContent(msgBtn, "message") dom.SetStyle(msgBtn, "padding", "6px 16px") dom.SetStyle(msgBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(msgBtn, "fontSize", "12px") dom.SetStyle(msgBtn, "background", "var(--accent)") dom.SetStyle(msgBtn, "color", "#000") dom.SetStyle(msgBtn, "border", "none") dom.SetStyle(msgBtn, "borderRadius", "4px") dom.SetStyle(msgBtn, "cursor", "pointer") dom.SetStyle(msgBtn, "marginTop", "12px") peerPK := pk dom.AddEventListener(msgBtn, "click", dom.RegisterCallback(func() { switchPage("messaging") openThread(peerPK) })) dom.AppendChild(card, msgBtn) } dom.AppendChild(profilePage, card) // About/bio. if about != "" { aboutEl := dom.CreateElement("div") dom.SetStyle(aboutEl, "padding", "12px 16px") dom.SetStyle(aboutEl, "fontSize", "14px") dom.SetStyle(aboutEl, "lineHeight", "1.5") dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(aboutEl, "wordBreak", "break-word") aboutTruncated := len(about) > 300 aboutText := about if aboutTruncated { aboutText = about[:300] + "..." } dom.SetInnerHTML(aboutEl, renderMarkdown(aboutText)) dom.AppendChild(profilePage, aboutEl) if aboutTruncated { more := dom.CreateElement("span") dom.SetTextContent(more, "show more") dom.SetStyle(more, "color", "var(--accent)") dom.SetStyle(more, "cursor", "pointer") dom.SetStyle(more, "fontSize", "13px") dom.SetStyle(more, "display", "inline-block") dom.SetStyle(more, "padding", "0 16px 8px") aboutExpanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { aboutExpanded = !aboutExpanded if aboutExpanded { dom.SetInnerHTML(aboutEl, renderMarkdown(about)) dom.SetTextContent(more, "show less") } else { dom.SetInnerHTML(aboutEl, renderMarkdown(about[:300]+"...")) dom.SetTextContent(more, "show more") } })) dom.AppendChild(profilePage, more) } } // Tab bar. tabBar := dom.CreateElement("div") dom.SetStyle(tabBar, "display", "flex") dom.SetStyle(tabBar, "gap", "0") dom.SetStyle(tabBar, "margin", "0 16px") dom.SetStyle(tabBar, "border", "1px solid var(--border)") dom.SetStyle(tabBar, "borderRadius", "6px") dom.SetStyle(tabBar, "overflow", "hidden") profileTabBtns = make(map[string]dom.Element) // Unrolled — tinyjs range/loop closure aliasing. tabNotes := makeProtoBtn("notes") dom.SetStyle(tabNotes, "cursor", "pointer") profileTabBtns["notes"] = tabNotes tabNotesPK := pk dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() { selectProfileTab("notes", tabNotesPK) })) dom.AppendChild(tabBar, tabNotes) tabFollows := makeProtoBtn("follows") dom.SetStyle(tabFollows, "cursor", "pointer") profileTabBtns["follows"] = tabFollows tabFollowsPK := pk dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() { selectProfileTab("follows", tabFollowsPK) })) dom.AppendChild(tabBar, tabFollows) tabRelays := makeProtoBtn("relays") dom.SetStyle(tabRelays, "cursor", "pointer") profileTabBtns["relays"] = tabRelays tabRelaysPK := pk dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() { selectProfileTab("relays", tabRelaysPK) })) dom.AppendChild(tabBar, tabRelays) tabMutes := makeProtoBtn("mutes") dom.SetStyle(tabMutes, "cursor", "pointer") profileTabBtns["mutes"] = tabMutes tabMutesPK := pk dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() { selectProfileTab("mutes", tabMutesPK) })) dom.AppendChild(tabBar, tabMutes) dom.AppendChild(profilePage, tabBar) // Tab content container. profileTabContent = dom.CreateElement("div") dom.SetStyle(profileTabContent, "padding", "8px 0") dom.AppendChild(profilePage, profileTabContent) // Restore or default tab. profileTab = "" if savedTab != "" { selectProfileTab(savedTab, pk) } else { selectProfileTab("notes", pk) } // Update title. if name != "" && activePage == "profile" { dom.SetTextContent(pageTitleEl, name) } } func profileMetaRow(icon, text, link string) dom.Element { row := dom.CreateElement("div") dom.SetStyle(row, "padding", "4px 0") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "8px") iconEl := dom.CreateElement("span") dom.SetTextContent(iconEl, icon) dom.AppendChild(row, iconEl) if link != "" { href := link if strIndex(href, "://") < 0 { href = "https://" + href } a := dom.CreateElement("a") dom.SetAttribute(a, "href", href) dom.SetAttribute(a, "target", "_blank") dom.SetAttribute(a, "rel", "noopener") dom.SetStyle(a, "color", "var(--accent)") dom.SetStyle(a, "wordBreak", "break-all") dom.SetTextContent(a, text) dom.AppendChild(row, a) } else { span := dom.CreateElement("span") dom.SetStyle(span, "color", "var(--fg)") dom.SetTextContent(span, text) dom.AppendChild(row, span) } return row } // --- Profile tab functions --- func closeProfileNoteSub() { if activeProfileNoteSub != "" { dom.PostToSW("[\"CLOSE\"," + jstr(activeProfileNoteSub) + "]") activeProfileNoteSub = "" } } // refreshProfileTab re-renders the active tab if we're viewing this author's profile. func refreshProfileTab(pk string) { if profileViewPK != pk || profileTab == "" { return } // Force re-render by clearing current tab and re-selecting. saved := profileTab profileTab = "" selectProfileTab(saved, pk) } func selectProfileTab(tab, pk string) { if tab == profileTab { return } closeProfileNoteSub() profileTab = tab clearChildren(profileTabContent) for id, btn := range profileTabBtns { if id == tab { dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#000") } else { dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--fg)") } } // Update URL hash to reflect active tab. if !navPop && profileViewPK != "" { npub := helpers.EncodeNpub(helpers.HexDecode(profileViewPK)) dom.ReplaceState("/p/" + npub + "#" + tab) } switch tab { case "notes": renderProfileNotes(pk) case "follows": renderProfileFollows(pk) case "relays": renderProfileRelays(pk) case "mutes": renderProfileMutes(pk) } } func renderProfileNotes(pk string) { profileNotesSeen = make(map[string]bool) profileSubCounter++ subID := "pn-" + itoa(profileSubCounter) activeProfileNoteSub = subID proxyRelays := buildProxy(pk) dom.PostToSW(buildProxyMsg(subID, "{\"authors\":["+jstr(pk)+"],\"kinds\":[1],\"limit\":20}", proxyRelays)) } func renderProfileNote(ev *nostr.Event) { if profileTabContent == 0 || profileTab != "notes" { return } note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 16px") content := dom.CreateElement("div") dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(content, "fontSize", "14px") dom.SetStyle(content, "lineHeight", "1.5") dom.SetStyle(content, "wordBreak", "break-word") text := ev.Content truncated := len(text) > 500 if truncated { text = text[:500] + "..." } dom.SetInnerHTML(content, renderMarkdown(text)) dom.AppendChild(note, content) if truncated { more := dom.CreateElement("span") dom.SetTextContent(more, "show more") dom.SetStyle(more, "color", "var(--accent)") dom.SetStyle(more, "cursor", "pointer") dom.SetStyle(more, "fontSize", "13px") dom.SetStyle(more, "display", "inline-block") dom.SetStyle(more, "marginTop", "4px") fullContent := ev.Content expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetInnerHTML(content, renderMarkdown(fullContent)) dom.SetTextContent(more, "show less") } else { dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"...")) dom.SetTextContent(more, "show more") } })) dom.AppendChild(note, more) } // Timestamp. if ev.CreatedAt > 0 { ts := dom.CreateElement("div") dom.SetTextContent(ts, formatTime(ev.CreatedAt)) dom.SetStyle(ts, "color", "var(--muted)") dom.SetStyle(ts, "fontSize", "12px") dom.SetStyle(ts, "marginTop", "4px") dom.AppendChild(note, ts) } dom.AppendChild(profileTabContent, note) } func renderProfileFollows(pk string) { follows, ok := authorFollows[pk] if !ok || len(follows) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, "no follows data") dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "13px") dom.AppendChild(profileTabContent, empty) return } countEl := dom.CreateElement("div") dom.SetTextContent(countEl, itoa(len(follows))+" following") dom.SetStyle(countEl, "padding", "8px 16px") dom.SetStyle(countEl, "color", "var(--muted)") dom.SetStyle(countEl, "fontSize", "12px") dom.AppendChild(profileTabContent, countEl) for i := 0; i < len(follows); i++ { fpk := follows[i] row := makeProfileRow(fpk) dom.AppendChild(profileTabContent, row) } scheduleTabRetry() } func renderProfileRelays(pk string) { relays, ok := authorRelays[pk] if !ok || len(relays) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, "no relay data") dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "13px") dom.AppendChild(profileTabContent, empty) return } for i := 0; i < len(relays); i++ { rURL := relays[i] row := dom.CreateElement("div") dom.SetStyle(row, "padding", "10px 16px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "fontSize", "13px") urlEl := dom.CreateElement("span") dom.SetTextContent(urlEl, rURL) dom.SetStyle(urlEl, "color", "var(--accent)") dom.AppendChild(row, urlEl) clickURL := rURL dom.AddEventListener(row, "click", dom.RegisterCallback(func() { showRelayInfo(clickURL) })) dom.AppendChild(profileTabContent, row) } } func renderProfileMutes(pk string) { mutes, ok := authorMutes[pk] if !ok || len(mutes) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, "no mutes data") dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "13px") dom.AppendChild(profileTabContent, empty) return } countEl := dom.CreateElement("div") dom.SetTextContent(countEl, itoa(len(mutes))+" muted") dom.SetStyle(countEl, "padding", "8px 16px") dom.SetStyle(countEl, "color", "var(--muted)") dom.SetStyle(countEl, "fontSize", "12px") dom.AppendChild(profileTabContent, countEl) for i := 0; i < len(mutes); i++ { mpk := mutes[i] row := makeProfileRow(mpk) dom.AppendChild(profileTabContent, row) } scheduleTabRetry() } func makeProfileRow(pk string) dom.Element { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "10px") dom.SetStyle(row, "padding", "10px 16px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") av := dom.CreateElement("img") dom.SetAttribute(av, "width", "32") dom.SetAttribute(av, "height", "32") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := authorPics[pk]; ok && pic != "" { dom.SetAttribute(av, "src", pic) } else { dom.SetStyle(av, "display", "none") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(row, av) nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "14px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") if name, ok := authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:]) } } dom.AppendChild(row, nameSpan) rowPK := pk dom.AddEventListener(row, "click", dom.RegisterCallback(func() { showProfile(rowPK) })) if _, cached := authorNames[pk]; !cached { pendingNotes[pk] = append(pendingNotes[pk], row) if !fetchedK0[pk] { queueProfileFetch(pk) } } return row } // --- Relay info page --- func showRelayInfo(url string) { profileViewPK = "" closeProfileNoteSub() clearChildren(profilePage) hdr := dom.CreateElement("div") dom.SetStyle(hdr, "display", "flex") dom.SetStyle(hdr, "alignItems", "center") dom.SetStyle(hdr, "gap", "10px") dom.SetStyle(hdr, "padding", "16px") dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)") backBtn := dom.CreateElement("button") dom.SetInnerHTML(backBtn, "←") dom.SetStyle(backBtn, "background", "none") dom.SetStyle(backBtn, "border", "none") dom.SetStyle(backBtn, "fontSize", "20px") dom.SetStyle(backBtn, "cursor", "pointer") dom.SetStyle(backBtn, "color", "var(--fg)") dom.SetStyle(backBtn, "padding", "0") dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() { switchPage("feed") })) dom.AppendChild(hdr, backBtn) urlEl := dom.CreateElement("span") dom.SetTextContent(urlEl, url) dom.SetStyle(urlEl, "fontWeight", "bold") dom.SetStyle(urlEl, "fontSize", "14px") dom.SetStyle(urlEl, "wordBreak", "break-all") dom.AppendChild(hdr, urlEl) dom.AppendChild(profilePage, hdr) loading := dom.CreateElement("div") dom.SetTextContent(loading, "loading...") dom.SetStyle(loading, "padding", "16px") dom.SetStyle(loading, "color", "var(--muted)") dom.AppendChild(profilePage, loading) activePage = "" switchPage("profile") dom.SetTextContent(pageTitleEl, "relay info") // Convert wss→https for NIP-11 HTTP fetch. httpURL := url if len(httpURL) > 6 && httpURL[:6] == "wss://" { httpURL = "https://" + httpURL[6:] } else if len(httpURL) > 5 && httpURL[:5] == "ws://" { httpURL = "http://" + httpURL[5:] } dom.FetchRelayInfo(httpURL, func(body string) { dom.RemoveChild(profilePage, loading) if body == "" { errEl := dom.CreateElement("div") dom.SetTextContent(errEl, "failed to fetch relay info") dom.SetStyle(errEl, "padding", "16px") dom.SetStyle(errEl, "color", "#e55") dom.AppendChild(profilePage, errEl) return } renderRelayInfoBody(body) }) } func renderRelayInfoBody(body string) { container := dom.CreateElement("div") dom.SetStyle(container, "padding", "16px") name := helpers.JsonGetString(body, "name") desc := helpers.JsonGetString(body, "description") pk := helpers.JsonGetString(body, "pubkey") contact := helpers.JsonGetString(body, "contact") software := helpers.JsonGetString(body, "software") ver := helpers.JsonGetString(body, "version") if name != "" { el := dom.CreateElement("div") dom.SetTextContent(el, name) dom.SetStyle(el, "fontSize", "20px") dom.SetStyle(el, "fontWeight", "bold") dom.SetStyle(el, "marginBottom", "8px") dom.SetStyle(el, "fontFamily", "system-ui, sans-serif") dom.AppendChild(container, el) } if desc != "" { el := dom.CreateElement("div") dom.SetInnerHTML(el, renderMarkdown(desc)) dom.SetStyle(el, "fontSize", "14px") dom.SetStyle(el, "lineHeight", "1.5") dom.SetStyle(el, "marginBottom", "12px") dom.SetStyle(el, "wordBreak", "break-word") dom.AppendChild(container, el) } if contact != "" { dom.AppendChild(container, profileMetaRow("@", contact, "")) } if pk != "" { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) short := npub if len(short) > 20 { short = short[:16] + "..." + short[len(short)-8:] } dom.AppendChild(container, profileMetaRow("pk", short, "")) } if software != "" { label := software if ver != "" { label += " " + ver } dom.AppendChild(container, profileMetaRow("sw", label, "")) } dom.AppendChild(profilePage, container) } // --- Messaging --- func relayURLsJSON() string { msg := "[" for i, url := range relayURLs { if i > 0 { msg += "," } msg += jstr(url) } return msg + "]" } func formatTime(ts int64) string { if ts == 0 { return "" } secs := ts % 86400 h := itoa(int(secs / 3600)) m := itoa(int((secs % 3600) / 60)) if len(h) < 2 { h = "0" + h } if len(m) < 2 { m = "0" + m } return h + ":" + m } func initMessaging() { // Render new-chat button immediately — don't wait for DM_LIST round-trip. clearChildren(msgListContainer) if !signer.HasSigner() { notice := dom.CreateElement("div") dom.SetStyle(notice, "padding", "24px") dom.SetStyle(notice, "textAlign", "center") dom.SetStyle(notice, "color", "var(--muted)") dom.SetStyle(notice, "fontSize", "13px") dom.SetStyle(notice, "lineHeight", "1.6") dom.SetInnerHTML(notice, "encrypted DMs require the Smesh Signer extension") dom.AppendChild(msgListContainer, notice) return } renderNewChatButton() // Request conversation list from cache (will re-render below the button). dom.PostToSW("[\"DM_LIST\"]") // Init MLS if not already done. publishKP + subscribe auto-bootstrap inside signer. if !marmotInited { marmotInited = true dom.PostToSW("[\"MLS_INIT\"," + relayURLsJSON() + "]") } } func renderNewChatButton() { newBtn := dom.CreateElement("button") dom.SetTextContent(newBtn, "+ new chat") dom.SetStyle(newBtn, "display", "block") dom.SetStyle(newBtn, "width", "100%") dom.SetStyle(newBtn, "padding", "10px") dom.SetStyle(newBtn, "marginBottom", "8px") dom.SetStyle(newBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(newBtn, "fontSize", "13px") dom.SetStyle(newBtn, "background", "var(--bg2)") dom.SetStyle(newBtn, "border", "1px solid var(--border)") dom.SetStyle(newBtn, "borderRadius", "6px") dom.SetStyle(newBtn, "color", "var(--accent)") dom.SetStyle(newBtn, "cursor", "pointer") dom.SetStyle(newBtn, "textAlign", "left") dom.AddEventListener(newBtn, "click", dom.RegisterCallback(func() { showNewChatInput() })) dom.AppendChild(msgListContainer, newBtn) } func renderConversationList(listJSON string) { if msgView != "list" { return } clearChildren(msgListContainer) renderNewChatButton() // Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...] if listJSON == "" || listJSON == "[]" { empty := dom.CreateElement("div") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "textAlign", "center") dom.SetStyle(empty, "marginTop", "48px") dom.SetTextContent(empty, "no conversations yet") dom.AppendChild(msgListContainer, empty) return } // Walk the JSON array manually — each element is an object. i := 0 for i < len(listJSON) && listJSON[i] != '[' { i++ } i++ // skip '[' for i < len(listJSON) { // Find next object. for i < len(listJSON) && listJSON[i] != '{' { if listJSON[i] == ']' { return } i++ } if i >= len(listJSON) { break } // Extract the object. objStart := i depth := 0 for i < len(listJSON) { if listJSON[i] == '{' { depth++ } else if listJSON[i] == '}' { depth-- if depth == 0 { i++ break } } else if listJSON[i] == '"' { i++ for i < len(listJSON) && listJSON[i] != '"' { if listJSON[i] == '\\' { i++ } i++ } } i++ } obj := listJSON[objStart:i] peer := helpers.JsonGetString(obj, "peer") lastMsg := helpers.JsonGetString(obj, "lastMessage") lastTs := jsonGetNum(obj, "lastTs") if peer == "" { continue } renderConversationRow(peer, lastMsg, lastTs) } } func renderConversationRow(peer, lastMsg string, lastTs int64) { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "10px") dom.SetStyle(row, "padding", "10px 4px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") // Avatar. av := dom.CreateElement("img") dom.SetAttribute(av, "width", "32") dom.SetAttribute(av, "height", "32") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := authorPics[peer]; ok && pic != "" { dom.SetAttribute(av, "src", pic) } else { dom.SetStyle(av, "background", "var(--bg2)") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(row, av) // Name + preview column. col := dom.CreateElement("div") dom.SetStyle(col, "flex", "1") dom.SetStyle(col, "minWidth", "0") nameSpan := dom.CreateElement("div") dom.SetStyle(nameSpan, "fontSize", "14px") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "overflow", "hidden") dom.SetStyle(nameSpan, "textOverflow", "ellipsis") dom.SetStyle(nameSpan, "whiteSpace", "nowrap") if name, ok := authorNames[peer]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:]) } else { dom.SetTextContent(nameSpan, npub) } } dom.AppendChild(col, nameSpan) preview := dom.CreateElement("div") dom.SetStyle(preview, "fontSize", "12px") dom.SetStyle(preview, "color", "var(--muted)") dom.SetStyle(preview, "overflow", "hidden") dom.SetStyle(preview, "textOverflow", "ellipsis") dom.SetStyle(preview, "whiteSpace", "nowrap") if len(lastMsg) > 80 { lastMsg = lastMsg[:80] + "..." } dom.SetTextContent(preview, lastMsg) dom.AppendChild(col, preview) dom.AppendChild(row, col) // Timestamp. if lastTs > 0 { tsSpan := dom.CreateElement("span") dom.SetStyle(tsSpan, "fontSize", "11px") dom.SetStyle(tsSpan, "color", "var(--muted)") dom.SetStyle(tsSpan, "flexShrink", "0") dom.SetTextContent(tsSpan, formatTime(lastTs)) dom.AppendChild(row, tsSpan) } rowPeer := peer dom.AddEventListener(row, "click", dom.RegisterCallback(func() { openThread(rowPeer) })) // Lazy profile fetch — batched to avoid 50+ simultaneous subscriptions. if _, cached := authorNames[peer]; !cached && !fetchedK0[peer] { queueProfileFetch(peer) } dom.AppendChild(msgListContainer, row) } func showNewChatInput() { // Check if input row already exists (first child after button). fc := dom.FirstChild(msgListContainer) if fc != 0 { ns := dom.NextSibling(fc) if ns != 0 { tag := dom.GetProperty(ns, "tagName") if tag == "DIV" { id := dom.GetProperty(ns, "id") if id == "new-chat-row" { return // already showing } } } } inputRow := dom.CreateElement("div") dom.SetAttribute(inputRow, "id", "new-chat-row") dom.SetStyle(inputRow, "display", "flex") dom.SetStyle(inputRow, "gap", "8px") dom.SetStyle(inputRow, "marginBottom", "8px") inp := dom.CreateElement("input") dom.SetAttribute(inp, "type", "text") dom.SetAttribute(inp, "placeholder", "npub or hex pubkey") dom.SetStyle(inp, "flex", "1") dom.SetStyle(inp, "padding", "8px") dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace") dom.SetStyle(inp, "fontSize", "12px") dom.SetStyle(inp, "background", "var(--bg)") dom.SetStyle(inp, "border", "1px solid var(--border)") dom.SetStyle(inp, "borderRadius", "4px") dom.SetStyle(inp, "color", "var(--fg)") goBtn := dom.CreateElement("button") dom.SetTextContent(goBtn, "go") dom.SetStyle(goBtn, "padding", "8px 16px") dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(goBtn, "fontSize", "12px") dom.SetStyle(goBtn, "background", "var(--accent)") dom.SetStyle(goBtn, "color", "#000") dom.SetStyle(goBtn, "border", "none") dom.SetStyle(goBtn, "borderRadius", "4px") dom.SetStyle(goBtn, "cursor", "pointer") submitNewChat := func() { val := dom.GetProperty(inp, "value") if val == "" { return } var hexPK string if len(val) == 64 { // Assume hex pubkey. hexPK = val } else if len(val) > 4 && val[:4] == "npub" { decoded := helpers.DecodeNpub(val) if decoded == nil { return } hexPK = helpers.HexEncode(decoded) } else { return } openThread(hexPK) } dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat)) // Enter key triggers the "go" button via inline handler. dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}") dom.AppendChild(inputRow, inp) dom.AppendChild(inputRow, goBtn) // Insert after the "new chat" button. btn := dom.FirstChild(msgListContainer) if btn != 0 { ns := dom.NextSibling(btn) if ns != 0 { dom.InsertBefore(msgListContainer, inputRow, ns) } else { dom.AppendChild(msgListContainer, inputRow) } } else { dom.AppendChild(msgListContainer, inputRow) } } func openThread(peer string) { msgCurrentPeer = peer msgView = "thread" if !navPop { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) dom.PushState("/msg/" + npub) } // Hide list, show thread. dom.SetStyle(msgListContainer, "display", "none") dom.SetStyle(msgThreadContainer, "display", "flex") // Build thread UI. clearChildren(msgThreadContainer) // Header: back + avatar + name. hdr := dom.CreateElement("div") dom.SetStyle(hdr, "display", "flex") dom.SetStyle(hdr, "alignItems", "center") dom.SetStyle(hdr, "gap", "10px") dom.SetStyle(hdr, "padding", "12px 16px") dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)") dom.SetStyle(hdr, "flexShrink", "0") backBtn := dom.CreateElement("button") dom.SetInnerHTML(backBtn, "←") // ← dom.SetStyle(backBtn, "background", "none") dom.SetStyle(backBtn, "border", "none") dom.SetStyle(backBtn, "fontSize", "20px") dom.SetStyle(backBtn, "cursor", "pointer") dom.SetStyle(backBtn, "color", "var(--fg)") dom.SetStyle(backBtn, "padding", "0") dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() { closeThread() })) dom.AppendChild(hdr, backBtn) // Thread header avatar + name — uses same img-then-span structure // as note headers so pendingNotes/updateNoteHeader can update them. threadHdrInner := dom.CreateElement("div") av := dom.CreateElement("img") dom.SetAttribute(av, "width", "28") dom.SetAttribute(av, "height", "28") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := authorPics[peer]; ok && pic != "" { dom.SetAttribute(av, "src", pic) } else { dom.SetStyle(av, "display", "none") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(threadHdrInner, av) nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "15px") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") if name, ok := authorNames[peer]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:]) } } dom.AppendChild(threadHdrInner, nameSpan) dom.SetStyle(threadHdrInner, "display", "flex") dom.SetStyle(threadHdrInner, "alignItems", "center") dom.SetStyle(threadHdrInner, "gap", "10px") dom.AppendChild(hdr, threadHdrInner) ratchetBtn := dom.CreateElement("button") dom.SetTextContent(ratchetBtn, "ratchet") dom.SetStyle(ratchetBtn, "marginLeft", "auto") dom.SetStyle(ratchetBtn, "background", "none") dom.SetStyle(ratchetBtn, "border", "1px solid var(--border)") dom.SetStyle(ratchetBtn, "borderRadius", "4px") dom.SetStyle(ratchetBtn, "color", "var(--fg)") dom.SetStyle(ratchetBtn, "cursor", "pointer") dom.SetStyle(ratchetBtn, "fontSize", "11px") dom.SetStyle(ratchetBtn, "padding", "4px 8px") dom.SetStyle(ratchetBtn, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() { if dom.Confirm("Delete all messages and rotate encryption keys?") { dom.PostToSW("[\"MLS_RATCHET\"," + jstr(peer) + "]") clearChildren(msgThreadMessages) } })) dom.AppendChild(hdr, ratchetBtn) dom.AppendChild(msgThreadContainer, hdr) // Track for live update when profile arrives. if _, cached := authorNames[peer]; !cached { pendingNotes[peer] = append(pendingNotes[peer], threadHdrInner) } // Message area. msgThreadMessages = dom.CreateElement("div") dom.SetStyle(msgThreadMessages, "flex", "1") dom.SetStyle(msgThreadMessages, "overflowY", "auto") dom.SetStyle(msgThreadMessages, "padding", "12px 16px") dom.AppendChild(msgThreadContainer, msgThreadMessages) // Compose area. compose := dom.CreateElement("div") dom.SetStyle(compose, "display", "flex") dom.SetStyle(compose, "gap", "8px") dom.SetStyle(compose, "padding", "8px 16px") dom.SetStyle(compose, "borderTop", "1px solid var(--border)") dom.SetStyle(compose, "flexShrink", "0") msgComposeInput = dom.CreateElement("textarea") dom.SetAttribute(msgComposeInput, "rows", "1") dom.SetAttribute(msgComposeInput, "placeholder", "message...") dom.SetStyle(msgComposeInput, "flex", "1") dom.SetStyle(msgComposeInput, "padding", "8px") dom.SetStyle(msgComposeInput, "fontFamily", "'Fira Code', monospace") dom.SetStyle(msgComposeInput, "fontSize", "13px") dom.SetStyle(msgComposeInput, "background", "var(--bg)") dom.SetStyle(msgComposeInput, "border", "1px solid var(--border)") dom.SetStyle(msgComposeInput, "borderRadius", "4px") dom.SetStyle(msgComposeInput, "color", "var(--fg)") dom.SetStyle(msgComposeInput, "resize", "none") dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}") dom.AppendChild(compose, msgComposeInput) sendBtn := dom.CreateElement("button") dom.SetTextContent(sendBtn, "send") dom.SetStyle(sendBtn, "padding", "8px 16px") dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(sendBtn, "fontSize", "13px") dom.SetStyle(sendBtn, "background", "var(--accent)") dom.SetStyle(sendBtn, "color", "#000") dom.SetStyle(sendBtn, "border", "none") dom.SetStyle(sendBtn, "borderRadius", "4px") dom.SetStyle(sendBtn, "cursor", "pointer") dom.SetStyle(sendBtn, "alignSelf", "flex-end") dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() { sendMessage() })) dom.AppendChild(compose, sendBtn) dom.AppendChild(msgThreadContainer, compose) // Fetch profile if needed. if !fetchedK0[peer] { queueProfileFetch(peer) } // Request history. dom.PostToSW("[\"DM_HISTORY\"," + jstr(peer) + ",50,0]") } func closeThread() { msgCurrentPeer = "" msgView = "list" dom.SetStyle(msgThreadContainer, "display", "none") dom.SetStyle(msgListContainer, "display", "block") if !navPop { dom.PushState("/msg") } // Refresh list. dom.PostToSW("[\"DM_LIST\"]") } func renderThreadMessages(peer, msgsJSON string) { if peer != msgCurrentPeer { return } if msgsJSON == "" || msgsJSON == "[]" { return } // Parse messages array — each element is a DMRecord object. // IDB returns newest-first; collect then reverse for oldest-at-top. type dmMsg struct { from string content string ts int64 } var msgs []dmMsg i := 0 for i < len(msgsJSON) && msgsJSON[i] != '[' { i++ } i++ for i < len(msgsJSON) { for i < len(msgsJSON) && msgsJSON[i] != '{' { if msgsJSON[i] == ']' { goto done } i++ } if i >= len(msgsJSON) { break } objStart := i depth := 0 for i < len(msgsJSON) { if msgsJSON[i] == '{' { depth++ } else if msgsJSON[i] == '}' { depth-- if depth == 0 { i++ break } } else if msgsJSON[i] == '"' { i++ for i < len(msgsJSON) && msgsJSON[i] != '"' { if msgsJSON[i] == '\\' { i++ } i++ } } i++ } obj := msgsJSON[objStart:i] from := helpers.JsonGetString(obj, "from") content := helpers.JsonGetString(obj, "content") ts := jsonGetNum(obj, "created_at") msgs = append(msgs, dmMsg{from, content, ts}) } done: // Reverse for oldest-first. for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 { msgs[l], msgs[r] = msgs[r], msgs[l] } clearChildren(msgThreadMessages) for _, m := range msgs { appendBubble(m.from, m.content, m.ts) } scrollToBottom() } func appendBubble(from, content string, ts int64) { isSent := from == pubhex wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "marginBottom", "6px") if isSent { dom.SetStyle(wrap, "justifyContent", "flex-end") } bubble := dom.CreateElement("div") dom.SetStyle(bubble, "maxWidth", "75%") dom.SetStyle(bubble, "padding", "8px 12px") dom.SetStyle(bubble, "borderRadius", "12px") dom.SetStyle(bubble, "fontSize", "14px") dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(bubble, "lineHeight", "1.4") dom.SetStyle(bubble, "wordBreak", "break-word") if isSent { dom.SetStyle(bubble, "background", "var(--accent)") dom.SetStyle(bubble, "color", "#000") } else { dom.SetStyle(bubble, "background", "var(--bg2)") dom.SetStyle(bubble, "color", "var(--fg)") } dom.SetInnerHTML(bubble, renderMarkdown(content)) // Timestamp below bubble. tsEl := dom.CreateElement("div") dom.SetStyle(tsEl, "fontSize", "10px") dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "marginTop", "2px") if isSent { dom.SetStyle(tsEl, "textAlign", "right") } dom.SetTextContent(tsEl, formatTime(ts)) if isSent && ts == 0 { pendingTsEls = append(pendingTsEls, tsEl) } outer := dom.CreateElement("div") dom.AppendChild(outer, bubble) dom.AppendChild(outer, tsEl) // Email quote-reply button for received messages with email headers. if !isSent { emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content) if isEmail { replyBtn := dom.CreateElement("div") dom.SetStyle(replyBtn, "fontSize", "11px") dom.SetStyle(replyBtn, "color", "var(--accent)") dom.SetStyle(replyBtn, "cursor", "pointer") dom.SetStyle(replyBtn, "marginTop", "2px") dom.SetTextContent(replyBtn, "\u21a9 Reply") dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() { quoted := quoteReply(emailFrom, emailSubject, emailBody) dom.SetProperty(msgComposeInput, "value", quoted) })) dom.AppendChild(outer, replyBtn) } } dom.AppendChild(wrap, outer) dom.AppendChild(msgThreadMessages, wrap) } func appendSystemBubble(text string) { wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "justifyContent", "center") dom.SetStyle(wrap, "marginBottom", "6px") bubble := dom.CreateElement("div") dom.SetStyle(bubble, "maxWidth", "85%") dom.SetStyle(bubble, "padding", "8px 12px") dom.SetStyle(bubble, "borderRadius", "8px") dom.SetStyle(bubble, "fontSize", "12px") dom.SetStyle(bubble, "fontFamily", "monospace") dom.SetStyle(bubble, "lineHeight", "1.5") dom.SetStyle(bubble, "whiteSpace", "pre-wrap") dom.SetStyle(bubble, "background", "var(--bg2)") dom.SetStyle(bubble, "color", "var(--muted)") dom.SetStyle(bubble, "border", "1px solid var(--muted)") dom.SetTextContent(bubble, text) dom.AppendChild(wrap, bubble) dom.AppendChild(msgThreadMessages, wrap) scrollToBottom() } func scrollToBottom() { dom.SetProperty(msgThreadMessages, "scrollTop", "999999") } func sendMessage() { content := dom.GetProperty(msgComposeInput, "value") if content == "" || msgCurrentPeer == "" { return } // Clear input. dom.SetProperty(msgComposeInput, "value", "") dom.PostToSW("[\"MLS_SEND\"," + jstr(msgCurrentPeer) + "," + jstr(content) + "]") // Optimistic render (ts=0 — timestamp not shown for "just sent"). appendBubble(pubhex, content, 0) scrollToBottom() } func handleDMReceived(dmJSON string) { peer := helpers.JsonGetString(dmJSON, "peer") from := helpers.JsonGetString(dmJSON, "from") content := helpers.JsonGetString(dmJSON, "content") ts := jsonGetNum(dmJSON, "created_at") if msgView == "thread" && peer == msgCurrentPeer { // Don't double-render our own sent messages (already optimistic). if from == pubhex { return } appendBubble(from, content, ts) scrollToBottom() } else if msgView == "list" { // Refresh conversation list. dom.PostToSW("[\"DM_LIST\"]") } } // --- Logout --- func doLogout() { // Tell SW to clean up. dom.PostToSW("[\"CLOSE\",\"prof\"]") dom.PostToSW("[\"CLOSE\",\"feed\"]") dom.PostToSW("[\"CLEAR_KEY\"]") pubkey = nil pubhex = "" profileName = "" profilePic = "" profileTs = 0 eventCount = 0 popoverOpen = false marmotInited = false msgCurrentPeer = "" msgView = "list" // Reset relay tracking. relayURLs = nil relayDots = nil relayLabels = nil relayUserPick = nil localstorage.RemoveItem(lsKeyPubkey) clearChildren(root) showLogin() } // --- Email header parsing for quote-reply --- // parseEmailHeaders checks if content looks like a forwarded email and extracts // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found. func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) { lines := splitLines(content) headerEnd := -1 for i, line := range lines { if line == "" { headerEnd = i break } if hasPrefix(line, "From: ") { from = line[6:] } else if hasPrefix(line, "Subject: ") { subject = line[9:] } else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") { // Known header, continue } else if i == 0 { return "", "", "", false } } if from == "" && subject == "" { return "", "", "", false } if headerEnd >= 0 && headerEnd+1 < len(lines) { body = joinLines(lines[headerEnd+1:]) } return from, subject, body, true } func quoteReply(from, subject, body string) string { out := "To: " + from + "\n" if subject != "" { if !hasPrefix(subject, "Re: ") { subject = "Re: " + subject } out += "Subject: " + subject + "\n" } out += "\n\n" if body != "" { lines := splitLines(body) for _, line := range lines { out += "> " + line + "\n" } } return out } func splitLines(s string) []string { var lines []string for { idx := strIndex(s, "\n") if idx < 0 { lines = append(lines, s) return lines } lines = append(lines, s[:idx]) s = s[idx+1:] } } func joinLines(lines []string) string { out := "" for i, line := range lines { if i > 0 { out += "\n" } out += line } return out } func hasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix } // --- Markdown rendering --- // All functions use string concatenation and indexOf — no byte-level ops. // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji. // renderMarkdown converts note text to safe HTML. func renderMarkdown(s string) string { s = strReplace(s, "&", "&") s = strReplace(s, "<", "<") s = strReplace(s, ">", ">") s = strReplace(s, "\"", """) s = wrapDelimited(s, "`", "", "") s = wrapDelimited(s, "**", "", "") s = wrapDelimited(s, "*", "", "") s = autoLinkURLs(s) s = strReplace(s, "\n", "
") return s } // strReplace replaces all occurrences of old with new using indexOf. func strReplace(s, old, nw string) string { out := "" for { idx := strIndex(s, old) if idx < 0 { return out + s } out += s[:idx] + nw s = s[idx+len(old):] } } // wrapDelimited finds matching pairs of delim and wraps content in open/close tags. func wrapDelimited(s, delim, open, close string) string { out := "" for { start := strIndex(s, delim) if start < 0 { return out + s } end := strIndex(s[start+len(delim):], delim) if end < 0 { return out + s } end += start + len(delim) inner := s[start+len(delim) : end] if len(inner) == 0 { out += s[:start+len(delim)] s = s[start+len(delim):] continue } out += s[:start] + open + inner + close s = s[end+len(delim):] } } func autoLinkURLs(s string) string { out := "" for { hi := strIndex(s, "https://") lo := strIndex(s, "http://") idx := -1 if hi >= 0 && (lo < 0 || hi <= lo) { idx = hi } else if lo >= 0 { idx = lo } if idx < 0 { return out + s } out += s[:idx] s = s[idx:] // Find end of URL. end := 0 for end < len(s) { c := s[end : end+1] if c == " " || c == "\n" || c == "\r" || c == "\t" || c == "<" || c == ">" { break } end++ } // Trim trailing punctuation. for end > 0 { c := s[end-1 : end] if c == "." || c == "," || c == ")" || c == ";" { end-- } else { break } } url := s[:end] if isImageURL(url) { out += "" } else { out += "" + url + "" } s = s[end:] } } func isImageURL(url string) bool { u := toLower(url) return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") || hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg") } func hasSuffix(s, suffix string) bool { return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix } // jsonGetNum extracts a numeric value for a given key from a JSON object. func jsonGetNum(s, key string) int64 { needle := "\"" + key + "\":" idx := strIndex(s, needle) if idx < 0 { return 0 } idx += len(needle) // Skip whitespace. for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') { idx++ } if idx >= len(s) { return 0 } var n int64 for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' { n = n*10 + int64(s[idx]-'0') idx++ } return n } // jsonEsc escapes a string for embedding in a JSON value. func jsonEsc(s string) string { s = strReplace(s, "\\", "\\\\") s = strReplace(s, "\"", "\\\"") s = strReplace(s, "\n", "\\n") s = strReplace(s, "\r", "\\r") s = strReplace(s, "\t", "\\t") return s } // strIndex finds substring in string. Returns -1 if not found. func strIndex(s, sub string) int { sl := len(sub) for i := 0; i <= len(s)-sl; i++ { if s[i:i+sl] == sub { return i } } return -1 } // --- Helpers --- // normalizeURL strips trailing slashes and lowercases the scheme+host. func normalizeURL(u string) string { for len(u) > 0 && u[len(u)-1] == '/' { u = u[:len(u)-1] } // Lowercase scheme and host (before first / after ://). if len(u) > 6 && u[:6] == "wss://" { rest := u[6:] slash := strIndex(rest, "/") if slash < 0 { return u[:6] + toLower(rest) } return u[:6] + toLower(rest[:slash]) + rest[slash:] } if len(u) > 5 && u[:5] == "ws://" { rest := u[5:] slash := strIndex(rest, "/") if slash < 0 { return u[:5] + toLower(rest) } return u[:5] + toLower(rest[:slash]) + rest[slash:] } return u } func toLower(s string) string { b := make([]byte, len(s)) for i := 0; i < len(s); i++ { c := s[i] if c >= 'A' && c <= 'Z' { c += 32 } b[i] = c } return string(b) } func showQRModal(npubStr string) { svg := qrSVG(npubStr, 280, logoSVGCache) if svg == "" { return } scrim := dom.CreateElement("div") dom.SetStyle(scrim, "position", "fixed") dom.SetStyle(scrim, "inset", "0") dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)") dom.SetStyle(scrim, "display", "flex") dom.SetStyle(scrim, "alignItems", "center") dom.SetStyle(scrim, "justifyContent", "center") dom.SetStyle(scrim, "zIndex", "9999") dom.SetStyle(scrim, "cursor", "pointer") dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() { dom.RemoveChild(dom.Body(), scrim) })) card := dom.CreateElement("div") dom.SetStyle(card, "background", "white") dom.SetStyle(card, "borderRadius", "16px") dom.SetStyle(card, "padding", "24px") dom.SetStyle(card, "display", "flex") dom.SetStyle(card, "flexDirection", "column") dom.SetStyle(card, "alignItems", "center") dom.SetStyle(card, "gap", "12px") dom.SetStyle(card, "cursor", "default") dom.SetAttribute(card, "onclick", "event.stopPropagation()") dom.SetInnerHTML(card, svg) label := dom.CreateElement("div") dom.SetStyle(label, "fontSize", "11px") dom.SetStyle(label, "color", "#666") dom.SetStyle(label, "wordBreak", "break-all") dom.SetStyle(label, "textAlign", "center") dom.SetStyle(label, "maxWidth", "280px") dom.SetStyle(label, "fontFamily", "'Fira Code', monospace") dom.SetTextContent(label, npubStr) dom.AppendChild(card, label) dom.AppendChild(scrim, card) dom.AppendChild(dom.Body(), scrim) } func clearChildren(el dom.Element) { dom.SetInnerHTML(el, "") } func itoa(n int) string { if n == 0 { return "0" } neg := false if n < 0 { neg = true n = -n } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) }