OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / public / app.js

1586 lines · javascript ref:
/**
 * 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 = `![](${hash})`
        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()