/** * Login, chrome, and in-app views. Signed-in data comes from the site API. */ import { dateFormatOptions, demoMessages, demoNotifications, demoProfile, pageLimitOptions, previewSession, timeFormatOptions, timezoneOptions } from './demo.js' const views = { login: document.getElementById('view-login'), mfa: document.getElementById('view-mfa'), home: document.getElementById('view-home'), stub: document.getElementById('view-stub'), search: document.getElementById('view-search'), notifications: document.getElementById('view-notifications'), messages: document.getElementById('view-messages'), profile: document.getElementById('view-profile'), settings: document.getElementById('view-settings'), contact: document.getElementById('view-contact'), bugreport: document.getElementById('view-bugreport') } const mainPages = { game: 'Game', friends: 'Friends', cabal: 'Cabal', hiscores: 'HiScores', shop: 'Shop' } const loginForm = document.getElementById('login-form') const loginError = document.getElementById('login-error') const loginSubmit = document.getElementById('login-submit') const loginSite = document.getElementById('login-site') const loginUsername = document.getElementById('login-username') const tokenForm = document.getElementById('token-form') const tokenError = document.getElementById('token-error') const tokenSubmit = document.getElementById('token-submit') const tokenSite = document.getElementById('token-site') const tokenUsername = document.getElementById('token-username') const previewButton = document.getElementById('preview-shell') let session = null let profile = { ...demoProfile } let plugins = [] let features = { contact: { available: false, accepting: false, canSubmit: false }, bugreport: { available: false, accepting: false, canSubmit: false } } let notifications = { items: [], unread: 0 } let messages = { items: [], unread: 0 } let recentMessages = null let canSendMessages = true let thread = null let searchState = { items: [], q: '', resource: 'all', resources: [], page: 1, pages: 0, total: 0 } let pendingAvatar = null let pendingAvatarPreview = null let workspaceSeq = 0 /** * Show or hide a status line. * * @param {HTMLElement} el - message node * @param {string} [message] - text to show; empty hides the node * @param {string} [kind] - optional text-success / text-danger class * @return {void} */ function setMessage(el, message, kind) { const text = String(message || '') el.hidden = text === '' el.textContent = text if (kind) { el.classList.remove('text-success', 'text-danger', 'text-muted') el.classList.add(kind) } } /** * Escape text for HTML attribute and label use. * * @param {string} value - raw text * @return {string} */ function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** * Unix timestamp or already-formatted string. * * @param {string|number} value - time * @return {string} */ function formatTime(value) { if (value == null || value === '') { return '' } if (typeof value === 'string' && !/^\d+$/.test(value)) { return value } const n = Number(value) if (!n) { return '' } return new Date(n * 1000).toLocaleString() } /** * True when the connected site has enabled that plugin folder. * * @param {string} name - plugin folder * @return {boolean} */ function hasPlugin(name) { return plugins.includes(name) } /** * Normalize Capsule feature flags from the API. * * @param {object} [raw] - available, accepting, canSubmit * @return {{available: boolean, accepting: boolean, canSubmit: boolean}} */ function normalizeFeature(raw) { return { available: Boolean(raw?.available), accepting: Boolean(raw?.accepting), canSubmit: Boolean(raw?.canSubmit) } } /** * Status for contact or bugreport from profile, GET, or the plugin list. * * @param {string} name - contact or bugreport * @return {{available: boolean, accepting: boolean, canSubmit: boolean}} */ function featureState(name) { return features[name] || { available: false, accepting: false, canSubmit: false } } /** * True when that footer item should appear (plugin on, accepting, permitted). * * @param {string} name - contact or bugreport * @return {boolean} */ function featureInMenu(name) { if (session?.preview) { return true } const state = featureState(name) return state.available && state.accepting && state.canSubmit } /** * Copy for a contact or bugreport page that is not taking submissions. * * @param {string} name - contact or bugreport * @return {string} */ function featureGateMessage(name) { const state = featureState(name) if (name === 'contact') { if (!state.available) { return 'The contact form is not available.' } if (!state.accepting) { return 'The contact form is not accepting new messages right now.' } if (!state.canSubmit) { return 'You do not have permission to use the contact form.' } return '' } if (!state.available) { return 'Bug reports are not available.' } if (!state.accepting) { return 'New bug reports are not being accepted right now.' } if (!state.canSubmit) { return 'You do not have permission to report bugs.' } return '' } /** * Store contact/bugreport flags from profile JSON or a GET status body. * * @param {object} [body] - GET/POST api/profile or GET api/contact|bugreport * @return {void} */ function applyFeatures(body) { if (body?.features && typeof body.features === 'object') { features = { contact: normalizeFeature(body.features.contact), bugreport: normalizeFeature(body.features.bugreport) } return } features = { contact: featureFromPlugin('contact'), bugreport: featureFromPlugin('bugreport') } } /** * Merge one GET status payload into local feature flags. * * @param {string} name - contact or bugreport * @param {object} [body] - GET api/contact or GET api/bugreport * @return {void} */ function applyFeatureStatus(name, body) { if (body && typeof body === 'object' && !body.error) { if ('available' in body || 'accepting' in body || 'canSubmit' in body) { features = { ...features, [name]: normalizeFeature(body) } return } } features = { ...features, [name]: featureFromPlugin(name) } } /** * Feature flags from the enabled-plugin list when the site has no features blob. * * @param {string} name - plugin folder * @return {{available: boolean, accepting: boolean, canSubmit: boolean}} */ function featureFromPlugin(name) { const on = hasPlugin(name) return { available: on, accepting: on, canSubmit: on } } /** * Site path that opens in the system browser. * * @param {string} path - path after the site root * @return {string} - absolute URL, or # */ function siteHref(path) { const root = session?.siteUrl || session?.lastSiteUrl if (!root) { return '#' } return `${root.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}` } /** * Name for copyright and contact copy. * * @return {string} */ function displaySiteName() { const root = session?.siteUrl || session?.lastSiteUrl if (!root) { return 'Capsule' } try { return new URL(root).hostname.replace(/^www\./i, '') } catch { return 'Capsule' } } /** * Avatar URL for the connected account. * * @return {string} - image URL */ function avatarUrl() { if (pendingAvatarPreview) { return pendingAvatarPreview } return mediaUrl(profile.avatarUrl || profile.avatar || 'images/defaultAvatar.png') } /** * Absolute URL for a site path or already-absolute avatar. * * @param {string} pathOrUrl - stored path or URL * @return {string} */ function mediaUrl(pathOrUrl) { const value = String(pathOrUrl || '') .replace(/\{BASE\}/g, '') .replace(/\\/g, '/') .replace(/^\/+/, '') if (!value) { return '' } if (/^https?:\/\//i.test(value)) { return value } const root = session?.siteUrl || session?.lastSiteUrl if (!root) { return '' } return `${root.replace(/\/+$/, '')}/${value}` } /** * Drop a chosen avatar file and its preview URL. * * @return {void} */ function clearPendingAvatar() { if (pendingAvatarPreview) { URL.revokeObjectURL(pendingAvatarPreview) } pendingAvatarPreview = null pendingAvatar = null } /** * Logo on navy chrome. * * @return {string} - image URL */ function logoUrl() { if (!session?.siteUrl) { return '' } return `${session.siteUrl.replace(/\/+$/, '')}/images/logoWhite.png` } /** * Fill a select from label/value pairs or plain strings. * * @param {HTMLSelectElement} select - target * @param {Array} options - choices * @param {string} selected - current value * @return {void} */ function fillSelect(select, options, selected) { select.innerHTML = '' const seen = new Set() options.forEach((option) => { const value = typeof option === 'string' ? option : option.value const label = typeof option === 'string' ? option : option.label seen.add(value) const node = document.createElement('option') node.value = value node.textContent = label if (value === selected) { node.selected = true } select.appendChild(node) }) if (selected && !seen.has(selected)) { const extra = document.createElement('option') extra.value = selected extra.textContent = selected extra.selected = true select.appendChild(extra) } } /** * Timezone names for the settings select. * * @return {string[]} */ function timezoneList() { if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') { return Intl.supportedValuesOf('timeZone') } return timezoneOptions } /** * Copy API profile fields into local state. * * @param {object} body - GET/POST api/profile JSON * @return {void} */ function applyProfile(body) { const user = body?.user || {} plugins = Array.isArray(body?.plugins) ? body.plugins : [] applyFeatures(body) profile = { id: user.id, username: user.username || session?.username || '', email: user.email || '', avatar: user.avatar || '', avatarUrl: user.avatarUrl || '', gender: user.gender || 'unspecified', newsletter: Boolean(user.newsletter), timezone: user.timezone || 'America/New_York', dateFormat: user.dateFormat || 'F j, Y', timeFormat: user.timeFormat || 'g:i:s A', pageLimit: String(user.pageLimit || '10'), darkMode: Boolean(user.darkMode), registered: formatTime(user.registered) || String(user.registered || ''), lastLogin: formatTime(user.lastLogin) || String(user.lastLogin || ''), group: user.group || '' } if (session && user.username) { session.username = user.username } } /** * True when an IPC error means the stored token is dead. * * @param {string} message - thrown Error message * @return {boolean} */ function isDeadTokenMessage(message) { const text = String(message || '') return ( text.includes('API token was not accepted') || text.includes('API token has expired') || text === 'invalid token' || text === 'token expired' ) } /** * Run a signed-in API IPC call. Logs out when the token is dead. * * @param {() => Promise<*>} work - IPC call * @return {Promise<*|null>} */ async function runApi(work) { if (!window.capsule || session?.preview) { return null } try { return await work() } catch (err) { const msg = err?.message || 'The site returned an error.' if (isDeadTokenMessage(msg)) { applySession(await window.capsule.session()) setMessage(loginError, msg) return null } throw err } } /** * Paint header identity, badges, and site links. * * @return {void} */ function renderChrome() { const connected = Boolean(session?.connected) document.body.dataset.shell = connected ? 'app' : 'login' document.getElementById('header-username').textContent = session?.username || 'Account' const logo = document.getElementById('header-logo') const avatar = document.getElementById('header-avatar') const avatarFallback = document.getElementById('header-avatar-fallback') const nextLogo = logoUrl() const nextAvatar = avatarUrl() logo.onerror = () => { logo.hidden = true } avatar.onerror = () => { avatar.hidden = true avatarFallback.hidden = false } avatar.onload = () => { avatar.hidden = false avatarFallback.hidden = true } logo.hidden = !nextLogo if (nextLogo) { logo.src = nextLogo } avatar.hidden = !nextAvatar avatarFallback.hidden = Boolean(nextAvatar) if (nextAvatar) { avatar.src = nextAvatar document.getElementById('profile-avatar').src = nextAvatar document.getElementById('settings-avatar').src = nextAvatar } const email = siteHref('usercp/email') const password = siteHref('usercp/password') const phone = siteHref('usercp/phone') document.getElementById('link-email').href = email document.getElementById('link-password').href = password document.getElementById('link-phone').href = phone document.getElementById('settings-link-email').href = email document.getElementById('settings-link-password').href = password document.getElementById('settings-link-phone').href = phone const noteItems = notifications.items || [] const mailMenu = dropdownMail() const mailItems = mailMenu.items || [] const unreadNotes = Number(notifications.unread ?? noteItems.filter((item) => item.unread).length) const unreadMail = Number(mailMenu.unread ?? messages.unread ?? mailItems.filter((item) => item.unread).length) const noteBadge = document.getElementById('notification-badge') const mailBadge = document.getElementById('message-badge') noteBadge.hidden = unreadNotes === 0 mailBadge.hidden = unreadMail === 0 noteBadge.textContent = String(unreadNotes) mailBadge.textContent = String(unreadMail) fillDropdown( document.getElementById('notification-menu'), noteItems.length ? noteItems .slice(0, 5) .map( (item) => `
  • ${escapeHtml(formatTime(item.createdAt))}

    ${escapeHtml(item.text || item.html || '')}
  • ` ) .join('') : `
  • No Notifications
  • ` ) fillDropdown( document.getElementById('message-menu'), mailItems.length ? mailItems .slice(0, 5) .map((item) => { const name = escapeHtml(item.otherUserPretty || item.otherUser || '') const src = escapeHtml(item.otherAvatarUrl || mediaUrl(item.otherAvatar || '')) const img = src ? `` : '' return `
  • ${img}
    ${name}

    ${escapeHtml(formatTime(item.lastMessageAt))}

    ${escapeHtml(item.preview || '')}
  • ` }) .join('') : `
  • No Messages
  • ` ) renderFooter() } /** * Copyright, socials, legal links, and contact/bug items. * * @return {void} */ function renderFooter() { document.getElementById('footer-year').textContent = String(new Date().getFullYear()) const sitename = displaySiteName() document.getElementById('footer-sitename').textContent = sitename document.getElementById('contact-sitename').textContent = sitename document.querySelectorAll('[data-footer-path]').forEach((link) => { const href = siteHref(link.dataset.footerPath) link.href = href if (href === '#') { link.removeAttribute('target') } else { link.setAttribute('target', '_blank') } }) const contactOn = featureInMenu('contact') const bugOn = featureInMenu('bugreport') document.getElementById('footer-link-contact').hidden = !contactOn document.getElementById('footer-link-bug').hidden = !bugOn document.getElementById('footer-contact-col').hidden = !contactOn && !bugOn } /** * Replace demo rows at the top of a header dropdown. * * @param {HTMLElement} list - dropdown ul * @param {string} html - li markup * @return {void} */ function fillDropdown(list, html) { list.querySelectorAll('[data-demo-item]').forEach((node) => node.remove()) list.insertAdjacentHTML('afterbegin', html) } /** * Recent conversations for the header menu, or the inbox page if recent is missing. * * @return {object} */ function dropdownMail() { if (recentMessages && !recentMessages.unavailable) { return recentMessages } return messages } /** * Apply canSend from an API payload when present. * * @param {object} [payload] - messages JSON * @return {void} */ function applyCanSend(payload) { if (payload && typeof payload.canSend === 'boolean') { canSendMessages = payload.canSend } } /** * Paint the MFA picker or code form from a pending challenge. * * @return {void} */ function renderMfa() { if (!session?.pendingMfa) { return } const mfa = session.mfa || {} const method = mfa.method || '' const methods = Array.isArray(mfa.methods) ? mfa.methods : [] document.getElementById('mfa-prompt').textContent = mfa.prompt || 'Choose how you want to authenticate.' document.getElementById('mfa-method-form').hidden = Boolean(method) document.getElementById('mfa-code-form').hidden = !method document.getElementById('mfa-reset').hidden = !method || methods.length < 2 setMessage(document.getElementById('mfa-method-error'), '') setMessage(document.getElementById('mfa-code-error'), '') if (!method) { document.getElementById('mfa-code').value = '' } document.getElementById('mfa-methods').innerHTML = methods .map((item, index) => { const key = item.key || item const label = item.label || item.key || item const id = `mfa-method-${escapeHtml(key)}` const checked = index === 0 ? ' checked' : '' return `
    ` }) .join('') } /** * Fill in-app pages from workspace (or preview) data. * * @return {void} */ function renderPages() { if (!session?.connected) { return } document.getElementById('home-username').textContent = session.username || 'this account' const siteLink = document.getElementById('home-site') siteLink.textContent = session.siteUrl siteLink.href = session.siteUrl document.getElementById('home-note').textContent = session.preview ? 'Preview chrome. Sign in to a live site to load API data.' : session.apiReady === false ? 'Connected, but this token cannot use the user API (needs a personal token).' : 'Search, notifications, messages, and profile load from this site.' document.getElementById('home-plugins').textContent = plugins.length ? `Enabled plugins: ${plugins.join(', ')}` : 'Plugin list will show after profile loads.' const contactOk = featureInMenu('contact') const bugOk = featureInMenu('bugreport') document.getElementById('contact-body').hidden = !contactOk document.getElementById('bug-body').hidden = !bugOk const contactUnavailable = document.getElementById('contact-unavailable') const bugUnavailable = document.getElementById('bug-unavailable') contactUnavailable.hidden = contactOk bugUnavailable.hidden = bugOk contactUnavailable.textContent = contactOk ? '' : featureGateMessage('contact') bugUnavailable.textContent = bugOk ? '' : featureGateMessage('bugreport') if (contactOk && !document.getElementById('contact-email').value && profile.email) { document.getElementById('contact-email').value = profile.email } if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) { document.getElementById('bug-url').value = session.siteUrl } const noteEmpty = document.getElementById('notifications-empty') if (notifications.unavailable || notifications.error) { noteEmpty.hidden = false noteEmpty.textContent = notifications.error || 'Notifications are not available on this site.' document.getElementById('notification-list').innerHTML = '' } else if ((notifications.items || []).length === 0) { noteEmpty.hidden = false noteEmpty.textContent = 'No notifications.' document.getElementById('notification-list').innerHTML = '' } else { noteEmpty.hidden = true document.getElementById('notification-list').innerHTML = notifications.items .map( (item) => ` ${escapeHtml(item.text || '')} ${escapeHtml(formatTime(item.createdAt))} ` ) .join('') } const mailEmpty = document.getElementById('messages-empty') if (messages.unavailable || messages.error) { mailEmpty.hidden = false mailEmpty.textContent = messages.error || 'Messages are not available on this site.' document.getElementById('message-list').innerHTML = '' } else if ((messages.items || []).length === 0) { mailEmpty.hidden = false mailEmpty.textContent = 'No conversations yet.' document.getElementById('message-list').innerHTML = '' } else { mailEmpty.hidden = true document.getElementById('message-list').innerHTML = messages.items .map((item) => { const name = escapeHtml(item.otherUserPretty || item.otherUser || '') const id = escapeHtml(item.id) return ` ${name} ${escapeHtml(item.preview || '')} ${escapeHtml(formatTime(item.lastMessageAt))} ` }) .join('') } document.getElementById('messages-new').hidden = !canSendMessages || Boolean(messages.unavailable) document.getElementById('thread-reply').hidden = !canSendMessages document.getElementById('profile-name').textContent = profile.username || session.username || '' document.getElementById('profile-registered').textContent = profile.registered || '' document.getElementById('profile-last-login').textContent = profile.lastLogin || '' document.getElementById('profile-gender').textContent = profile.gender || '' document.getElementById('settings-gender').value = profile.gender document.getElementById('settings-newsletter').checked = profile.newsletter document.getElementById('settings-dark').checked = profile.darkMode fillSelect(document.getElementById('settings-timezone'), timezoneList(), profile.timezone) fillSelect(document.getElementById('settings-date'), dateFormatOptions, profile.dateFormat) fillSelect(document.getElementById('settings-time'), timeFormatOptions, profile.timeFormat) fillSelect(document.getElementById('settings-limit'), pageLimitOptions, profile.pageLimit) applyTheme(profile.darkMode) renderSearch() renderThread() } /** * Search hits and pager. * * @return {void} */ function renderSearch() { const error = searchState.error || '' setMessage(document.getElementById('search-error'), error) const results = document.getElementById('search-results') const items = searchState.items || [] if (!items.length) { results.innerHTML = searchState.q ? '
    No results.
    ' : '' } else { results.innerHTML = items .map((item) => { const url = item.url || siteHref(item.path || '') return ` ${escapeHtml(item.title || item.path || 'Result')}
    ${escapeHtml(item.resource || '')}
    ${escapeHtml(item.summary || '')}
    ` }) .join('') } const pager = document.getElementById('search-pager') const pages = Number(searchState.pages || 0) pager.hidden = pages <= 1 document.getElementById('search-page-label').textContent = pages > 1 ? `Page ${searchState.page || 1} of ${pages}` : '' document.getElementById('search-prev').disabled = Number(searchState.page || 1) <= 1 document.getElementById('search-next').disabled = Number(searchState.page || 1) >= pages if (Array.isArray(searchState.resources) && searchState.resources.length) { fillSelect( document.getElementById('search-resource'), searchState.resources.map((item) => ({ value: item.key || item.value || item, label: item.label || item.key || item })), searchState.resource || 'all' ) } } /** * Conversation thread pane. * * @return {void} */ function renderThread() { const lines = document.getElementById('thread-lines') if (!thread) { lines.innerHTML = '' document.getElementById('thread-title').textContent = '' return } document.getElementById('thread-title').textContent = thread.conversation?.otherUser ? `With ${thread.conversation.otherUser}` : 'Conversation' lines.innerHTML = (thread.messages || []) .map( (item) => `
    ${escapeHtml(item.senderName || '')} ${escapeHtml(formatTime(item.sent))}
    ${escapeHtml(item.body || '')}
    ` ) .join('') } /** * Toggle TTP light/dark tokens on the document. * * @param {boolean} dark - dark-mode preference * @return {void} */ function applyTheme(dark) { const on = Boolean(dark) document.documentElement.setAttribute('data-bs-theme', on ? 'dark' : 'light') const footerToggle = document.getElementById('dark-mode-toggle') const settingsToggle = document.getElementById('settings-dark') if (footerToggle) { footerToggle.checked = on } if (settingsToggle) { settingsToggle.checked = on } } /** * Apply dark mode and save it on a live session. * * @param {boolean} dark - dark-mode preference * @param {boolean} persist - POST api/profile/update when signed in * @return {Promise} */ async function setDarkMode(dark, persist) { profile.darkMode = Boolean(dark) applyTheme(profile.darkMode) if (!persist || session?.preview || !window.capsule || !session?.connected) { return } try { const data = await runApi(() => window.capsule.updateProfile({ fields: { darkMode: profile.darkMode ? '1' : '0' } }) ) if (data) { applyProfile(data) applyTheme(profile.darkMode) } } catch { applyTheme(profile.darkMode) } } /** * Show one named view. * * @param {string} name - views key * @return {void} */ function showView(name) { Object.entries(views).forEach(([key, node]) => { if (node) { node.hidden = key !== name } }) } /** * Mark the matching main-nav link, or none. * * @param {string} path - hash path without query * @return {void} */ function setMainNav(path) { document.querySelectorAll('.capsule-mainnav [data-nav]').forEach((link) => { link.classList.toggle('active', link.dataset.nav === path) }) } /** * Show inbox, compose, or thread inside the messages view. * * @param {string} which - inbox, compose, thread * @return {void} */ function showMessagesPane(which) { document.getElementById('messages-inbox').hidden = which !== 'inbox' document.getElementById('messages-compose').hidden = which !== 'compose' document.getElementById('messages-thread').hidden = which !== 'thread' } /** * Route from the hash. Login when there is no session. * * @return {void} */ function route() { if (session?.pendingMfa) { showView('mfa') return } if (!session?.connected) { showView('login') return } const hash = window.location.hash.replace(/^#\/?/, '') const [path, query] = hash.split('?') setMainNav(path.split('/')[0] || 'dashboard') if (mainPages[path]) { document.getElementById('stub-title').textContent = mainPages[path] showView('stub') return } if (path === 'notifications') { showView('notifications') return } if (path === 'messages') { showMessagesPane('inbox') showView('messages') return } if (path === 'messages/new') { showMessagesPane('compose') showView('messages') return } if (path.startsWith('messages/')) { const id = decodeURIComponent(path.slice('messages/'.length)) if (!id) { showMessagesPane('inbox') showView('messages') return } showMessagesPane('thread') showView('messages') if (!session.preview && String(thread?.conversation?.id) !== String(id)) { loadThread(id) } return } if (path === 'profile') { showView('profile') return } if (path === 'settings') { showView('settings') return } if (path === 'contact') { showView('contact') if (!session.preview) { loadFeatureStatus('contact') } return } if (path === 'bugreport') { showView('bugreport') if (!session.preview) { loadFeatureStatus('bugreport') } return } if (path === 'search') { showView('search') const params = new URLSearchParams(query || '') const q = params.get('q') || '' const resource = params.get('resource') || 'all' const page = Number(params.get('page') || 1) document.getElementById('search-q').value = q if (resource) { document.getElementById('search-resource').value = resource } if (!session.preview) { loadSearch(q, resource, page) } else { document.getElementById('search-summary').textContent = q ? `Preview search for ${q} in ${resource}.` : `Choose a term to search ${resource}.` } return } showView('home') } /** * Apply a public session and go to the current route. * * @param {object} next - connection state * @return {void} */ function applySession(next) { session = next if (!session?.connected) { window.location.hash = '' applyTheme(false) plugins = [] features = { contact: { available: false, accepting: false, canSubmit: false }, bugreport: { available: false, accepting: false, canSubmit: false } } notifications = { items: [], unread: 0 } messages = { items: [], unread: 0 } recentMessages = null canSendMessages = true thread = null profile = { ...demoProfile } clearPendingAvatar() } else if (!window.location.hash) { window.location.hash = '#/' } if (loginSite && session?.lastSiteUrl) { loginSite.value = session.lastSiteUrl tokenSite.value = session.lastSiteUrl } if (session?.pendingMfa) { document.getElementById('login-password').value = '' } if (session?.preview) { notifications = { items: demoNotifications, unread: demoNotifications.filter((item) => item.unread).length } messages = { items: demoMessages, unread: demoMessages.filter((item) => item.unread).length } recentMessages = { items: demoMessages, unread: messages.unread } canSendMessages = true profile = { ...demoProfile } plugins = ['contact', 'bugreport'] features = { contact: { available: true, accepting: true, canSubmit: true }, bugreport: { available: true, accepting: true, canSubmit: true } } } else if (session?.connected && window.capsule) { loadWorkspace() } renderChrome() renderMfa() renderPages() route() } /** * Load profile, notifications, and messages from the site. * * @return {Promise} */ async function loadWorkspace() { const seq = ++workspaceSeq try { const data = await runApi(() => window.capsule.workspace()) if (!data || seq !== workspaceSeq || !session?.connected || session.preview) { return } if (data.session) { session = { ...session, ...data.session } } applyProfile(data.profile) notifications = data.notifications || { items: [], unread: 0 } messages = data.messages || { items: [], unread: 0 } recentMessages = data.recentMessages || null applyCanSend(messages) applyCanSend(recentMessages) renderChrome() renderPages() } catch (err) { if (seq !== workspaceSeq) { return } document.getElementById('home-note').textContent = err?.message || 'Could not load this site.' } } /** * Recheck whether contact or bug reports are accepting submissions. * * @param {string} name - contact or bugreport * @return {Promise} */ async function loadFeatureStatus(name) { if (!window.capsule || session?.preview) { return } try { const data = await runApi(() => name === 'contact' ? window.capsule.contactStatus() : window.capsule.bugreportStatus() ) if (!data) { return } applyFeatureStatus(name, data) renderChrome() renderPages() } catch { applyFeatureStatus(name, null) renderChrome() renderPages() } } /** * Load one conversation. * * @param {string} id - conversation id * @return {Promise} */ async function loadThread(id) { setMessage(document.getElementById('thread-error'), '') try { const data = await runApi(() => window.capsule.messageView({ id })) if (!data) { return } thread = data applyCanSend(data) renderThread() await refreshMail() showMessagesPane('thread') } catch (err) { setMessage(document.getElementById('thread-error'), err?.message || 'Could not load that conversation.') } } /** * Reload inbox and dropdown conversations. * * @return {Promise} */ async function refreshMail() { if (!window.capsule || session?.preview) { renderChrome() renderPages() return } const [list, recent] = await Promise.all([ runApi(() => window.capsule.messages({ page: 1 })), runApi(() => window.capsule.messagesRecent({ limit: 5 })) ]) if (list) { messages = list applyCanSend(list) } if (recent) { recentMessages = recent applyCanSend(recent) } renderChrome() renderPages() } /** * Run site search and paint results. * * @param {string} q - query * @param {string} resource - resource key * @param {number} page - pager page * @return {Promise} */ async function loadSearch(q, resource, page) { document.getElementById('search-summary').textContent = q ? `Searching ${q} in ${resource}.` : `Choose a term to search ${resource}.` if (!q) { searchState = { ...searchState, items: [], q, resource, page: 1, pages: 0, error: '' } renderSearch() return } try { const data = await runApi(() => window.capsule.search({ q, resource, page })) if (!data) { return } searchState = { items: data.items || [], q: data.q || q, resource: data.resource || resource, resources: data.resources || searchState.resources, page: data.page || page, pages: data.pages || 0, total: data.total || 0, error: '' } document.getElementById('search-summary').textContent = `${searchState.total} result${searchState.total === 1 ? '' : 's'} for ${searchState.q}.` renderSearch() } catch (err) { searchState = { ...searchState, items: [], q, resource, error: err?.message || 'Search failed.' } renderSearch() } } /** * Run an auth IPC call. * * @param {HTMLButtonElement} button - submit button * @param {HTMLElement} errorEl - error line * @param {() => Promise} work - IPC call * @return {Promise} */ async function runAuth(button, errorEl, work) { button.disabled = true setMessage(errorEl, '') try { applySession(await work()) } catch (err) { const msg = err?.message || 'Sign-in failed.' if (/expired/i.test(msg) && session?.pendingMfa) { applySession({ connected: false, lastSiteUrl: session.siteUrl || session.lastSiteUrl || '' }) setMessage(loginError, msg) return } setMessage(errorEl, msg) } finally { button.disabled = false } } loginForm.addEventListener('submit', (event) => { event.preventDefault() if (!window.capsule) { setMessage(loginError, 'Preload bridge is missing. Restart Capsule.') return } runAuth(loginSubmit, loginError, () => window.capsule.login({ siteUrl: loginSite.value, username: loginUsername.value, password: document.getElementById('login-password').value }) ) }) document.getElementById('mfa-code-form').addEventListener('submit', (event) => { event.preventDefault() if (!window.capsule) { setMessage(document.getElementById('mfa-code-error'), 'Preload bridge is missing. Restart Capsule.') return } runAuth(document.getElementById('mfa-code-submit'), document.getElementById('mfa-code-error'), () => window.capsule.mfaChallenge({ authCode: document.getElementById('mfa-code').value }) ) }) document.getElementById('mfa-method-form').addEventListener('submit', (event) => { event.preventDefault() if (!window.capsule) { setMessage(document.getElementById('mfa-method-error'), 'Preload bridge is missing. Restart Capsule.') return } const selected = document.querySelector('#mfa-methods input[name="mfaMethod"]:checked') runAuth(document.getElementById('mfa-method-submit'), document.getElementById('mfa-method-error'), () => window.capsule.mfaSelect({ method: selected?.value || '' }) ) }) document.getElementById('mfa-reset').addEventListener('click', () => { if (!window.capsule) { setMessage(document.getElementById('mfa-code-error'), 'Preload bridge is missing. Restart Capsule.') return } runAuth(document.getElementById('mfa-reset'), document.getElementById('mfa-code-error'), () => window.capsule.mfaReset() ) }) document.getElementById('mfa-cancel').addEventListener('click', async () => { if (!window.capsule) { applySession({ connected: false, lastSiteUrl: session?.siteUrl || session?.lastSiteUrl || '' }) return } applySession(await window.capsule.mfaCancel()) }) tokenForm.addEventListener('submit', (event) => { event.preventDefault() if (!window.capsule) { setMessage(tokenError, 'Preload bridge is missing. Restart Capsule.') return } runAuth(tokenSubmit, tokenError, () => window.capsule.connectToken({ siteUrl: tokenSite.value, token: document.getElementById('token-value').value, username: tokenUsername.value }) ) }) previewButton.addEventListener('click', () => { applySession({ ...previewSession }) }) document.getElementById('logout-button').addEventListener('click', async () => { if (session?.preview || !window.capsule) { applySession({ connected: false, lastSiteUrl: session?.siteUrl || '' }) return } applySession(await window.capsule.logout()) }) document.getElementById('header-search').addEventListener('submit', (event) => { event.preventDefault() const q = document.getElementById('search-q').value.trim() const resource = document.getElementById('search-resource').value const params = new URLSearchParams({ q, resource }) const next = `#/search?${params.toString()}` if (window.location.hash === next) { route() return } window.location.hash = next }) document.getElementById('search-prev').addEventListener('click', () => { const page = Math.max(1, Number(searchState.page || 1) - 1) const params = new URLSearchParams({ q: searchState.q || document.getElementById('search-q').value, resource: searchState.resource || 'all', page: String(page) }) window.location.hash = `#/search?${params.toString()}` }) document.getElementById('search-next').addEventListener('click', () => { const page = Number(searchState.page || 1) + 1 const params = new URLSearchParams({ q: searchState.q || document.getElementById('search-q').value, resource: searchState.resource || 'all', page: String(page) }) window.location.hash = `#/search?${params.toString()}` }) document.getElementById('notification-list').addEventListener('click', async (event) => { const read = event.target.closest('[data-note-read]') const del = event.target.closest('[data-note-delete]') if (!read && !del) { return } const id = read?.dataset.noteRead || del?.dataset.noteDelete try { if (read) { await runApi(() => window.capsule.notificationRead({ id })) } else { await runApi(() => window.capsule.notificationDelete({ id })) } notifications = (await runApi(() => window.capsule.notifications({ page: 1 }))) || notifications renderChrome() renderPages() } catch (err) { setMessage(document.getElementById('notifications-empty'), err?.message || 'Could not update that notification.') document.getElementById('notifications-empty').hidden = false } }) document.getElementById('message-list').addEventListener('click', async (event) => { const read = event.target.closest('[data-message-read]') const unread = event.target.closest('[data-message-unread]') const del = event.target.closest('[data-message-delete]') if (read || unread || del) { event.preventDefault() event.stopPropagation() const id = read?.dataset.messageRead || unread?.dataset.messageUnread || del?.dataset.messageDelete try { if (session?.preview || !window.capsule) { messages.items = (messages.items || []).filter((item) => { if (String(item.id) !== String(id)) { return true } if (del) { return false } item.unread = Boolean(unread) return true }) messages.unread = (messages.items || []).filter((item) => item.unread).length if (recentMessages?.items) { recentMessages.items = messages.items.slice(0, 5) recentMessages.unread = messages.unread } renderChrome() renderPages() return } if (read) { await runApi(() => window.capsule.messageRead({ id })) } else if (unread) { await runApi(() => window.capsule.messageUnread({ id })) } else { await runApi(() => window.capsule.messageDelete({ id })) } await refreshMail() } catch (err) { setMessage(document.getElementById('messages-empty'), err?.message || 'Could not update that conversation.') document.getElementById('messages-empty').hidden = false } return } const row = event.target.closest('[data-message-id]') if (!row) { return } window.location.hash = `#/messages/${encodeURIComponent(row.dataset.messageId)}` }) document.getElementById('thread-back').addEventListener('click', () => { thread = null window.location.hash = '#/messages' }) document.getElementById('compose-form').addEventListener('submit', async (event) => { event.preventDefault() const button = event.target.querySelector('button[type="submit"]') button.disabled = true setMessage(document.getElementById('compose-error'), '') try { const data = await runApi(() => window.capsule.messageCreate({ toUser: document.getElementById('compose-to').value.trim(), message: document.getElementById('compose-body').value }) ) if (!data) { return } document.getElementById('compose-form').reset() window.location.hash = `#/messages/${encodeURIComponent(data.conversation?.id || '')}` } catch (err) { setMessage(document.getElementById('compose-error'), err?.message || 'Could not send that message.') } finally { button.disabled = false } }) document.getElementById('thread-reply').addEventListener('submit', async (event) => { event.preventDefault() const id = thread?.conversation?.id if (!id) { return } const button = event.target.querySelector('button[type="submit"]') button.disabled = true setMessage(document.getElementById('thread-error'), '') try { await runApi(() => window.capsule.messageReply({ id, message: document.getElementById('thread-body').value }) ) document.getElementById('thread-body').value = '' await loadThread(id) } catch (err) { setMessage(document.getElementById('thread-error'), err?.message || 'Could not send that reply.') } finally { button.disabled = false } }) document.getElementById('contact-form').addEventListener('submit', async (event) => { event.preventDefault() const status = document.getElementById('contact-status') const button = event.target.querySelector('button[type="submit"]') button.disabled = true setMessage(status, '') if (!featureInMenu('contact')) { setMessage(status, featureGateMessage('contact'), 'text-danger') button.disabled = false return } if (session?.preview || !window.capsule) { setMessage(status, 'Saved in preview only.', 'text-muted') button.disabled = false return } try { await runApi(() => window.capsule.contact({ name: document.getElementById('contact-name').value.trim(), contactEmail: document.getElementById('contact-email').value.trim(), entry: document.getElementById('contact-entry').value }) ) document.getElementById('contact-entry').value = '' setMessage(status, 'Sent.', 'text-success') } catch (err) { const msg = err?.message || 'Could not send that message.' if (/disabled|not available|permission/i.test(msg)) { loadFeatureStatus('contact') } setMessage(status, msg, 'text-danger') } finally { button.disabled = false } }) document.getElementById('bugreport-form').addEventListener('submit', async (event) => { event.preventDefault() const status = document.getElementById('bug-status') const button = event.target.querySelector('button[type="submit"]') button.disabled = true setMessage(status, '') if (!featureInMenu('bugreport')) { setMessage(status, featureGateMessage('bugreport'), 'text-danger') button.disabled = false return } if (session?.preview || !window.capsule) { setMessage(status, 'Saved in preview only.', 'text-muted') button.disabled = false return } try { const url = document.getElementById('bug-url').value.trim() const ourl = document.getElementById('bug-ourl').value.trim() || url await runApi(() => window.capsule.bugreport({ url, ourl, repeat: document.getElementById('bug-repeat-yes').checked, entry: document.getElementById('bug-entry').value }) ) document.getElementById('bug-entry').value = '' setMessage(status, 'Sent.', 'text-success') } catch (err) { const msg = err?.message || 'Could not send that report.' if (/disabled|not available|permission/i.test(msg)) { loadFeatureStatus('bugreport') } setMessage(status, msg, 'text-danger') } finally { button.disabled = false } }) document.getElementById('settings-form').addEventListener('submit', async (event) => { event.preventDefault() const status = document.getElementById('settings-status') const errorEl = document.getElementById('settings-error') setMessage(status, '') setMessage(errorEl, '') if (session?.preview || !window.capsule) { profile.gender = document.getElementById('settings-gender').value profile.newsletter = document.getElementById('settings-newsletter').checked profile.timezone = document.getElementById('settings-timezone').value profile.dateFormat = document.getElementById('settings-date').value profile.timeFormat = document.getElementById('settings-time').value profile.pageLimit = document.getElementById('settings-limit').value profile.darkMode = document.getElementById('settings-dark').checked applyTheme(profile.darkMode) document.getElementById('profile-gender').textContent = profile.gender setMessage(status, 'Saved in preview only.') return } const fields = { gender: document.getElementById('settings-gender').value, newsletter: document.getElementById('settings-newsletter').checked ? '1' : '0', timezone: document.getElementById('settings-timezone').value, dateFormat: document.getElementById('settings-date').value, timeFormat: document.getElementById('settings-time').value, pageLimit: document.getElementById('settings-limit').value, darkMode: document.getElementById('settings-dark').checked ? '1' : '0' } try { const data = await runApi(() => window.capsule.updateProfile({ fields, avatar: pendingAvatar })) if (!data) { return } clearPendingAvatar() document.getElementById('settings-avatar-file').value = '' applyProfile(data) renderChrome() renderPages() setMessage(status, 'Saved.') } catch (err) { setMessage(errorEl, err?.message || 'Could not save settings.') } }) document.getElementById('settings-avatar-file').addEventListener('change', async (event) => { const file = event.target.files?.[0] if (!file) { clearPendingAvatar() renderChrome() return } if (pendingAvatarPreview) { URL.revokeObjectURL(pendingAvatarPreview) } pendingAvatarPreview = URL.createObjectURL(file) pendingAvatar = { name: file.name, type: file.type, data: await file.arrayBuffer() } renderChrome() }) document.getElementById('settings-dark').addEventListener('change', (event) => { setDarkMode(event.target.checked, true) }) document.getElementById('dark-mode-toggle').addEventListener('change', (event) => { setDarkMode(event.target.checked, true) }) window.addEventListener('hashchange', route) /** * Load a stored session, or stay on login. * * @return {Promise} */ async function boot() { if (!window.capsule) { applySession({ connected: false }) setMessage(loginError, '') return } const stored = await window.capsule.session() if (stored.connected) { applySession(await window.capsule.verify()) return } applySession(stored) } boot()