OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-social-web / web / js / app.js

718 lines · javascript ref:
/**
 * hyper-social-web — SPA router and initialization
 */

let currentSource = ''
let oldestTimestamp = null
let currentUser = null
let currentProfile = null

// --- Router ---
function route () {
  const hash = window.location.hash || '#/'
  const pages = document.querySelectorAll('.page')
  const links = document.querySelectorAll('.nav-link')

  pages.forEach(p => { p.style.display = 'none' })
  links.forEach(l => l.classList.remove('active'))

  const isLoggedIn = !!(currentUser && currentUser.userId)

  // Hide nav items for anonymous users
  document.querySelector('[data-page="compose"]').style.display = isLoggedIn ? '' : 'none'
  document.querySelector('[data-page="notifications"]').style.display = isLoggedIn ? '' : 'none'
  // Settings only visible when logged in — anonymous gets login page
  const settingsLabel = document.querySelector('[data-page="settings"]')
  if (settingsLabel) settingsLabel.textContent = isLoggedIn ? 'Settings' : 'Login'

  if (hash.startsWith('#/compose') && isLoggedIn) {
    document.getElementById('page-compose').style.display = 'block'
    document.querySelector('[data-page="compose"]').classList.add('active')
  } else if (hash.startsWith('#/notifications') && isLoggedIn) {
    document.getElementById('page-notifications').style.display = 'block'
    document.querySelector('[data-page="notifications"]').classList.add('active')
    loadNotifications()
  } else if (hash.startsWith('#/settings')) {
    document.getElementById('page-settings').style.display = 'block'
    document.querySelector('[data-page="settings"]').classList.add('active')
    loadSettings()
  } else if (hash.startsWith('#/admin')) {
    document.getElementById('page-admin').style.display = 'block'
    document.querySelector('[data-page="admin"]').classList.add('active')
    loadAdmin()
  } else {
    document.getElementById('page-timeline').style.display = 'block'
    document.querySelector('[data-page="timeline"]').classList.add('active')
    loadTimeline()
  }
}

// --- Identity status in header ---
async function loadIdentityStatus () {
  if (typeof Identity === 'undefined' || !Identity.isReady) return

  try {
    const data = await API.me()
    currentUser = data.user
    const statusEl = document.getElementById('identity-status')

    if (data.user) {
      const name = data.user.username ? '@' + data.user.username : (data.user.name || 'Anonymous')
      const badge = data.user.admin ? ' [admin]' : ''
      statusEl.textContent = name + badge

      if (data.user.admin) {
        document.getElementById('nav-admin').style.display = ''
      }
    } else {
      statusEl.textContent = ''
    }
  } catch (err) {
    console.error('[identity] Failed to load status:', err.message)
  }
}

// --- Settings page ---
async function loadSettings () {
  const isLoggedIn = !!(currentUser && currentUser.userId)

  if (!isLoggedIn) {
    // Show login page for anonymous
    renderIdentityCard()
    // Hide all other settings sections
    document.getElementById('profile-section').innerHTML = ''
    document.getElementById('identities-section').innerHTML = ''
    document.getElementById('keypair-section').innerHTML = ''
    document.getElementById('accounts-list').innerHTML = ''
    return
  }

  // Fetch full profile with identities
  try {
    const res = await API._fetch('/api/user/profile')
    const data = await res.json()
    if (data.user) {
      currentProfile = data
      currentUser = data.user
    }
  } catch {}

  renderIdentityCard()
  renderProfileSection()
  renderIdentitiesSection()
  renderKeypairSection()
  updateThemeButtons()
  loadAccounts()
}

function renderIdentityCard () {
  const el = document.getElementById('identity-card')
  if (!currentUser) {
    el.innerHTML = `
      <div style="color:var(--text-muted);margin-bottom:12px">Not logged in.</div>
      <div style="display:flex;gap:8px;flex-wrap:wrap">
        <button class="btn btn-primary" onclick="doLogin()">Login</button>
        <button class="btn btn-small" onclick="showImportDialog()">Import Keypair</button>
      </div>
      <p style="font-size:12px;color:var(--text-muted);margin-top:8px">
        Login generates a new identity. Import Keypair links an existing identity from another device.
      </p>
    `
    return
  }

  const u = currentUser
  const permLevel = u.admin ? 'admin' : (u.globalPermissions && u.globalPermissions.includes('write') ? 'write' : 'read')
  const permLabel = permLevel.charAt(0).toUpperCase() + permLevel.slice(1)

  el.innerHTML = `
    <div class="identity-card-header">
      <div class="identity-avatar">&#128100;</div>
      <div>
        <div class="identity-name">${esc(u.name || 'Anonymous')}</div>
        <div class="identity-handle">${u.username ? '@' + esc(u.username) : '<em>no username set</em>'}</div>
      </div>
      <span class="perm-badge ${permLevel}" style="margin-left:auto">${permLabel}</span>
    </div>
    <div style="font-size:12px;color:var(--text-muted)">
      User ID: <code class="mono">${esc(u.userId)}</code>
      ${u.createdAt ? ' &middot; Joined ' + new Date(u.createdAt).toLocaleDateString() : ''}
    </div>
  `
}

function renderProfileSection () {
  const el = document.getElementById('profile-section')
  if (!currentUser) { el.innerHTML = ''; return }

  const u = currentUser
  el.innerHTML = `
    <div class="settings-row">
      <span class="settings-label">Username</span>
      <span class="settings-value">
        ${u.username
          ? '@' + esc(u.username) + ' <button class="btn btn-small" onclick="promptUsername()">Change</button>'
          : '<button class="btn btn-small" onclick="promptUsername()">Set username</button>'
        }
      </span>
    </div>
    <div class="settings-row">
      <span class="settings-label">Display Name</span>
      <span class="settings-value">
        ${esc(u.name || 'Anonymous')}
        <button class="btn btn-small" onclick="promptDisplayName()">Edit</button>
      </span>
    </div>
  `
}

function renderIdentitiesSection () {
  const el = document.getElementById('identities-section')
  if (!currentProfile || !currentProfile.identities) {
    el.innerHTML = '<div style="color:var(--text-muted);font-size:13px">No identities loaded</div>'
    return
  }

  const methodIcons = {
    'web-identity': '&#127760;',
    'ssh-key': '&#128274;',
    'keet-identity': '&#128179;',
    'bearer-token': '&#128273;'
  }

  el.innerHTML = currentProfile.identities.map(id => `
    <div class="identity-item">
      <span>${methodIcons[id.method] || '&#128100;'}</span>
      <span class="identity-method">${esc(id.method)}</span>
      <code class="mono" style="flex:1;overflow:hidden;text-overflow:ellipsis">${esc(id.publicKey || id.identityId)}</code>
      <button class="btn btn-small" onclick="copyText('${esc(id.publicKey || id.identityId)}')">Copy</button>
    </div>
  `).join('')
}

function renderKeypairSection () {
  const el = document.getElementById('keypair-section')
  if (typeof Identity === 'undefined' || !Identity.isReady) {
    el.innerHTML = '<div style="color:var(--text-muted);font-size:13px">Identity not initialized</div>'
    return
  }

  const pubkey = Identity.publicKey || ''
  const keypair = localStorage.getItem('hs-keypair') || ''

  el.innerHTML = `
    <div class="settings-row">
      <span class="settings-label">Public Key</span>
      <code class="mono" style="flex:1;overflow:hidden;text-overflow:ellipsis">${esc(pubkey)}</code>
      <button class="btn btn-small" onclick="copyText('${esc(pubkey)}')">Copy</button>
    </div>
    <div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap">
      <button class="btn btn-small" onclick="toggleKeypairDisplay()">Show Full Keypair</button>
      <button class="btn btn-small" onclick="copyText('${esc(keypair)}')">Copy Full Keypair</button>
      <button class="btn btn-small" onclick="exportKeypairJSON()">Export JSON</button>
    </div>
    <div id="keypair-display" style="display:none;margin-top:8px">
      <div style="background:var(--bg-input);padding:8px;border-radius:var(--radius);border:1px solid var(--border)">
        <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Full Keypair (132 chars) — <span style="color:var(--red);font-weight:600">KEEP SECRET</span></div>
        <code class="mono" style="color:var(--red)">${esc(keypair)}</code>
      </div>
    </div>
  `
}

function toggleKeypairDisplay () {
  const el = document.getElementById('keypair-display')
  el.style.display = el.style.display === 'none' ? 'block' : 'none'
}

function exportKeypairJSON () {
  const keypair = localStorage.getItem('hs-keypair')
  if (!keypair) { alert('No keypair found'); return }

  const data = JSON.stringify({
    publicKey: keypair.substring(0, 44),
    keypair: keypair,
    exportedAt: new Date().toISOString(),
    app: 'hyper-social-web'
  }, null, 2)

  const blob = new Blob([data], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'hyper-social-keypair.json'
  a.click()
  URL.revokeObjectURL(url)
}

function copyText (text) {
  navigator.clipboard.writeText(text).then(() => {
    // Brief visual feedback would be nice but keep it simple
  }).catch(() => {
    // Fallback for older browsers
    const ta = document.createElement('textarea')
    ta.value = text
    document.body.appendChild(ta)
    ta.select()
    document.execCommand('copy')
    document.body.removeChild(ta)
  })
}

// --- Login ---
async function doLogin () {
  if (typeof Identity === 'undefined') { alert('Identity system not loaded'); return }

  // Generate a new keypair for this user
  if (!Identity.publicKey) {
    const keypair = await Identity._generateKeypair()
    localStorage.setItem('hs-keypair', keypair)
    // Re-init to load the new keypair
    await Identity.init(API.base)
  }

  if (!Identity.publicKey) { alert('Failed to generate keypair'); return }

  // Register with the server
  const base = API.base
  const result = await Identity.register(base)
  if (!result || result.error) {
    alert('Registration failed: ' + (result ? result.error : 'unknown error'))
    return
  }

  Identity.markRegistered()
  currentUser = result.user

  // Prompt for username
  const username = prompt('Choose a username (@handle):')
  if (username) {
    try {
      const res = await API.setUsername(username.replace(/^@/, ''))
      if (!res.error) {
        Identity.setUsername(res.user.username)
        currentUser = { ...currentUser, username: res.user.username }
      }
    } catch {}
  }

  await loadIdentityStatus()
  loadSettings()
}

// --- Profile editing ---
async function promptUsername () {
  const username = prompt('Choose a username (lowercase, 3-32 chars, letters/numbers/dashes):')
  if (!username) return

  try {
    const res = await API.setUsername(username)
    if (res.error) { alert('Error: ' + res.error); return }
    if (typeof Identity !== 'undefined') Identity.setUsername(username)
    currentUser = { ...currentUser, username }
    loadSettings()
    loadIdentityStatus()
  } catch (err) {
    alert('Error: ' + err.message)
  }
}

async function promptDisplayName () {
  const name = prompt('Enter display name:', currentUser ? currentUser.name : '')
  if (!name) return

  try {
    const res = await API._fetch('/api/auth/profile', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name })
    })
    const data = await res.json()
    if (data.error) { alert('Error: ' + data.error); return }
    currentUser = { ...currentUser, name }
    loadSettings()
    loadIdentityStatus()
  } catch (err) {
    alert('Error: ' + err.message)
  }
}

// --- Theme ---
function updateThemeButtons () {
  const current = currentUser ? (currentUser.theme || 'dark') : (localStorage.getItem('hs-theme') || 'dark')
  document.querySelectorAll('.theme-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.theme === current)
  })
}

async function setTheme (theme) {
  document.documentElement.setAttribute('data-theme', theme)
  localStorage.setItem('hs-theme', theme)

  if (currentUser) {
    try {
      await API._fetch('/api/auth/profile', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ theme })
      })
      currentUser.theme = theme
    } catch {}
  }
  updateThemeButtons()
}

// --- Device linking ---
function showImportDialog () {
  const input = prompt('Paste your 132-character keypair string:')
  if (!input) return

  if (input.length !== 132) {
    alert('Invalid keypair length. Expected 132 characters (44 public + 88 secret).')
    return
  }

  const newPubkey = input.substring(0, 44)
  const resultEl = document.getElementById('device-link-result')

  // Register the new pubkey with the server (link to current user)
  if (currentUser) {
    API._fetch('/api/auth/import-keypair', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ publicKey: newPubkey })
    }).then(res => res.json()).then(data => {
      if (data.error) {
        showResult(resultEl, 'Import failed: ' + data.error, 'error')
        return
      }
      // Save new keypair locally
      localStorage.setItem('hs-keypair', input)
      localStorage.setItem('hs-pubkey', newPubkey)
      showResult(resultEl, 'Keypair imported. Reloading...', 'success')
      setTimeout(() => location.reload(), 1000)
    }).catch(err => {
      showResult(resultEl, err.message, 'error')
    })
  } else {
    // No user yet — just store locally
    localStorage.setItem('hs-keypair', input)
    showResult(resultEl, 'Keypair imported. Reloading...', 'success')
    setTimeout(() => location.reload(), 1000)
  }
}

function showMergeDialog () {
  const targetKey = prompt('Paste the 44-character public key of the account to merge into yours:')
  if (!targetKey) return

  if (targetKey.length !== 44) {
    alert('Invalid public key length. Expected 44 characters (base64).')
    return
  }

  const resultEl = document.getElementById('device-link-result')

  API._fetch('/api/merge/initiate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ targetPublicKey: targetKey })
  }).then(res => res.json()).then(data => {
    if (data.error) {
      showResult(resultEl, 'Merge failed: ' + data.error, 'error')
      return
    }
    showResult(resultEl, `Merge initiated (ID: ${data.mergeId.substring(0, 8)}...). The other account must confirm by signing challenge: ${data.challenge.substring(0, 16)}... Expires: ${new Date(data.expiresAt).toLocaleTimeString()}`, 'success')
  }).catch(err => {
    showResult(resultEl, err.message, 'error')
  })
}

// --- Logout ---
function logout () {
  if (!confirm('Clear your identity from this browser? You will need your keypair to log back in.')) return
  if (typeof Identity !== 'undefined') Identity.clear()
  location.reload()
}

// --- Timeline ---
async function loadTimeline (append) {
  const container = document.getElementById('timeline')
  const loadMoreBtn = document.getElementById('load-more')
  const isLoggedIn = !!(currentUser && currentUser.userId)

  // Hide filters for anonymous users
  const filtersEl = document.querySelector('.filters')
  const refreshBtn = document.getElementById('refresh-btn')
  if (filtersEl) filtersEl.style.display = isLoggedIn ? 'flex' : 'none'
  if (refreshBtn) refreshBtn.style.display = isLoggedIn ? '' : 'none'

  if (!append) {
    container.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">Loading...</div>'
    oldestTimestamp = null
  }

  const opts = { limit: 30 }
  // Anonymous users only see local (your) posts
  if (!isLoggedIn) {
    opts.source = 'local'
  } else if (currentSource) {
    opts.source = currentSource
  }
  if (append && oldestTimestamp) opts.before = oldestTimestamp

  let data
  try {
    data = await API.timeline(opts)
  } catch (err) {
    container.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Error loading timeline: ' + esc(err.message) + '</div>'
    return
  }

  const items = data.items || []
  if (!append) container.innerHTML = ''

  if (items.length === 0 && !append) {
    container.innerHTML = '<div style="color:var(--text-muted);padding:40px;text-align:center">No posts yet. Connect an account in Settings, then Refresh.</div>'
    loadMoreBtn.style.display = 'none'
    return
  }

  for (const item of items) {
    container.appendChild(renderPost(item))
    oldestTimestamp = item.timestamp
  }

  loadMoreBtn.style.display = items.length >= 30 ? 'block' : 'none'
}

// --- Notifications ---
async function loadNotifications () {
  const container = document.getElementById('notifications')
  container.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">Loading...</div>'

  const data = await API.notifications()
  const notifs = data.notifications || []
  container.innerHTML = ''

  if (notifs.length === 0) {
    container.innerHTML = '<div style="color:var(--text-muted);padding:40px;text-align:center">No notifications</div>'
    return
  }

  for (const n of notifs) {
    container.appendChild(renderNotification(n))
  }
}

// --- Accounts ---
async function loadAccounts () {
  const container = document.getElementById('accounts-list')
  container.innerHTML = ''

  const data = await API.accounts()
  const accounts = data.accounts || []

  if (accounts.length === 0) {
    container.innerHTML = '<div style="color:var(--text-muted);padding:12px">No accounts connected</div>'
    return
  }

  for (const account of accounts) {
    container.appendChild(renderAccountCard(account))
  }
}

async function disconnectAccount (id) {
  if (!confirm('Disconnect this account?')) return
  await API.disconnect(id)
  loadAccounts()
}

// --- Admin ---
async function loadAdmin () {
  const infoEl = document.getElementById('admin-info')
  const usersEl = document.getElementById('admin-users')

  if (!currentUser || !currentUser.admin) {
    infoEl.innerHTML = '<div style="color:var(--red)">Admin access required</div>'
    usersEl.innerHTML = ''
    return
  }

  infoEl.innerHTML = '<div>Logged in as admin: <strong>' + esc(currentUser.username || currentUser.name) + '</strong></div>'
  usersEl.innerHTML = '<div style="color:var(--text-muted)">Loading users...</div>'

  try {
    const data = await API._fetch('/api/admin/users')
    const res = await data.json()
    const users = res.users || []

    if (users.length === 0) {
      usersEl.innerHTML = '<div style="color:var(--text-muted)">No users</div>'
      return
    }

    usersEl.innerHTML = users.map(u => {
      const perms = (u.globalPermissions || []).join(', ')
      const isAdmin = u.globalPermissions && u.globalPermissions.includes('admin')
      const promoteBtn = isAdmin ? '' : ` <button class="btn btn-small" onclick="promoteUser('${esc(u.userId)}')">Promote</button>`
      const isSelf = currentUser && u.userId === currentUser.userId
      const deleteBtn = isSelf ? '' : ` <button class="btn btn-small btn-danger" onclick="deleteUser('${esc(u.userId)}')">Delete</button>`
      return `
        <div class="settings-card">
          <strong>${esc(u.username ? '@' + u.username : u.name || 'Anonymous')}</strong>
          <span style="color:var(--text-muted);font-size:0.85em;margin-left:8px">${esc(u.userId.substring(0, 8))}...</span>
          <span class="perm-badge ${isAdmin ? 'admin' : 'read'}" style="margin-left:8px">${esc(perms)}</span>
          ${promoteBtn}${deleteBtn}
          <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
            Identities: ${u.identities.length} | Created: ${new Date(u.createdAt).toLocaleDateString()}
          </div>
        </div>
      `
    }).join('')
  } catch (err) {
    usersEl.innerHTML = '<div style="color:var(--red)">Error: ' + esc(err.message) + '</div>'
  }
}

async function deleteUser (userId) {
  if (!confirm('Delete this user and all their identities? This cannot be undone.')) return
  try {
    const res = await API._fetch('/api/admin/users/' + encodeURIComponent(userId), { method: 'DELETE' })
    const data = await res.json()
    if (data.error) { alert('Error: ' + data.error); return }
    loadAdmin()
  } catch (err) {
    alert('Error: ' + err.message)
  }
}

async function promoteUser (userId) {
  if (!confirm('Promote this user to admin?')) return
  try {
    const res = await API._fetch('/api/admin/promote', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId })
    })
    const data = await res.json()
    if (data.error) { alert('Error: ' + data.error); return }
    loadAdmin()
  } catch (err) {
    alert('Error: ' + err.message)
  }
}

// --- Helpers ---
function esc (str) {
  if (!str) return ''
  const div = document.createElement('div')
  div.textContent = String(str)
  return div.innerHTML
}

function showResult (el, msg, type) {
  if (!el) return
  el.textContent = msg
  el.className = 'result ' + type
  el.style.display = 'block'
  setTimeout(() => { el.style.display = 'none' }, 8000)
}

// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
  // Apply saved theme
  const savedTheme = localStorage.getItem('hs-theme') || 'dark'
  document.documentElement.setAttribute('data-theme', savedTheme)

  // Initialize identity
  if (typeof Identity !== 'undefined') {
    await Identity.init(API.base)
  }

  await loadIdentityStatus()

  initComposer()

  // Filter buttons
  document.querySelectorAll('.filter-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'))
      btn.classList.add('active')
      currentSource = btn.dataset.source
      loadTimeline()
    })
  })

  // Refresh button
  document.getElementById('refresh-btn').addEventListener('click', async () => {
    const btn = document.getElementById('refresh-btn')
    btn.textContent = 'Refreshing...'
    btn.disabled = true
    try {
      const data = await API.refresh()
      btn.textContent = `Refreshed (${data.refreshed} new)`
      loadTimeline()
    } catch (err) {
      btn.textContent = 'Error'
    }
    setTimeout(() => { btn.textContent = 'Refresh'; btn.disabled = false }, 2000)
  })

  // Load more
  document.getElementById('load-more').addEventListener('click', () => loadTimeline(true))

  // Theme buttons
  document.querySelectorAll('.theme-btn').forEach(btn => {
    btn.addEventListener('click', () => setTheme(btn.dataset.theme))
  })

  // Platform fields toggle
  document.getElementById('connect-platform').addEventListener('change', (e) => {
    document.getElementById('mastodon-fields').style.display = e.target.value === 'mastodon' ? 'block' : 'none'
    document.getElementById('atproto-fields').style.display = e.target.value === 'atproto' ? 'block' : 'none'
  })

  // Connect account
  document.getElementById('connect-btn').addEventListener('click', async () => {
    const platform = document.getElementById('connect-platform').value
    const resultEl = document.getElementById('connect-result')
    let data

    if (platform === 'mastodon') {
      data = {
        platform: 'mastodon',
        instance: document.getElementById('masto-instance').value.trim(),
        token: document.getElementById('masto-token').value.trim()
      }
      if (!data.instance || !data.token) { showResult(resultEl, 'Instance and token required', 'error'); return }
      data.handle = data.instance
    } else if (platform === 'atproto') {
      data = {
        platform: 'atproto',
        handle: document.getElementById('bsky-handle').value.trim(),
        password: document.getElementById('bsky-password').value.trim(),
        instance: document.getElementById('bsky-service').value.trim() || 'https://bsky.social'
      }
      if (!data.handle || !data.password) { showResult(resultEl, 'Handle and app password required', 'error'); return }
    }

    try {
      const res = await API.connect(data)
      if (res.error) {
        showResult(resultEl, res.error, 'error')
      } else {
        showResult(resultEl, 'Connected: ' + (res.account?.id || 'success'), 'success')
        loadAccounts()
      }
    } catch (err) {
      showResult(resultEl, err.message, 'error')
    }
  })

  // Route
  window.addEventListener('hashchange', route)
  route()
})