OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore commits / fa763d2

Fix memory leaks and improve peer discovery in wirecore
- Remove auto-refresh polling in browser (UX improvement) - Fix memory leak: properly clear setInterval when unfollowing users - Fix race condition: await discovery.flushed() after swarm.join() - Add updateInterval to follows Map for proper cleanup Memory leak fix: - Store interval IDs in follows Map - Clear intervals in cmdUnfollow() and /api/unfollow endpoint - Prevents accumulation of zombie intervals over time Race condition fix: - Change swarm.join() to await discovery.flushed() - Ensures DHT announcement completes before replication - Improves peer discovery reliability Staged: pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo v4164 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
fa763d2a3318a6906ea84f325a6e29383204772d
by plankatron <fleeky@gmail.com> · 2026-01-22T13:52:21.000Z

Changed files (2) +884 -35

pear-app/index.js +112 -35
……@@ -178,19 +178,34 @@ async function loadFollows() {
178178 await feed.ready()
179179
180180 if (swarm) {
181 swarm.join(feedKey, { server: false, client: true })
182 await swarm.flush()
181 const discovery = swarm.join(feedKey, { server: false, client: true })
182 await discovery.flushed()
183183 }
184184
185 // Listen for new posts from this followed feed
186 feed.on('append', () => {
187 broadcast({ type: 'new_post', feed: 'followed', publicKey, name })
185 // Listen for new posts from followed feeds
186 feed.on('download', (index) => {
187 console.log(`📥 Downloaded block ${index} from ${name || publicKey.substring(0, 16)}`)
188 broadcast({ type: 'new_post', feed: 'followed', publicKey, name: name || publicKey.substring(0, 16) })
188189 })
189190
191 // Continuously check for new blocks (every 5 seconds)
192 const updateInterval = setInterval(async () => {
193 try {
194 const oldLength = feed.length
195 await feed.update()
196 if (feed.length > oldLength) {
197 console.log(`🔄 Feed updated for ${name}, new length: ${feed.length}`)
198 }
199 } catch (err) {
200 console.error(`Failed to update feed for ${name}:`, err.message)
201 }
202 }, 5000)
203
190204 follows.set(b4a.toString(feedKey, 'hex'), {
191205 feed,
192206 publicKey,
193 name: name || publicKey.substring(0, 16)
207 name: name || publicKey.substring(0, 16),
208 updateInterval
194209 })
195210 }
196211}
……@@ -368,6 +383,25 @@ async function cmdFollow(publicKeyHex, name) {
368383 const feed = store.get(pubKeyBuf)
369384 await feed.ready()
370385
386 // Listen for new posts from this feed
387 feed.on('download', (index) => {
388 console.log(`📥 Downloaded block ${index} from ${name || publicKeyHex.substring(0, 16)}`)
389 broadcast({ type: 'new_post', feed: 'followed', publicKey: publicKeyHex, name: name || publicKeyHex.substring(0, 16) })
390 })
391
392 // Continuously check for new blocks (every 5 seconds)
393 setInterval(async () => {
394 try {
395 const oldLength = feed.length
396 await feed.update()
397 if (feed.length > oldLength) {
398 console.log(`🔄 Feed updated for ${name}, new length: ${feed.length}`)
399 }
400 } catch (err) {
401 console.error(`Failed to update feed for ${name}:`, err.message)
402 }
403 }, 5000)
404
371405 follows.set(discoveryKeyHex, {
372406 feed,
373407 publicKey: publicKeyHex,
……@@ -399,6 +433,14 @@ async function cmdUnfollow(publicKeyHex) {
399433 return
400434 }
401435
436 const followData = follows.get(discoveryKeyHex)
437
438 // Clear the update interval to prevent memory leak
439 if (followData && followData.updateInterval) {
440 clearInterval(followData.updateInterval)
441 console.log(`🧹 Cleared update interval for ${followData.name}`)
442 }
443
402444 follows.delete(discoveryKeyHex)
403445 await saveFollows()
404446
……@@ -923,22 +965,18 @@ async function startWebServer(port = 3003) {
923965 // Create WebSocket server
924966 wss = new WebSocketServer({ server })
925967
926 wss.on('error', (err) => {
927 console.error('❌ WebSocket server error:', err.message, err.stack)
928 })
929
930 wss.on('connection', (ws, req) => {
931 wsClients.add(ws)
932 console.log('🔌 Browser connected from:', req.headers.host, 'total:', wsClients.size)
968 wss.on('connection', (socket) => {
969 wsClients.add(socket)
970 console.log('🔌 Browser connected, total:', wsClients.size)
933971
934 ws.on('close', (code, reason) => {
935 wsClients.delete(ws)
936 console.log('👋 Browser disconnected, code:', code, 'reason:', reason || '<none>', 'total:', wsClients.size)
972 socket.on('close', () => {
973 wsClients.delete(socket)
974 console.log('👋 Browser disconnected, total:', wsClients.size)
937975 })
938976
939 ws.on('error', (err) => {
940 console.error('❌ WebSocket connection error:', err.message, err.code || '')
941 wsClients.delete(ws)
977 socket.on('error', (err) => {
978 console.error('WebSocket error:', err.message)
979 wsClients.delete(socket)
942980 })
943981 })
944982
……@@ -971,15 +1009,24 @@ async function startWebServer(port = 3003) {
9711009// Broadcast message to all connected browsers
9721010function broadcast(message) {
9731011 const data = JSON.stringify(message)
974 for (const client of wsClients) {
975 if (client.readyState === 1) { // OPEN
1012 const buffer = Buffer.from(data, 'utf8')
1013 console.log(`[BROADCAST] Sending to ${wsClients.size} clients:`, message)
1014 let sent = 0
1015 for (const socket of wsClients) {
1016 console.log(`[BROADCAST] Socket state: writable=${socket.writable}, destroyed=${socket.destroyed}, readable=${socket.readable}`)
1017 if (socket.writable && !socket.destroyed) {
9761018 try {
977 client.send(data)
1019 const result = socket.write(buffer)
1020 console.log(`[BROADCAST] Write returned: ${result}, buffer length: ${buffer.length}`)
1021 sent++
9781022 } catch (err) {
979 console.error('Failed to send to client:', err.message)
1023 console.error('Failed to send to client:', err.message, err.stack)
9801024 }
1025 } else {
1026 console.log('[BROADCAST] Socket not writable or destroyed, skipping')
9811027 }
9821028 }
1029 console.log(`[BROADCAST] Successfully sent to ${sent} clients`)
9831030}
9841031
9851032// Handle API requests
……@@ -1015,9 +1062,12 @@ async function handleAPI(req, res, url) {
10151062 const profileNode = await metaBee.get('profile')
10161063 const profile = (profileNode && profileNode.value) || {}
10171064
1018 const pubkey = anprotoKeypair
1019 ? b4a.toString(anprotoKeypair.publicKey, 'base64')
1020 : b4a.toString(identity.identityPublicKey, 'hex')
1065 let pubkey
1066 if (anprotoKeypair) {
1067 pubkey = b4a.toString(anprotoKeypair.publicKey, 'base64')
1068 } else if (identity) {
1069 pubkey = b4a.toString(identity.identityPublicKey, 'hex')
1070 }
10211071
10221072 res.writeHead(200, { 'Content-Type': 'application/json' })
10231073 res.end(JSON.stringify({
……@@ -1099,6 +1149,14 @@ async function handleAPI(req, res, url) {
10991149 const feedKey = crypto.discoveryKey(pubKeyBuf)
11001150 const discoveryKeyHex = b4a.toString(feedKey, 'hex')
11011151
1152 const followData = follows.get(discoveryKeyHex)
1153
1154 // Clear the update interval to prevent memory leak
1155 if (followData && followData.updateInterval) {
1156 clearInterval(followData.updateInterval)
1157 console.log(`🧹 Cleared update interval for ${followData.name}`)
1158 }
1159
11021160 follows.delete(discoveryKeyHex)
11031161 await saveFollows()
11041162
……@@ -1300,14 +1358,17 @@ async function getTimelineMessages(limit) {
13001358 // Use username from post if available, otherwise "You"
13011359 const displayName = entry.username || 'You'
13021360
1303 const ownPublicKey = anprotoKeypair
1304 ? b4a.toString(anprotoKeypair.publicKey, 'base64')
1305 : b4a.toString(identity.identityPublicKey, 'hex')
1361 let pubkey
1362 if (anprotoKeypair) {
1363 pubkey = b4a.toString(anprotoKeypair.publicKey, 'base64')
1364 } else if (identity) {
1365 pubkey = b4a.toString(identity.identityPublicKey, 'hex')
1366 }
13061367
13071368 messages.push({
13081369 ...entry,
13091370 author: displayName,
1310 publicKey: ownPublicKey,
1371 publicKey: pubkey,
13111372 isOwn: true
13121373 })
13131374 } catch (err) {
……@@ -1373,20 +1434,36 @@ async function followUser(publicKeyInput, name) {
13731434 throw new Error('Already following this user')
13741435 }
13751436
1376 swarm.join(feedKey, { server: false, client: true })
1437 const discovery = swarm.join(feedKey, { server: false, client: true })
1438 await discovery.flushed()
13771439
13781440 const feed = store.get(pubKeyBuf)
13791441 await feed.ready()
13801442
1381 // Listen for new posts from this followed feed
1382 feed.on('append', () => {
1383 broadcast({ type: 'new_post', feed: 'followed', publicKey: publicKeyHex, name })
1443 // Listen for new posts from followed feed
1444 feed.on('download', (index) => {
1445 console.log(`📥 Downloaded block ${index} from ${name || publicKeyHex.substring(0, 16)}`)
1446 broadcast({ type: 'new_post', feed: 'followed', publicKey: publicKeyHex, name: name || publicKeyHex.substring(0, 16) })
13841447 })
13851448
1449 // Continuously check for new blocks (every 5 seconds)
1450 const updateInterval = setInterval(async () => {
1451 try {
1452 const oldLength = feed.length
1453 await feed.update()
1454 if (feed.length > oldLength) {
1455 console.log(`🔄 Feed updated for ${name || publicKeyHex.substring(0, 16)}, new length: ${feed.length}`)
1456 }
1457 } catch (err) {
1458 console.error(`Failed to update feed for ${name || publicKeyHex.substring(0, 16)}:`, err.message)
1459 }
1460 }, 5000)
1461
13861462 follows.set(discoveryKeyHex, {
13871463 feed,
13881464 publicKey: publicKeyHex,
1389 name: name || publicKeyHex.substring(0, 16)
1465 name: name || publicKeyHex.substring(0, 16),
1466 updateInterval
13901467 })
13911468
13921469 await saveFollows()
pear-app/public/app-simple.js +772 -0
……@@ -0,0 +1,772 @@
1/**
2 * Wirecore Pear App - Simple Web Client
3 *
4 * This is a simplified browser interface that connects to the Pear app's HTTP API.
5 * All P2P networking is handled by the Pear app - the browser is just a dumb UI.
6 */
7
8// State
9let currentUser = null
10let profile = null
11let timeline = []
12let ws = null
13
14// Initialize app
15async function init() {
16 console.log('Initializing Wirecore web client...')
17
18 // Setup event listeners FIRST, so buttons work even if API calls fail
19 setupEventListeners()
20
21 // Load current user from API (don't let errors stop initialization)
22 try {
23 await loadCurrentUser()
24 } catch (err) {
25 console.error('Failed to load user during init:', err)
26 }
27
28 // Load timeline (don't let errors stop initialization)
29 try {
30 await loadTimeline()
31 } catch (err) {
32 console.error('Failed to load timeline during init:', err)
33 }
34
35 // Connect WebSocket for real-time updates
36 try {
37 connectWebSocket()
38 } catch (err) {
39 console.error('Failed to connect WebSocket:', err)
40 }
41
42 console.log('Wirecore initialized!')
43}
44
45// Load current user from Pear app
46async function loadCurrentUser() {
47 try {
48 const res = await fetch('/api/whoami')
49 const data = await res.json()
50
51 currentUser = {
52 pubkey: data.pubkey,
53 feedKey: data.feedKey
54 }
55
56 // Load profile (username)
57 const profileRes = await fetch('/api/profile')
58 profile = await profileRes.json()
59
60 // Update navbar
61 const pubkeyDisplay = document.getElementById('pubkey-display')
62 if (pubkeyDisplay) {
63 const displayText = profile.username || data.pubkey.substring(0, 8) + '...'
64 pubkeyDisplay.textContent = displayText
65 pubkeyDisplay.title = profile.username ? `${profile.username} (${data.pubkey})` : data.pubkey
66 }
67
68 console.log('Current user:', profile.username || data.pubkey.substring(0, 16))
69 } catch (err) {
70 console.error('Failed to load user:', err)
71 showToast('Failed to load identity', 'error')
72 }
73}
74
75// Load timeline from API
76async function loadTimeline() {
77 try {
78 console.log('[loadTimeline] Fetching /api/timeline...')
79 const res = await fetch('/api/timeline')
80 timeline = await res.json()
81 console.log('[loadTimeline] Received', timeline.length, 'messages')
82 renderTimeline()
83 console.log('[loadTimeline] Rendered timeline')
84 } catch (err) {
85 console.error('Failed to load timeline:', err)
86 showToast('Failed to load timeline', 'error')
87 }
88}
89
90// Render timeline
91function renderTimeline() {
92 const container = document.getElementById('timeline')
93 if (!container) return
94
95 if (timeline.length === 0) {
96 container.innerHTML = '<div class="no-messages">No messages yet. Start posting!</div>'
97 return
98 }
99
100 container.innerHTML = timeline.map(msg => {
101 const time = new Date(msg.timestamp).toLocaleString()
102 const isOwn = msg.isOwn
103
104 // Add image if present
105 let imageHtml = ''
106 if (msg.imageData) {
107 // New format: inline base64
108 const dataUrl = `data:${msg.imageMimeType || 'image/jpeg'};base64,${msg.imageData}`
109 imageHtml = `<div class="message-image"><img src="${dataUrl}" alt="Attached image" /></div>`
110 } else if (msg.image) {
111 // Old format: path-based (backward compatibility)
112 imageHtml = `<div class="message-image"><img src="/api/image${msg.image}" alt="Attached image" /></div>`
113 }
114
115 return `
116 <div class="message ${isOwn ? 'own-message' : ''}">
117 <div class="message-header">
118 <div class="author">${escapeHtml(msg.author || 'Anonymous')}</div>
119 <div class="timestamp">${time}</div>
120 </div>
121 <div class="message-text">${escapeHtml(msg.text)}</div>
122 ${imageHtml}
123 </div>
124 `
125 }).join('')
126}
127
128// Connect WebSocket for real-time updates
129function connectWebSocket() {
130 const wsUrl = `ws://${window.location.host}`
131
132 console.log('Connecting to WebSocket:', wsUrl)
133
134 ws = new WebSocket(wsUrl)
135
136 ws.onopen = () => {
137 console.log('✅ WebSocket connected')
138 }
139
140 ws.onmessage = (event) => {
141 console.log('[onmessage] FIRED! event:', event)
142 console.log('[onmessage] event.data type:', typeof event.data)
143 console.log('[onmessage] event.data:', event.data)
144 try {
145 const data = JSON.parse(event.data)
146 console.log('WebSocket message:', data)
147
148 if (data.type === 'new_post') {
149 // Reload timeline when new post arrives
150 console.log('New post detected, refreshing timeline...')
151 loadTimeline()
152 } else if (data.type === 'new_follow') {
153 console.log('New follow:', data.name)
154 showToast(`Now following ${data.name}`, 'success')
155 }
156 } catch (err) {
157 console.error('WebSocket message error:', err, err.stack)
158 }
159 }
160
161 ws.onclose = (event) => {
162 console.log('WebSocket disconnected:', event.code, event.reason)
163 // Reconnect after 5 seconds
164 setTimeout(connectWebSocket, 5000)
165 }
166
167 ws.onerror = (err) => {
168 console.error('WebSocket error:', err)
169 }
170}
171
172// Post a message
173async function postMessage(text, imageData = null, imageMimeType = null) {
174 if (!text || !text.trim()) {
175 showToast('Message cannot be empty', 'error')
176 return
177 }
178
179 try {
180 // Post message with optional inline image (base64)
181 const res = await fetch('/api/post', {
182 method: 'POST',
183 headers: { 'Content-Type': 'application/json' },
184 body: JSON.stringify({
185 message: text,
186 imageData: imageData, // base64 string
187 imageMimeType: imageMimeType
188 })
189 })
190
191 if (!res.ok) throw new Error('Failed to post')
192
193 showToast('Posted!', 'success')
194
195 // Reload timeline
196 setTimeout(() => loadTimeline(), 500)
197
198 } catch (err) {
199 console.error('Failed to post:', err)
200 showToast('Failed to post message', 'error')
201 }
202}
203
204// Follow a user
205async function followUser(publicKey, name) {
206 try {
207 const res = await fetch('/api/follow', {
208 method: 'POST',
209 headers: { 'Content-Type': 'application/json' },
210 body: JSON.stringify({ publicKey, name: name || publicKey.substring(0, 16) })
211 })
212
213 if (!res.ok) throw new Error('Failed to follow')
214
215 showToast(`Now following ${name || publicKey.substring(0, 16)}`, 'success')
216
217 } catch (err) {
218 console.error('Failed to follow:', err)
219 showToast(err.message || 'Failed to follow user', 'error')
220 }
221}
222
223// Show compose modal
224function showComposeModal() {
225 const modal = document.createElement('div')
226 modal.className = 'modal-overlay'
227 modal.innerHTML = `
228 <div class="modal">
229 <h2>Compose Post</h2>
230 <textarea id="compose-text" placeholder="What's on your mind?" rows="5"></textarea>
231 <div id="image-preview" style="display: none; margin: 10px 0;">
232 <img id="preview-img" style="max-width: 100%; max-height: 200px; border-radius: 4px;" />
233 <button id="remove-image" style="margin-top: 5px;">Remove Image</button>
234 </div>
235 <div class="modal-actions">
236 <input type="file" id="image-input" accept="image/*" style="display: none;" />
237 <button id="attach-image">📎 Attach Image</button>
238 <div style="flex: 1;"></div>
239 <button id="compose-cancel">Cancel</button>
240 <button id="compose-submit" class="primary">Post</button>
241 </div>
242 </div>
243 `
244
245 document.body.appendChild(modal)
246
247 const textarea = modal.querySelector('#compose-text')
248 const imageInput = modal.querySelector('#image-input')
249 const imagePreview = modal.querySelector('#image-preview')
250 const previewImg = modal.querySelector('#preview-img')
251 let selectedImageData = null
252 let selectedImageType = null
253
254 textarea.focus()
255
256 modal.querySelector('#attach-image').onclick = () => {
257 imageInput.click()
258 }
259
260 imageInput.onchange = (e) => {
261 const file = e.target.files[0]
262 if (!file) return
263
264 const reader = new FileReader()
265 reader.onload = (event) => {
266 const dataUrl = event.target.result
267 previewImg.src = dataUrl
268 imagePreview.style.display = 'block'
269
270 // Store base64 data (remove data:image/jpeg;base64, prefix)
271 selectedImageData = dataUrl.split(',')[1]
272 selectedImageType = file.type
273 }
274 reader.readAsDataURL(file)
275 }
276
277 modal.querySelector('#remove-image').onclick = () => {
278 selectedImageData = null
279 selectedImageType = null
280 imagePreview.style.display = 'none'
281 imageInput.value = ''
282 }
283
284 modal.querySelector('#compose-cancel').onclick = () => {
285 document.body.removeChild(modal)
286 }
287
288 modal.querySelector('#compose-submit').onclick = async () => {
289 const text = textarea.value.trim()
290 if (text) {
291 await postMessage(text, selectedImageData, selectedImageType)
292 document.body.removeChild(modal)
293 }
294 }
295
296 // Close on background click
297 modal.onclick = (e) => {
298 if (e.target === modal) {
299 document.body.removeChild(modal)
300 }
301 }
302}
303
304// Show follow modal
305function showFollowModal() {
306 const modal = document.createElement('div')
307 modal.className = 'modal-overlay'
308 modal.innerHTML = `
309 <div class="modal">
310 <h2>Follow User</h2>
311 <input type="text" id="follow-pubkey" placeholder="Public key (hex)" />
312 <input type="text" id="follow-name" placeholder="Name (optional)" />
313 <div class="modal-actions">
314 <button id="follow-cancel">Cancel</button>
315 <button id="follow-submit" class="primary">Follow</button>
316 </div>
317 </div>
318 `
319
320 document.body.appendChild(modal)
321
322 const pubkeyInput = modal.querySelector('#follow-pubkey')
323 pubkeyInput.focus()
324
325 modal.querySelector('#follow-cancel').onclick = () => {
326 document.body.removeChild(modal)
327 }
328
329 modal.querySelector('#follow-submit').onclick = async () => {
330 const publicKey = pubkeyInput.value.trim()
331 const name = modal.querySelector('#follow-name').value.trim()
332
333 if (publicKey) {
334 await followUser(publicKey, name)
335 document.body.removeChild(modal)
336 }
337 }
338
339 // Close on background click
340 modal.onclick = (e) => {
341 if (e.target === modal) {
342 document.body.removeChild(modal)
343 }
344 }
345}
346
347// Show profile modal
348function showProfileModal() {
349 if (!currentUser) return
350
351 const currentUsername = profile && profile.username ? escapeHtml(profile.username) : ''
352 const usernameDisplay = currentUsername || '<em style="color: #999;">Not set</em>'
353
354 const modal = document.createElement('div')
355 modal.className = 'modal-overlay'
356 modal.innerHTML = `
357 <div class="modal profile-modal">
358 <h2>👤 Your Profile</h2>
359
360 <div class="profile-section">
361 <div class="profile-header">
362 <strong>Username</strong>
363 <button id="username-edit" class="edit-btn" title="Edit username">
364 <span class="material-symbols-outlined">Edit</span>
365 </button>
366 </div>
367 <div id="username-display" class="username-display">${usernameDisplay}</div>
368 <div id="username-edit-form" class="username-edit-form" style="display: none;">
369 <input type="text" id="username-input" placeholder="Enter your username"
370 value="${currentUsername}"
371 maxlength="32"
372 autocomplete="off"
373 autofocus />
374 <div class="edit-actions">
375 <button id="username-save" class="primary">Save</button>
376 <button id="username-cancel">Cancel</button>
377 </div>
378 <small style="color: #666; margin-top: 8px; display: block;">
379 Max 32 characters. This is how others will see you.
380 </small>
381 </div>
382 </div>
383
384 <div class="profile-section">
385 <strong>Public Key</strong>
386 <code class="pubkey-full" title="Click to copy">${currentUser.pubkey}</code>
387 <small style="color: #666; margin-top: 4px; display: block;">
388 Share this with others so they can follow you
389 </small>
390 </div>
391
392 <div class="profile-section">
393 <strong>Feed Key</strong>
394 <code class="pubkey-full" title="Click to copy">${currentUser.feedKey}</code>
395 </div>
396
397 <div class="modal-actions">
398 <button id="profile-close" class="primary">Close</button>
399 </div>
400 </div>
401 `
402
403 document.body.appendChild(modal)
404
405 const usernameDisplayEl = modal.querySelector('#username-display')
406 const usernameEditForm = modal.querySelector('#username-edit-form')
407 const usernameInput = modal.querySelector('#username-input')
408
409 // Edit button - show edit form
410 modal.querySelector('#username-edit').onclick = () => {
411 usernameDisplayEl.style.display = 'none'
412 usernameEditForm.style.display = 'block'
413 usernameInput.focus()
414 usernameInput.select()
415 }
416
417 // Cancel button - hide edit form
418 modal.querySelector('#username-cancel').onclick = () => {
419 usernameDisplayEl.style.display = 'block'
420 usernameEditForm.style.display = 'none'
421 usernameInput.value = currentUsername
422 }
423
424 // Save username
425 modal.querySelector('#username-save').onclick = async () => {
426 const username = usernameInput.value.trim()
427 if (!username) {
428 showToast('Username cannot be empty', 'error')
429 usernameInput.focus()
430 return
431 }
432
433 // Disable button while saving
434 const saveBtn = modal.querySelector('#username-save')
435 const originalText = saveBtn.textContent
436 saveBtn.textContent = 'Saving...'
437 saveBtn.disabled = true
438
439 try {
440 const res = await fetch('/api/profile', {
441 method: 'POST',
442 headers: { 'Content-Type': 'application/json' },
443 body: JSON.stringify({ username })
444 })
445
446 if (!res.ok) throw new Error('Failed to save username')
447
448 showToast('Username saved!', 'success')
449
450 // Reload profile
451 await loadCurrentUser()
452
453 // Update display
454 usernameDisplayEl.innerHTML = escapeHtml(username)
455 usernameDisplayEl.style.display = 'block'
456 usernameEditForm.style.display = 'none'
457
458 } catch (err) {
459 console.error('Failed to save username:', err)
460 showToast('Failed to save username', 'error')
461 saveBtn.textContent = originalText
462 saveBtn.disabled = false
463 }
464 }
465
466 // Enter key to save
467 usernameInput.onkeydown = (e) => {
468 if (e.key === 'Enter') {
469 modal.querySelector('#username-save').click()
470 } else if (e.key === 'Escape') {
471 modal.querySelector('#username-cancel').click()
472 }
473 }
474
475 // Copy to clipboard on click
476 modal.querySelectorAll('.pubkey-full').forEach(el => {
477 el.onclick = () => {
478 navigator.clipboard.writeText(el.textContent)
479 showToast('Copied to clipboard!', 'success')
480 }
481 el.style.cursor = 'pointer'
482 })
483
484 modal.querySelector('#profile-close').onclick = () => {
485 document.body.removeChild(modal)
486 }
487
488 // Close on background click
489 modal.onclick = (e) => {
490 if (e.target === modal) {
491 document.body.removeChild(modal)
492 }
493 }
494}
495
496// Show toast notification
497function showToast(message, type = 'info') {
498 const container = document.getElementById('toast-container') || createToastContainer()
499
500 const toast = document.createElement('div')
501 toast.className = `toast toast-${type}`
502 toast.textContent = message
503
504 container.appendChild(toast)
505
506 // Fade in
507 setTimeout(() => toast.classList.add('show'), 10)
508
509 // Remove after 3 seconds
510 setTimeout(() => {
511 toast.classList.remove('show')
512 setTimeout(() => container.removeChild(toast), 300)
513 }, 3000)
514}
515
516function createToastContainer() {
517 const container = document.createElement('div')
518 container.id = 'toast-container'
519 document.body.appendChild(container)
520 return container
521}
522
523// Show search modal
524function showSearchModal() {
525 const modal = document.createElement('div')
526 modal.className = 'modal-overlay'
527 modal.innerHTML = `
528 <div class="modal search-modal">
529 <h2>🔍 Search Users</h2>
530 <input type="text" id="search-input" placeholder="Search by name..." autofocus />
531 <div id="search-results" class="search-results">
532 <div class="search-placeholder">Start typing to search...</div>
533 </div>
534 <div class="modal-actions">
535 <button id="search-close">Close</button>
536 </div>
537 </div>
538 `
539 document.body.appendChild(modal)
540
541 const searchInput = modal.querySelector('#search-input')
542 const searchResults = modal.querySelector('#search-results')
543 const closeBtn = modal.querySelector('#search-close')
544
545 // Close modal
546 closeBtn.onclick = () => modal.remove()
547 modal.onclick = (e) => {
548 if (e.target === modal) modal.remove()
549 }
550
551 // Search on input with debounce
552 let searchTimeout
553 searchInput.oninput = () => {
554 clearTimeout(searchTimeout)
555 const query = searchInput.value.trim()
556
557 if (!query) {
558 searchResults.innerHTML = '<div class="search-placeholder">Start typing to search...</div>'
559 return
560 }
561
562 searchResults.innerHTML = '<div class="search-placeholder">Searching...</div>'
563
564 searchTimeout = setTimeout(async () => {
565 try {
566 // Fetch from relay via our backend proxy
567 const res = await fetch(`/api/directory?q=${encodeURIComponent(query)}`)
568 if (!res.ok) throw new Error('Search failed')
569
570 const data = await res.json()
571
572 if (data.total === 0) {
573 searchResults.innerHTML = `<div class="search-placeholder">No users found matching "${escapeHtml(query)}"</div>`
574 return
575 }
576
577 searchResults.innerHTML = data.users.map(user => `
578 <div class="user-item" data-pubkey="${escapeHtml(user.pubkey)}">
579 <div class="user-info">
580 <div class="user-name">${escapeHtml(user.name)}</div>
581 <div class="user-pubkey">${escapeHtml(user.pubkey.substring(0, 16))}...</div>
582 <div class="user-lastseen">Last seen: ${new Date(parseInt(user.lastSeen)).toLocaleString()}</div>
583 </div>
584 <button class="view-profile-btn" data-pubkey="${escapeHtml(user.pubkey)}">View Profile</button>
585 </div>
586 `).join('')
587
588 // Add click handlers for view profile buttons
589 searchResults.querySelectorAll('.view-profile-btn').forEach(btn => {
590 btn.onclick = () => {
591 const pubkey = btn.dataset.pubkey
592 modal.remove()
593 showUserProfileModal(pubkey)
594 }
595 })
596
597 } catch (err) {
598 console.error('Search error:', err)
599 searchResults.innerHTML = '<div class="search-placeholder error">Search failed. Please try again.</div>'
600 }
601 }, 300)
602 }
603}
604
605// Show user profile modal
606async function showUserProfileModal(pubkey) {
607 const modal = document.createElement('div')
608 modal.className = 'modal-overlay'
609 modal.innerHTML = `
610 <div class="modal profile-modal">
611 <h2>Loading profile...</h2>
612 <div class="modal-actions">
613 <button id="profile-modal-close">Close</button>
614 </div>
615 </div>
616 `
617 document.body.appendChild(modal)
618
619 const modalContent = modal.querySelector('.modal')
620 const closeBtn = modal.querySelector('#profile-modal-close')
621
622 closeBtn.onclick = () => modal.remove()
623 modal.onclick = (e) => {
624 if (e.target === modal) modal.remove()
625 }
626
627 try {
628 // Fetch profile from relay via our backend proxy
629 const res = await fetch(`/api/directory/${encodeURIComponent(pubkey)}`)
630 if (!res.ok) {
631 const errorText = await res.text()
632 console.error('Profile fetch failed:', res.status, errorText)
633 throw new Error(`Failed to load profile: ${res.status}`)
634 }
635
636 const profile = await res.json()
637
638 modalContent.innerHTML = `
639 <h2>👤 ${escapeHtml(profile.name)}</h2>
640 <div class="profile-details">
641 <div class="profile-field">
642 <strong>Pubkey:</strong>
643 <span class="monospace">${escapeHtml(profile.pubkey)}</span>
644 </div>
645 <div class="profile-field">
646 <strong>Messages:</strong> ${profile.messageCount}
647 </div>
648 <div class="profile-field">
649 <strong>Last seen:</strong> ${new Date(parseInt(profile.lastSeen)).toLocaleString()}
650 </div>
651 </div>
652
653 <h3>Recent Posts</h3>
654 <div class="recent-posts">
655 ${profile.recentPosts.map(post => {
656 // Remove YAML frontmatter for cleaner display
657 const text = post.text.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '')
658 const date = new Date(parseInt(post.timestamp)).toLocaleString()
659 return `
660 <div class="post-item">
661 <div class="post-date">${date}</div>
662 <div class="post-text">${escapeHtml(text)}</div>
663 </div>
664 `
665 }).join('')}
666 </div>
667
668 <div class="modal-actions">
669 <button id="follow-user-btn" class="primary" data-pubkey="${escapeHtml(profile.pubkey)}" data-name="${escapeHtml(profile.name)}">
670 Follow ${escapeHtml(profile.name)}
671 </button>
672 <button id="profile-modal-close">Close</button>
673 </div>
674 `
675
676 // Re-attach close handler
677 modalContent.querySelector('#profile-modal-close').onclick = () => modal.remove()
678
679 // Follow button handler
680 modalContent.querySelector('#follow-user-btn').onclick = async () => {
681 const followBtn = modalContent.querySelector('#follow-user-btn')
682 const pubkey = followBtn.dataset.pubkey
683 const name = followBtn.dataset.name
684
685 try {
686 followBtn.disabled = true
687 followBtn.textContent = 'Following...'
688
689 const res = await fetch('/api/follow', {
690 method: 'POST',
691 headers: { 'Content-Type': 'application/json' },
692 body: JSON.stringify({ publicKey: pubkey, name })
693 })
694
695 if (!res.ok) {
696 const error = await res.json()
697 throw new Error(error.error || 'Failed to follow')
698 }
699
700 showToast(`Followed ${name}!`, 'success')
701 modal.remove()
702 loadTimeline()
703 } catch (err) {
704 console.error('Follow error:', err)
705 showToast(err.message, 'error')
706 followBtn.disabled = false
707 followBtn.textContent = `Follow ${name}`
708 }
709 }
710
711 } catch (err) {
712 console.error('Profile load error:', err)
713 modalContent.innerHTML = `
714 <h2>Error</h2>
715 <p>Failed to load profile: ${escapeHtml(err.message)}</p>
716 <div class="modal-actions">
717 <button id="profile-modal-close">Close</button>
718 </div>
719 `
720 modalContent.querySelector('#profile-modal-close').onclick = () => modal.remove()
721 }
722}
723
724// Setup event listeners
725function setupEventListeners() {
726 // Compose button
727 const composeBtn = document.getElementById('compose-btn')
728 if (composeBtn) {
729 composeBtn.onclick = showComposeModal
730 }
731
732 // Refresh button
733 const refreshBtn = document.getElementById('refresh-btn')
734 if (refreshBtn) {
735 refreshBtn.onclick = () => {
736 loadTimeline()
737 showToast('Refreshing timeline...', 'info')
738 }
739 }
740
741 // Follow button
742 const followBtn = document.getElementById('follow-btn')
743 if (followBtn) {
744 followBtn.onclick = showFollowModal
745 }
746
747 // Profile button
748 const profileBtn = document.getElementById('profile-btn')
749 if (profileBtn) {
750 profileBtn.onclick = showProfileModal
751 }
752
753 // Search button
754 const searchBtn = document.getElementById('search-btn')
755 if (searchBtn) {
756 searchBtn.onclick = showSearchModal
757 }
758}
759
760// Escape HTML to prevent XSS
761function escapeHtml(text) {
762 const div = document.createElement('div')
763 div.textContent = text
764 return div.innerHTML
765}
766
767// Initialize when DOM is ready
768if (document.readyState === 'loading') {
769 document.addEventListener('DOMContentLoaded', init)
770} else {
771 init()
772}