/**
* 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">👤</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 ? ' · 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': '🌐',
'ssh-key': '🔒',
'keet-identity': '💳',
'bearer-token': '🔑'
}
el.innerHTML = currentProfile.identities.map(id => `
<div class="identity-item">
<span>${methodIcons[id.method] || '👤'}</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()
})