/**
* Wirecore Web Client
*
* Frontend application for browser interface
*/
import { apds } from './apds-compat.js'
import { autoLink, markdown } from './markdown.js'
import { TrysteroContentTransport } from './trystero-content-transport.js'
import { joinRoom } from './trystero-torrent.min.js'
// State
let currentUser = null
let timeline = []
let ws = null
let trysteroTransport = null
// API Base URL - detect if we're running under a sub-path
const getBasePath = () => {
const path = window.location.pathname
// If path starts with /wirecore/, use that as base
if (path.startsWith('/wirecore/')) {
return window.location.origin + '/wirecore'
}
// Otherwise use origin only
return window.location.origin
}
const API_BASE = getBasePath()
// Initialize app
async function init() {
console.log('Initializing Wirecore web client...')
// Initialize identity layer
await apds.start()
// Load or generate identity
await initializeIdentity()
// Initialize P2P networking
await initializeTrystero()
// Load timeline
await loadTimeline()
// Connect WebSocket
connectWebSocket()
// Setup event listeners
setupEventListeners()
console.log('Wirecore initialized!')
}
// Initialize browser identity
async function initializeIdentity() {
let keypair = await apds.keypair()
// Check if we have an old-format keypair (88 chars instead of 132)
if (keypair && keypair.length !== 132) {
console.log('Detected old keypair format (length ' + keypair.length + '), clearing and regenerating...')
// Clear old keypair
await apds.put('keypair', null)
keypair = null
}
if (!keypair) {
// First time user or migrating from old format - generate new identity
console.log('Generating new identity...')
keypair = await apds.generate()
await apds.put('keypair', keypair)
// Show backup modal
showBackupModal(keypair)
}
const publicKey = keypair.substring(0, 44)
currentUser = {
publicKey,
name: await apds.get('name') || 'Anonymous'
}
console.log('Identity loaded:', publicKey.substring(0, 8) + '...')
// Update navbar
const pubkeyDisplay = document.getElementById('pubkey-display')
if (pubkeyDisplay) {
pubkeyDisplay.textContent = publicKey.substring(0, 8) + '...'
pubkeyDisplay.title = publicKey
}
}
// Initialize Trystero P2P networking
async function initializeTrystero() {
if (!currentUser || !currentUser.publicKey) {
console.error('Cannot initialize Trystero: no user identity')
return
}
try {
console.log('Initializing Trystero P2P networking...')
// Detect environment for relay configuration
const isProduction = window.location.pathname.startsWith('/wirecore/')
// Create transport with custom relay URLs and STUN servers
const relayUrls = isProduction ? [
// Production: Use only public trackers (working ones)
'wss://tracker.openwebtorrent.com',
'wss://tracker.files.fm:7073/announce'
] : [
// Development: Try local tracker first, then public
'ws://localhost:8000',
'wss://tracker.openwebtorrent.com',
'wss://tracker.files.fm:7073/announce'
]
trysteroTransport = new TrysteroContentTransport(apds, {
appId: 'wirecore-v1',
password: 'wirecore-p2p-2026',
relayUrls,
// STUN servers for NAT traversal (TURN servers not needed for most cases)
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
}
})
// Join shared room for peer discovery (like wiredove's 'wiredovev1')
// All Wirecore users join the same room to find each other
const roomName = 'wirecorev1'
await trysteroTransport.joinRoom(roomName, joinRoom)
console.log(`Trystero: joined room ${roomName.substring(0, 8)}...`)
console.log(`Trystero: ${trysteroTransport.getPeerCount()} peers connected`)
// Expose to window for use in markdown.js and other modules
window.trysteroTransport = trysteroTransport
// Update peer count in UI periodically
setInterval(() => {
const peerCount = trysteroTransport.getPeerCount()
console.log(`Trystero: ${peerCount} peers connected`)
}, 30000) // Every 30 seconds
} catch (err) {
console.error('Failed to initialize Trystero:', err)
showToast('P2P networking unavailable', 'error')
}
}
// Show backup modal for new identity
function showBackupModal(keypair) {
const publicKey = keypair.substring(0, 44)
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.innerHTML = `
<div class="modal-content" style="max-width: 600px;">
<h2>🔑 Identity Created!</h2>
<p>Your identity has been generated. <strong>Save this keypair</strong> - it's your only way to access this account!</p>
<div style="margin: 20px 0;">
<label style="display: block; margin-bottom: 5px; font-weight: 600;">Public Key (share this):</label>
<input type="text" readonly value="${publicKey}" style="width: 100%; padding: 8px; font-family: monospace;" onclick="this.select()">
</div>
<div style="margin: 20px 0;">
<label style="display: block; margin-bottom: 5px; font-weight: 600;">Keypair (keep secret!):</label>
<textarea readonly style="width: 100%; height: 100px; padding: 8px; font-family: monospace; resize: vertical;" onclick="this.select()">${keypair}</textarea>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button onclick="downloadKeypair('${keypair}')" style="flex: 1; padding: 10px;">
📥 Download Backup
</button>
<button onclick="copyToClipboard('${keypair}')" style="flex: 1; padding: 10px;">
📋 Copy to Clipboard
</button>
</div>
<button onclick="this.closest('.modal-overlay').remove()" style="width: 100%; padding: 10px; margin-top: 10px; background: #4CAF50; color: white; border: none; cursor: pointer;">
✅ I've Saved My Keypair
</button>
<p style="margin-top: 20px; font-size: 0.9em; color: #666;">
⚠️ Without this keypair, you cannot recover your identity. Store it somewhere safe!
</p>
</div>
`
document.body.appendChild(modal)
}
// Download keypair as file
window.downloadKeypair = function(keypair) {
const blob = new Blob([keypair], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `wirecore-identity-${Date.now()}.txt`
a.click()
URL.revokeObjectURL(url)
showToast('Keypair downloaded!', 'success')
}
// Copy to clipboard
window.copyToClipboard = function(text) {
navigator.clipboard.writeText(text).then(() => {
showToast('Copied to clipboard!', 'success')
}).catch(err => {
console.error('Failed to copy:', err)
showToast('Failed to copy', 'error')
})
}
// Load timeline
let timelinePagination = { offset: 0, limit: 50, hasMore: false }
async function loadTimeline(append = false) {
try {
const timelineEl = document.getElementById('timeline')
if (!append) {
timelineEl.innerHTML = '<div class="loading">Loading timeline</div>'
timelinePagination.offset = 0
}
const response = await fetch(`${API_BASE}/api/timeline?limit=${timelinePagination.limit}&offset=${timelinePagination.offset}`, {
headers: {
'X-Wirecore-PublicKey': currentUser.publicKey
}
})
if (!response.ok) {
const error = await response.json()
console.error('Timeline fetch failed:', error)
timeline = []
renderTimeline()
showToast('Failed to load timeline: ' + (error.error || response.statusText), 'error')
return
}
const data = await response.json()
// Handle both new format (with pagination) and old format (array)
if (Array.isArray(data)) {
// Old format - backward compatibility
timeline = data
timelinePagination.hasMore = false
} else {
// New format with pagination
if (append) {
timeline = timeline.concat(data.posts)
} else {
timeline = data.posts
}
timelinePagination = data.pagination
}
renderTimeline()
} catch (err) {
console.error('Failed to load timeline:', err)
timeline = []
renderTimeline()
showToast('Failed to load timeline', 'error')
}
}
// Load more posts
async function loadMore() {
if (!timelinePagination.hasMore) return
timelinePagination.offset += timelinePagination.limit
await loadTimeline(true)
}
// Render timeline
async function renderTimeline() {
const timelineEl = document.getElementById('timeline')
if (timeline.length === 0) {
timelineEl.innerHTML = `
<div class="empty-state">
<h3>No posts yet</h3>
<p>Start posting or follow some users!</p>
</div>
`
return
}
const postsHtml = (await Promise.all(timeline.map(post => renderPost(post)))).join('')
const loadMoreButton = timelinePagination.hasMore ? `
<div style="text-align: center; margin: 2em 0;">
<button onclick="loadMore()" class="button" style="padding: 0.75em 2em;">
Load More
</button>
</div>
` : ''
timelineEl.innerHTML = postsHtml + loadMoreButton
}
// Render a single post
async function renderPost(post) {
const isOwnPost = post.author === currentUser.publicKey
const authorName = isOwnPost ? currentUser.name : (post.name || 'Anonymous')
const authorShort = post.author.substring(0, 8)
const avatarLetter = authorShort[0].toUpperCase()
const timestamp = new Date(post.timestamp).toLocaleString()
// Get profile image (try for both own and other users)
let avatarHtml
let profileImage = null
if (isOwnPost) {
// Load own profile image
profileImage = await apds.get('profile-image')
} else {
// Try to load other user's profile image (if they've shared it)
profileImage = await apds.get(`profile-image:${post.author}`)
// If not found locally and we have P2P, request from peers
if (!profileImage && window.trysteroTransport && window.trysteroTransport.hasActivePeers()) {
const pfpHash = `profile-image:${post.author}`
try {
console.log(`Requesting profile picture for ${authorShort}... from P2P peers`)
profileImage = await window.trysteroTransport.requestContent(pfpHash, { timeout: 3000 })
if (profileImage) {
// Store for future use
await apds.put(pfpHash, profileImage)
console.log(`Received profile picture for ${authorShort}... from peers`)
}
} catch (err) {
console.log(`P2P request timeout for profile picture ${authorShort}...`)
}
}
}
if (profileImage) {
avatarHtml = `<img src="${profileImage}" class="avatar" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`
} else {
avatarHtml = `<div class="avatar">${avatarLetter}</div>`
}
let contentHtml = ''
switch (post.content.type) {
case 'post':
contentHtml = `<div>${await markdown(post.content.text)}</div>`
break
case 'reply':
const replyTarget = post.content.target?.author ? post.content.target.author.substring(0, 8) + '...' : 'someone'
contentHtml = `
<div style="color: #999; font-size: 0.9em; margin-bottom: 0.5em;">
<span class="material-symbols-outlined" style="font-size: 16px; vertical-align: middle;">Reply</span>
Replying to ${replyTarget}
</div>
<div>${await markdown(post.content.text)}</div>
`
break
case 'boost':
// Try to find the boosted post in timeline
const boostedPost = post.content.target?.author ? timeline.find(p =>
p.author === post.content.target.author &&
p.sequence === post.content.target.sequence
) : null
if (boostedPost && boostedPost.content.type === 'post') {
const boostedAuthorName = boostedPost.name || 'Anonymous'
const boostedAuthorShort = boostedPost.author.substring(0, 8)
contentHtml = `
<div style="margin-bottom: 0.75em; color: #10b981; font-weight: 600; display: flex; align-items: center; gap: 0.5em;">
<span class="material-symbols-outlined" style="font-size: 18px;">Repeat</span>
<span>Boosted</span>
</div>
<div style="padding-left: 1em; border-left: 3px solid #10b981; margin-left: 0.5em;">
<div style="margin-bottom: 0.5em;">
<a href="#${boostedPost.author}" onclick="event.stopPropagation()" style="font-weight: 600; color: #333; text-decoration: none;">${escapeHtml(boostedAuthorName)}</a>
<span style="color: #999; font-size: 0.85em; margin-left: 0.5em;">${boostedAuthorShort}...</span>
</div>
<div>${await markdown(boostedPost.content.text)}</div>
</div>
`
} else {
contentHtml = `
<div style="color: #10b981; font-weight: 600;">
<span class="material-symbols-outlined" style="font-size: 18px; vertical-align: middle;">Repeat</span>
Boosted ${post.content.target.author.substring(0, 8)}...'s post
<span style="color: #999; font-weight: normal; font-size: 0.9em;">(content not loaded)</span>
</div>
`
}
break
case 'like':
contentHtml = `
<div style="color: #ef4444; font-weight: 600;">
<span class="material-symbols-outlined" style="font-size: 18px; vertical-align: middle;">Favorite</span>
Liked ${post.content.target.author.substring(0, 8)}...'s post
</div>
`
break
}
return `
<div class="message message-clickable" data-author="${post.author}" data-sequence="${post.sequence}" onclick="window.location.hash='msg:${post.author}@${post.sequence}'">
<div class="message-header">
<a href="#${post.author}" class="avatar-link" onclick="event.stopPropagation()">
${avatarHtml}
</a>
<div class="message-meta">
<div>
<a href="#${post.author}" class="message-author" onclick="event.stopPropagation()">${escapeHtml(authorName)}</a>
<span class="message-pubkey">${authorShort}...</span>
</div>
<div class="message-time">${timestamp}</div>
</div>
</div>
<div class="message-content">
${contentHtml}
</div>
<div class="message-actions" onclick="event.stopPropagation()">
<button class="action-btn" onclick="replyToPost('${post.author}', ${post.sequence})">
<span class="material-symbols-outlined">Reply</span>
Reply
</button>
<button class="action-btn" onclick="boostPost('${post.author}', ${post.sequence})">
<span class="material-symbols-outlined">Repeat</span>
Boost
</button>
<button class="action-btn" onclick="likePost('${post.author}', ${post.sequence})">
<span class="material-symbols-outlined">Favorite</span>
Like
</button>
<button class="action-btn" onclick="sharePost('${post.author}', ${post.sequence})" title="Copy link to this post">
<span class="material-symbols-outlined">Share</span>
</button>
</div>
</div>
`
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// WebSocket reconnection state
let wsReconnectAttempts = 0
let wsReconnectTimeout = null
const WS_MAX_RECONNECT_DELAY = 30000 // Max 30 seconds
// Connect WebSocket for real-time updates
function connectWebSocket() {
// Clear any pending reconnection
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout)
wsReconnectTimeout = null
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
// Determine WebSocket URL based on environment
let wsUrl
if (window.location.pathname.startsWith('/wirecore/')) {
// Production: behind Apache proxy at /wirecore/
wsUrl = `${protocol}//${host}/wirecore/`
} else {
// Development: direct connection to server
wsUrl = `${protocol}//${host}`
}
// Close existing connection if any
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close()
}
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('WebSocket connected')
wsReconnectAttempts = 0 // Reset reconnection counter
showToast('Connected', 'success')
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'post') {
// Add new post to timeline
timeline.unshift(message.data)
renderTimeline()
showToast('New post!')
} else if (message.type === 'follow') {
showToast(`Now following ${message.data.name}`)
}
}
ws.onclose = (event) => {
console.log('WebSocket disconnected', event.code, event.reason)
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max)
wsReconnectAttempts++
const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempts - 1), WS_MAX_RECONNECT_DELAY)
console.log(`Reconnecting in ${delay/1000}s (attempt ${wsReconnectAttempts})...`)
wsReconnectTimeout = setTimeout(connectWebSocket, delay)
}
ws.onerror = (err) => {
console.error('WebSocket error:', err)
}
}
// Setup event listeners
function setupEventListeners() {
const composeBtn = document.getElementById('compose-btn')
const refreshBtn = document.getElementById('refresh-btn')
const followBtn = document.getElementById('follow-btn')
const searchBtn = document.getElementById('search-btn')
const profileBtn = document.getElementById('profile-btn')
console.log('Setting up event listeners...')
console.log('Compose button:', composeBtn)
console.log('Refresh button:', refreshBtn)
console.log('Follow button:', followBtn)
console.log('Search button:', searchBtn)
console.log('Profile button:', profileBtn)
if (composeBtn) {
composeBtn.addEventListener('click', () => {
console.log('Compose button clicked!')
showComposeModal()
})
} else {
console.error('Compose button not found!')
}
if (refreshBtn) {
refreshBtn.addEventListener('click', loadTimeline)
}
if (followBtn) {
followBtn.addEventListener('click', showFollowModal)
}
if (searchBtn) {
searchBtn.addEventListener('click', showSearchModal)
}
if (profileBtn) {
profileBtn.addEventListener('click', showProfileModal)
}
}
// Show compose modal
function showComposeModal(replyTo = null) {
console.log('showComposeModal called with replyTo:', replyTo)
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove()
}
}
const title = replyTo ? 'Reply' : 'New Post'
const placeholder = replyTo ? 'Write your reply...' : 'What\'s on your mind?'
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">${title}</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
</div>
<form id="compose-form">
${replyTo ? `<p style="color: #999; margin-bottom: 1em;">Replying to ${replyTo.author.substring(0, 8)}...</p>` : ''}
<textarea id="compose-text" placeholder="${placeholder}" required></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 0.5em;">
<div>
<button type="button" id="image-upload-btn" class="icon-btn" title="Upload image" style="background: none; border: 1px solid #e4e4e4;">
<span class="material-symbols-outlined">Image</span>
</button>
<input type="file" id="post-image-upload" accept="image/*" style="display: none;">
</div>
<div style="display: flex; gap: 0.5em;">
<button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button type="submit">Post</button>
</div>
</div>
</form>
</div>
`
document.body.appendChild(modal)
// Focus textarea
setTimeout(() => document.getElementById('compose-text').focus(), 100)
// Handle image upload button
const imageUploadBtn = document.getElementById('image-upload-btn')
const imageUploadInput = document.getElementById('post-image-upload')
const composeTextarea = document.getElementById('compose-text')
imageUploadBtn.addEventListener('click', () => {
imageUploadInput.click()
})
imageUploadInput.addEventListener('change', async (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = async (e) => {
try {
// Hash the image data
const imageData = e.target.result
const hash = await apds.hash(imageData)
// Store image in IndexedDB
await apds.put(`post-image:${hash}`, imageData)
// Insert markdown image syntax into textarea
const imageMarkdown = ``
const currentText = composeTextarea.value
if (currentText) {
composeTextarea.value = currentText + '\n' + imageMarkdown
} else {
composeTextarea.value = imageMarkdown
}
showToast('Image uploaded! Preview after posting.', 'success')
// Clear file input
imageUploadInput.value = ''
} catch (err) {
console.error('Image upload error:', err)
showToast('Failed to upload image', 'error')
}
}
reader.readAsDataURL(file)
})
// Handle form submit
document.getElementById('compose-form').onsubmit = async (e) => {
e.preventDefault()
const text = document.getElementById('compose-text').value
try {
const submitBtn = e.target.querySelector('button[type="submit"]')
submitBtn.disabled = true
submitBtn.textContent = 'Posting...'
if (replyTo) {
// Sign reply content locally (apds.sign will stringify deterministically)
const content = {
type: 'reply',
target: { author: replyTo.author, sequence: replyTo.sequence },
text
}
const signature = await apds.sign(content)
await fetch(`${API_BASE}/api/post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({
signature,
content
})
})
showToast('Reply posted!', 'success')
} else {
// Sign post content locally (apds.sign will stringify deterministically)
const content = { type: 'post', text }
const signature = await apds.sign(content)
await fetch(`${API_BASE}/api/post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({
signature,
content
})
})
showToast('Post published!', 'success')
}
modal.remove()
await loadTimeline()
} catch (err) {
console.error('Failed to post:', err)
showToast('Failed to post', 'error')
e.target.querySelector('button[type="submit"]').disabled = false
e.target.querySelector('button[type="submit"]').textContent = 'Post'
}
}
}
// Reply to post
window.replyToPost = function(author, sequence) {
showComposeModal({ author, sequence })
}
// Boost post
window.boostPost = async function(author, sequence) {
try {
await fetch(`${API_BASE}/api/boost`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({ author, sequence })
})
showToast('Boosted!', 'success')
await loadTimeline()
} catch (err) {
console.error('Failed to boost:', err)
showToast('Failed to boost', 'error')
}
}
// Like post
window.likePost = async function(author, sequence) {
try {
await fetch(`${API_BASE}/api/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({ author, sequence })
})
showToast('Liked!', 'success')
await loadTimeline()
} catch (err) {
console.error('Failed to like:', err)
showToast('Failed to like', 'error')
}
}
// Show follow modal
async function showFollowModal() {
// Get current following list
let following = []
try {
const res = await fetch(`${API_BASE}/api/following`, {
headers: {
'X-Wirecore-PublicKey': currentUser.publicKey
}
})
if (res.ok) {
const data = await res.json()
following = data.following || []
}
} catch (err) {
console.error('Failed to load following list:', err)
}
const alreadyFollowingClode = following.some(f => f.publicKey === 'PV5UJBmgbs9Xq2D3Qttl8Zz7Xq59Pr9SsPtxxxRumzM=')
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) modal.remove()
}
const followingListHTML = following.length > 0
? `<div style="margin-bottom: 1.5em; padding: 1em; background: #f9fafb; border-radius: 6px;">
<p style="font-weight: 600; margin-bottom: 0.75em; color: #374151;">Following (${following.length})</p>
${following.map(f => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5em 0; border-bottom: 1px solid #e5e7eb;">
<div>
<div style="font-weight: 500;">${f.name}</div>
<div style="font-size: 0.75em; color: #6b7280; font-family: monospace;">${f.publicKey.substring(0, 20)}...</div>
</div>
</div>
`).join('')}
</div>`
: `<div style="margin-bottom: 1.5em; padding: 1em; background: #f9fafb; border-radius: 6px; color: #6b7280; text-align: center;">
Not following anyone yet. Start by following clode below!
</div>`
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Follow User</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
</div>
${followingListHTML}
<div style="margin-bottom: 1.5em; padding: 1em; background: #eff6ff; border-radius: 6px; border-left: 3px solid #3b82f6;">
<p style="font-weight: 600; margin-bottom: 0.25em;">Quick Follow: clode</p>
<p style="font-size: 0.85em; color: #6b7280; margin-bottom: 0.75em;">Development updates from the Wirecore team</p>
<button type="button" id="follow-clode-btn"
${alreadyFollowingClode ? 'disabled' : ''}
style="width: 100%; padding: 0.75em; background: ${alreadyFollowingClode ? '#9ca3af' : '#3b82f6'}; color: white; border: none; border-radius: 6px; cursor: ${alreadyFollowingClode ? 'not-allowed' : 'pointer'}; font-weight: 500;">
${alreadyFollowingClode ? '✓ Already Following' : 'Follow clode'}
</button>
</div>
<form id="follow-form">
<p style="font-weight: 600; margin-bottom: 1em; color: #374151;">Or Follow Custom User</p>
<div style="margin-bottom: 1em;">
<label style="display: block; margin-bottom: 0.5em; font-weight: 600;">Public Key (ANProto)</label>
<input type="text" id="follow-publickey" placeholder="PV5UJBmgbs9Xq2D3Qttl8Zz7..." required
style="width: 100%; padding: 0.5em; font-family: 'Roboto Mono', monospace; font-size: 0.9em;">
<small style="color: #999;">Example: clode's key</small>
</div>
<div style="margin-bottom: 1em;">
<label style="display: block; margin-bottom: 0.5em; font-weight: 600;">Feed Key (Hypercore)</label>
<input type="text" id="follow-feedkey" placeholder="9dNgEVvB72V053D0D7hi..." required
style="width: 100%; padding: 0.5em; font-family: 'Roboto Mono', monospace; font-size: 0.9em;">
<small style="color: #999;">Base64 encoded Hypercore key</small>
</div>
<div style="margin-bottom: 1.5em;">
<label style="display: block; margin-bottom: 0.5em; font-weight: 600;">Name (optional)</label>
<input type="text" id="follow-name" placeholder="Anonymous"
style="width: 100%; padding: 0.5em;">
</div>
<div style="display: flex; justify-content: flex-end; gap: 0.5em;">
<button type="button" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button type="submit">Follow</button>
</div>
</form>
</div>
`
document.body.appendChild(modal)
// Handle clode follow button
const clodeBtn = document.getElementById('follow-clode-btn')
if (clodeBtn && !alreadyFollowingClode) {
clodeBtn.onclick = async () => {
clodeBtn.disabled = true
clodeBtn.textContent = 'Following...'
try {
await followClode()
modal.remove()
} catch (err) {
clodeBtn.disabled = false
clodeBtn.textContent = 'Follow clode'
}
}
}
// Handle form submit
document.getElementById('follow-form').onsubmit = async (e) => {
e.preventDefault()
const publicKey = document.getElementById('follow-publickey').value.trim()
const feedKey = document.getElementById('follow-feedkey').value.trim()
const name = document.getElementById('follow-name').value.trim() || 'Anonymous'
try {
const submitBtn = e.target.querySelector('button[type="submit"]')
submitBtn.disabled = true
submitBtn.textContent = 'Following...'
const res = await fetch(`${API_BASE}/api/follow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({ publicKey, feedKey, name })
})
if (!res.ok) {
let errorMsg = 'Failed to follow'
try {
const error = await res.json()
errorMsg = error.error || errorMsg
} catch (e) {
// Response wasn't JSON, use status text
errorMsg = res.statusText || errorMsg
}
throw new Error(errorMsg)
}
showToast(`Now following ${name}!`, 'success')
modal.remove()
// Reload timeline to show new posts
setTimeout(loadTimeline, 1000)
} catch (error) {
console.error('Follow error:', error)
showToast('Failed to follow: ' + error.message, 'error')
const submitBtn = e.target.querySelector('button[type="submit"]')
submitBtn.disabled = false
submitBtn.textContent = 'Follow'
}
}
}
// Quick follow clode
window.followClode = async function() {
console.log('followClode called, currentUser:', currentUser)
if (!currentUser || !currentUser.publicKey) {
showToast('Please wait for identity to load', 'error')
return
}
try {
const res = await fetch(`${API_BASE}/api/follow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({
publicKey: 'PV5UJBmgbs9Xq2D3Qttl8Zz7Xq59Pr9SsPtxxxRumzM=',
feedKey: '9dNgEVvB72V053D0D7himBGJBqPyr4rJyZJYRgcWivA=',
name: 'clode'
})
})
console.log('Follow clode response:', res.status, res.statusText)
if (!res.ok) {
let errorMsg = 'Failed to follow'
try {
const error = await res.json()
console.error('Follow error response:', error)
errorMsg = error.error || errorMsg
} catch (e) {
// Response wasn't JSON, use status text
errorMsg = res.statusText || errorMsg
}
throw new Error(errorMsg)
}
showToast('Now following clode!', 'success')
document.querySelector('.modal-overlay')?.remove()
// Reload timeline to show clode's posts
setTimeout(loadTimeline, 1000)
} catch (error) {
console.error('Follow clode error:', error)
showToast('Failed to follow clode: ' + error.message, 'error')
throw error // Re-throw so button handler can catch it
}
}
// Show profile modal
async function showProfileModal() {
const keypair = await apds.keypair()
const name = await apds.get('name') || 'Anonymous'
const bio = await apds.get('bio') || ''
const profileImage = await apds.get('profile-image')
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) modal.remove()
}
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Your Profile</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
</div>
<div>
<div style="text-align: center; margin-bottom: 1.5em;">
<div style="position: relative; display: inline-block;">
${profileImage ? `
<img id="profile-avatar-preview" src="${profileImage}" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover; cursor: pointer; border: 3px solid #eee;" onclick="document.getElementById('avatar-upload').click()">
` : `
<div id="profile-avatar-preview" style="width: 120px; height: 120px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 3em; cursor: pointer; border: 3px solid #eee;" onclick="document.getElementById('avatar-upload').click()">
${currentUser.publicKey[0].toUpperCase()}
</div>
`}
<div style="position: absolute; bottom: 0; right: 0; background: #667eea; color: white; width: 35px; height: 35px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; border: 3px solid white;" onclick="document.getElementById('avatar-upload').click()">
<span class="material-symbols-outlined" style="font-size: 20px;">Photo_Camera</span>
</div>
</div>
<input type="file" id="avatar-upload" accept="image/*" style="display: none;" onchange="handleAvatarUpload(event)">
<p style="margin-top: 0.5em; font-size: 0.85em; color: #999;">Click to upload profile picture</p>
</div>
<label>Public Key</label>
<input type="text" value="${currentUser.publicKey}" readonly onclick="this.select()">
<label>Display Name</label>
<input type="text" id="profile-name" value="${name}" placeholder="Your name">
<label>Bio</label>
<textarea id="profile-bio" placeholder="Tell us about yourself">${bio}</textarea>
<div style="margin: 20px 0; padding: 15px; background: #f0f0f0; border-radius: 4px;">
<label style="display: block; margin-bottom: 5px; font-weight: 600;">Backup Keypair</label>
<p style="font-size: 0.9em; color: #666; margin-bottom: 10px;">Save your keypair for account recovery</p>
<div style="display: flex; gap: 10px;">
<button onclick="downloadKeypair('${keypair}')" style="flex: 1;">📥 Download</button>
<button onclick="copyToClipboard('${keypair}')" style="flex: 1;">📋 Copy</button>
</div>
</div>
<div style="margin: 20px 0; padding: 15px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;">
<label style="display: block; margin-bottom: 5px; font-weight: 600; color: #856404;">Danger Zone</label>
<p style="font-size: 0.9em; color: #856404; margin-bottom: 10px;">This will delete your identity permanently</p>
<button onclick="resetIdentity()" style="width: 100%; padding: 8px; background: #dc3545; color: white; border: none; cursor: pointer; border-radius: 4px;">🗑️ Reset Identity</button>
</div>
<div style="display: flex; justify-content: flex-end; gap: 0.5em;">
<button onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button onclick="updateProfile()">Save Profile</button>
</div>
</div>
</div>
`
document.body.appendChild(modal)
}
// Handle avatar upload
window.handleAvatarUpload = function(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
const size = 256
let cropWidth, cropHeight, offsetX = 0, offsetY = 0
// Calculate crop dimensions (center square crop)
if (img.width > img.height) {
cropHeight = img.height
cropWidth = img.height
offsetX = (img.width - img.height) / 2
} else {
cropWidth = img.width
cropHeight = img.width
offsetY = (img.height - img.width) / 2
}
// Resize to 256x256
canvas.width = size
canvas.height = size
ctx.drawImage(img, offsetX, offsetY, cropWidth, cropHeight, 0, 0, size, size)
const croppedImage = canvas.toDataURL('image/jpeg', 0.85)
// Update preview
const preview = document.getElementById('profile-avatar-preview')
if (preview.tagName === 'IMG') {
preview.src = croppedImage
} else {
// Replace div with img
const newPreview = document.createElement('img')
newPreview.id = 'profile-avatar-preview'
newPreview.src = croppedImage
newPreview.style = 'width: 120px; height: 120px; border-radius: 50%; object-fit: cover; cursor: pointer; border: 3px solid #eee;'
newPreview.onclick = () => document.getElementById('avatar-upload').click()
preview.replaceWith(newPreview)
}
// Store in IndexedDB
apds.put('profile-image', croppedImage)
showToast('Profile picture uploaded!', 'success')
}
img.src = e.target.result
}
reader.readAsDataURL(file)
}
// Update profile
window.updateProfile = async function() {
const name = document.getElementById('profile-name').value
const bio = document.getElementById('profile-bio').value
try {
// Save to IndexedDB
await apds.put('name', name)
await apds.put('bio', bio)
// Update current user
currentUser.name = name
// Update server-side profile (important for YAML metadata in posts)
try {
const response = await fetch('/api/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-wirecore-publickey': currentUser.publicKey
},
body: JSON.stringify({ name, bio, image: currentUser.imageHash })
})
if (!response.ok) {
console.error('Server profile update failed:', response.status)
} else {
console.log('✓ Server-side profile updated')
}
} catch (serverErr) {
console.error('Failed to update server-side profile:', serverErr)
// Don't fail the whole operation if server update fails
}
showToast('Profile updated!', 'success')
document.querySelector('.modal-overlay').remove()
// Re-render timeline to show new username
renderTimeline()
} catch (err) {
console.error('Failed to update profile:', err)
showToast('Failed to update profile', 'error')
}
}
// Reset identity (delete all data and reload)
window.resetIdentity = async function() {
if (!confirm('⚠️ This will permanently delete your identity and all data. Make sure you have backed up your keypair!\n\nAre you sure you want to continue?')) {
return
}
try {
// Clear all IndexedDB data
await apds.put('keypair', null)
await apds.put('name', null)
await apds.put('bio', null)
showToast('Identity reset. Reloading...', 'success')
// Reload page after a short delay
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (err) {
console.error('Failed to reset identity:', err)
showToast('Failed to reset identity', 'error')
}
}
// Show toast notification
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container')
const toast = document.createElement('div')
toast.className = `toast ${type}`
toast.textContent = message
container.appendChild(toast)
// Auto-remove after 3 seconds
setTimeout(() => {
toast.style.animation = 'slidein 0.3s reverse'
setTimeout(() => toast.remove(), 300)
}, 3000)
}
// Open image lightbox
window.openImageLightbox = function(imgElement) {
const lightbox = document.createElement('div')
lightbox.className = 'image-lightbox'
const img = document.createElement('img')
img.src = imgElement.src
img.alt = imgElement.alt
const closeBtn = document.createElement('button')
closeBtn.className = 'image-lightbox-close'
closeBtn.innerHTML = '×'
closeBtn.onclick = (e) => {
e.stopPropagation()
lightbox.remove()
}
lightbox.onclick = (e) => {
e.stopPropagation() // Prevent event from bubbling to message card
lightbox.remove()
}
img.onclick = (e) => e.stopPropagation() // Prevent closing when clicking image
lightbox.appendChild(img)
lightbox.appendChild(closeBtn)
document.body.appendChild(lightbox)
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
lightbox.remove()
document.removeEventListener('keydown', handleEscape)
}
}
document.addEventListener('keydown', handleEscape)
}
// Hash-based routing
function handleHashChange() {
const hash = window.location.hash.substring(1) // Remove #
if (!hash) {
return
}
// Check if it's a message permalink (msg:author@sequence)
if (hash.startsWith('msg:')) {
const parts = hash.substring(4).split('@')
if (parts.length === 2) {
const [author, sequence] = parts
showMessageContext(author, sequence)
return
}
}
// Check if it's a public key (44 chars base64)
if (hash.length === 44 && !hash.startsWith('?')) {
showUserProfile(hash)
} else if (hash.startsWith('?')) {
// Hashtag search
const hashtag = hash.substring(1)
showHashtagTimeline(hashtag)
}
}
// Show user profile modal
async function showUserProfile(publicKey) {
const shortKey = publicKey.substring(0, 8) + '...'
// Get user info
let userName = 'Anonymous'
let userFeedKey = null
// Check if this user is in our following list
try {
const res = await fetch(`${API_BASE}/api/discover/${publicKey}`)
if (res.ok) {
const data = await res.json()
userFeedKey = data.feedKey
}
} catch (err) {
console.error('Failed to discover user:', err)
}
const isOwnProfile = currentUser && currentUser.publicKey === publicKey
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove()
window.location.hash = '' // Clear hash
}
}
modal.innerHTML = `
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 class="modal-title">User Profile</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove(); window.location.hash=''">×</button>
</div>
<div style="text-align: center; margin: 1.5em 0;">
<div class="avatar" style="width: 80px; height: 80px; font-size: 2em; margin: 0 auto 1em;">
${publicKey[0].toUpperCase()}
</div>
<h3 style="margin: 0.5em 0;">${userName}</h3>
<p style="font-family: 'Roboto Mono', monospace; font-size: 0.85em; color: #999; word-break: break-all;">
${publicKey}
</p>
</div>
${isOwnProfile ? `
<div style="text-align: center; padding: 1em; background: #f0f0f0; border-radius: 6px; margin-bottom: 1em;">
<p style="color: #666;">This is your profile</p>
</div>
` : `
<div style="text-align: center;">
<button onclick="followUserFromProfile('${publicKey}', '${userFeedKey || ''}', '${userName}')" style="padding: 0.75em 2em;">
Follow ${userName}
</button>
</div>
`}
</div>
`
document.body.appendChild(modal)
}
// Follow user from profile modal
window.followUserFromProfile = async function(publicKey, feedKey, name) {
if (!currentUser || !currentUser.publicKey) {
showToast('Please wait for identity to load', 'error')
return
}
try {
const res = await fetch(`${API_BASE}/api/follow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wirecore-PublicKey': currentUser.publicKey
},
body: JSON.stringify({
publicKey,
feedKey: feedKey || undefined,
name: name || 'Anonymous'
})
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to follow')
}
showToast(`Now following ${name}!`, 'success')
// Close modal and clear hash
const modal = document.querySelector('.modal-overlay')
if (modal) modal.remove()
window.location.hash = ''
// Reload timeline
setTimeout(loadTimeline, 1000)
} catch (err) {
showToast('Follow failed: ' + err.message, 'error')
}
}
// Share post - copy permalink to clipboard
window.sharePost = function(author, sequence) {
const permalink = `${window.location.origin}${window.location.pathname}#msg:${author}@${sequence}`
navigator.clipboard.writeText(permalink)
.then(() => {
showToast('Link copied to clipboard!', 'success')
})
.catch(err => {
console.error('Failed to copy:', err)
showToast('Failed to copy link', 'error')
})
}
// Show single message in context
async function showMessageContext(author, sequence) {
// Find the post in timeline
const post = timeline.find(p => p.author === author && p.sequence === parseInt(sequence))
if (!post) {
showToast('Message not found in timeline', 'error')
return
}
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove()
window.location.hash = ''
}
}
// Render the post (await since it's async)
const postHtml = await renderPost(post)
// Find all replies to this post
const replies = timeline.filter(p =>
p.content.type === 'reply' &&
p.content.target?.author === author &&
p.content.target?.sequence === sequence
)
// Sort replies by timestamp
replies.sort((a, b) => a.timestamp - b.timestamp)
// Render all replies
let repliesHtml = ''
if (replies.length > 0) {
const repliesRendered = await Promise.all(replies.map(r => renderPost(r)))
repliesHtml = `
<div style="margin-top: 1.5em;">
<h3 style="font-size: 1.1em; color: #666; margin-bottom: 1em;">
${replies.length} ${replies.length === 1 ? 'Reply' : 'Replies'}
</h3>
<div style="display: flex; flex-direction: column; gap: 1em;">
${repliesRendered.join('')}
</div>
</div>
`
} else {
repliesHtml = `
<div style="margin-top: 1.5em; padding: 1em; background: #f9fafb; border-radius: 6px; text-align: center;">
<p style="color: #666; font-size: 0.9em;">No replies yet. Be the first to reply!</p>
</div>
`
}
modal.innerHTML = `
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 class="modal-title">Message Context</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove(); window.location.hash=''">×</button>
</div>
<div style="margin-top: 1em;">
${postHtml}
</div>
${repliesHtml}
</div>
`
document.body.appendChild(modal)
}
// Show search modal
function showSearchModal() {
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) modal.remove()
}
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Search</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove()">×</button>
</div>
<div>
<label>Search for hashtags or content</label>
<input type="text" id="search-input" placeholder="Enter hashtag (e.g., #wirecore) or search term" autofocus>
<div style="margin-top: 1.5em;">
<button onclick="performSearch()" style="width: 100%; padding: 0.75em;">
Search Timeline
</button>
</div>
<div id="search-results" style="margin-top: 1.5em; max-height: 400px; overflow-y: auto;">
</div>
</div>
</div>
`
document.body.appendChild(modal)
// Handle Enter key
const searchInput = document.getElementById('search-input')
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
performSearch()
}
})
}
// Perform search
window.performSearch = async function() {
const searchInput = document.getElementById('search-input')
const query = searchInput.value.trim()
if (!query) {
showToast('Please enter a search term', 'error')
return
}
// If it's a hashtag, redirect to hashtag view
if (query.startsWith('#')) {
document.querySelector('.modal-overlay')?.remove()
window.location.hash = '?' + query
return
}
// Otherwise, search timeline for content
const resultsDiv = document.getElementById('search-results')
resultsDiv.innerHTML = '<div class="loading">Searching</div>'
const filteredPosts = timeline.filter(post => {
if (post.content.type !== 'post' && post.content.type !== 'reply') {
return false
}
const text = post.content.text || ''
const authorName = post.name || ''
// Case-insensitive search
const searchLower = query.toLowerCase()
return text.toLowerCase().includes(searchLower) || authorName.toLowerCase().includes(searchLower)
})
if (filteredPosts.length > 0) {
const postsHtml = (await Promise.all(filteredPosts.slice(0, 20).map(post => renderPost(post)))).join('')
resultsDiv.innerHTML = `
<h3 style="margin-bottom: 1em; color: #666;">Found ${filteredPosts.length} result${filteredPosts.length === 1 ? '' : 's'}</h3>
${postsHtml}
`
} else {
resultsDiv.innerHTML = `
<div class="empty-state">
<h3>No results found</h3>
<p>Try a different search term</p>
</div>
`
}
}
// Show hashtag timeline
async function showHashtagTimeline(hashtag) {
// Filter timeline for posts containing this hashtag
const filteredPosts = timeline.filter(post => {
if (post.content.type !== 'post' && post.content.type !== 'reply') {
return false
}
const text = post.content.text || ''
// Match hashtag (with word boundary)
const hashtagRegex = new RegExp(`(^|\\s)${hashtag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$|[.,!?;:])`, 'i')
return hashtagRegex.test(text)
})
const modal = document.createElement('div')
modal.className = 'modal-overlay'
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove()
window.location.hash = ''
}
}
let postsHtml = ''
if (filteredPosts.length > 0) {
postsHtml = (await Promise.all(filteredPosts.map(post => renderPost(post)))).join('')
} else {
postsHtml = `
<div class="empty-state">
<h3>No posts found</h3>
<p>No posts contain ${hashtag}</p>
</div>
`
}
modal.innerHTML = `
<div class="modal-content" onclick="event.stopPropagation()" style="max-width: 800px;">
<div class="modal-header">
<h2 class="modal-title">${hashtag} (${filteredPosts.length} ${filteredPosts.length === 1 ? 'post' : 'posts'})</h2>
<button class="close-btn" onclick="this.closest('.modal-overlay').remove(); window.location.hash=''">×</button>
</div>
<div style="max-height: 70vh; overflow-y: auto; margin-top: 1em;">
${postsHtml}
</div>
</div>
`
document.body.appendChild(modal)
}
// Listen for hash changes
window.addEventListener('hashchange', handleHashChange)
// Handle initial hash on page load
if (window.location.hash) {
// Delay to ensure app is initialized
setTimeout(handleHashChange, 500)
}
// Start the app
init()