Files
Capsule/src/renderer/src/main.js
2026-09-12 15:11:21 -04:00

1233 lines
38 KiB
JavaScript
Raw Blame History

/**
* 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')
}
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 notifications = { items: [], unread: 0 }
let messages = { items: [], unread: 0 }
let thread = null
let searchState = { items: [], q: '', resource: 'all', resources: [], page: 1, pages: 0, total: 0 }
let pendingAvatar = 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* 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)
}
/**
* Site path that opens in the system browser.
*
* @param {string} path - path after the site root
* @return {string} - absolute URL, or #
*/
function siteHref(path) {
if (!session?.siteUrl) {
return '#'
}
return `${session.siteUrl.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
}
/**
* Avatar URL for the connected account.
*
* @return {string} - image URL
*/
function avatarUrl() {
if (profile.avatarUrl) {
return profile.avatarUrl
}
if (!session?.siteUrl) {
return ''
}
const path = profile.avatar || 'images/defaultAvatar.png'
if (/^https?:\/\//i.test(path)) {
return path
}
return `${session.siteUrl.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
}
/**
* 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<string|{label: string, value: string}>} 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 : []
profile = {
id: user.id,
username: user.username || session?.username || '',
name: user.name || '',
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
}
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 mailItems = messages.items || []
const unreadNotes = Number(notifications.unread ?? noteItems.filter((item) => item.unread).length)
const unreadMail = Number(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
.slice(0, 5)
.map(
(item) => `
<li data-demo-item>
<a href="#/notifications" class="dropdown-item dropdown-item-block" data-route="notifications">
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${escapeHtml(formatTime(item.createdAt))}</p>
<span>${escapeHtml(item.text || item.html || '')}</span>
</a>
</li>`
)
.join('')
)
fillDropdown(
document.getElementById('message-menu'),
mailItems
.slice(0, 5)
.map(
(item) => `
<li data-demo-item>
<a href="#/messages/${encodeURIComponent(item.id)}" class="dropdown-item dropdown-item-block">
<strong>${escapeHtml(item.otherUser || '')}</strong>
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${escapeHtml(formatTime(item.lastMessageAt))}</p>
<span>${escapeHtml(item.preview || '')}</span>
</a>
</li>`
)
.join('')
)
}
/**
* 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)
}
/**
* 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 `<div class="form-check">
<input class="form-check-input" type="radio" name="mfaMethod" id="${id}" value="${escapeHtml(key)}"${checked} />
<label class="form-check-label" for="${id}">${escapeHtml(label)}</label>
</div>`
})
.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 contactPanel = document.getElementById('contact-panel')
const bugPanel = document.getElementById('bugreport-panel')
contactPanel.hidden = session.preview || !hasPlugin('contact')
bugPanel.hidden = session.preview || !hasPlugin('bugreport')
if (!contactPanel.hidden && profile.name && !document.getElementById('contact-name').value) {
document.getElementById('contact-name').value = profile.name || profile.username || ''
document.getElementById('contact-email').value = profile.email || ''
}
if (!bugPanel.hidden && 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) => `
<tr class="${item.unread ? 'is-unread' : ''}" data-note-id="${escapeHtml(item.id)}">
<td>${escapeHtml(item.text || '')}</td>
<td class="text-muted text-nowrap">${escapeHtml(formatTime(item.createdAt))}</td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-primary" data-note-read="${escapeHtml(item.id)}" title="Mark as read" ${item.unread ? '' : 'disabled'}>
<i class="fa-solid fa-fw fa-envelope-open"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-note-delete="${escapeHtml(item.id)}" title="Delete">
<i class="fa-solid fa-fw fa-trash"></i>
</button>
</td>
</tr>`
)
.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) => `
<tr class="capsule-row-link ${item.unread ? 'is-unread' : ''}" data-message-id="${escapeHtml(item.id)}">
<td>${escapeHtml(item.otherUser || '')}</td>
<td>${escapeHtml(item.preview || '')}</td>
<td class="text-muted">${escapeHtml(formatTime(item.lastMessageAt))}</td>
</tr>`
)
.join('')
}
const displayName = profile.name || profile.username || session.username || ''
document.getElementById('profile-name').textContent = displayName
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-name').value = profile.name || ''
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
? '<div class="text-muted">No results.</div>'
: ''
} else {
results.innerHTML = items
.map((item) => {
const url = item.url || siteHref(item.path || '')
return `<a class="list-group-item list-group-item-action" href="${escapeHtml(url)}" target="_blank" rel="noreferrer">
<strong>${escapeHtml(item.title || item.path || 'Result')}</strong>
<div class="small text-muted">${escapeHtml(item.resource || '')}</div>
<div>${escapeHtml(item.summary || '')}</div>
</a>`
})
.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) => `
<div class="capsule-bubble ${item.mine ? 'is-mine' : ''}">
<div class="small text-muted mb-1">${escapeHtml(item.senderName || '')} <20> ${escapeHtml(formatTime(item.sent))}</div>
<div>${escapeHtml(item.body || '')}</div>
</div>`
)
.join('')
}
/**
* Toggle TTP light/dark tokens on the document.
*
* @param {boolean} dark - dark-mode preference
* @return {void}
*/
function applyTheme(dark) {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
}
/**
* 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 === '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 <20>${q}<EFBFBD> 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 = []
notifications = { items: [], unread: 0 }
messages = { items: [], unread: 0 }
thread = null
profile = { ...demoProfile }
pendingAvatar = null
} 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 }
profile = { ...demoProfile }
plugins = []
} else if (session?.connected && window.capsule) {
loadWorkspace()
}
renderChrome()
renderMfa()
renderPages()
route()
}
/**
* Load profile, notifications, and messages from the site.
*
* @return {Promise<void>}
*/
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 }
renderChrome()
renderPages()
} catch (err) {
if (seq !== workspaceSeq) {
return
}
document.getElementById('home-note').textContent = err?.message || 'Could not load this site.'
}
}
/**
* Load one conversation.
*
* @param {string} id - conversation id
* @return {Promise<void>}
*/
async function loadThread(id) {
setMessage(document.getElementById('thread-error'), '')
try {
const data = await runApi(() => window.capsule.messageView({ id }))
if (!data) {
return
}
thread = data
renderThread()
messages = (await runApi(() => window.capsule.messages({ page: 1 }))) || messages
renderChrome()
renderPages()
showMessagesPane('thread')
} catch (err) {
setMessage(document.getElementById('thread-error'), err?.message || 'Could not load that conversation.')
}
}
/**
* Run site search and paint results.
*
* @param {string} q - query
* @param {string} resource - resource key
* @param {number} page - pager page
* @return {Promise<void>}
*/
async function loadSearch(q, resource, page) {
document.getElementById('search-summary').textContent = q
? `Searching <20>${q}<EFBFBD> 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 <20>${searchState.q}<EFBFBD>.`
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<object>} work - IPC call
* @return {Promise<void>}
*/
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', (event) => {
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, '')
try {
await runApi(() =>
window.capsule.contact({
name: document.getElementById('contact-name').value.trim(),
email: document.getElementById('contact-email').value.trim(),
entry: document.getElementById('contact-entry').value
})
)
document.getElementById('contact-entry').value = ''
setMessage(status, 'Sent.', 'text-success')
} catch (err) {
setMessage(status, err?.message || 'Could not send that message.', '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, '')
try {
await runApi(() =>
window.capsule.bugreport({
url: document.getElementById('bug-url').value.trim(),
ourl: document.getElementById('bug-ourl').value.trim(),
repeat: document.getElementById('bug-repeat').checked,
entry: document.getElementById('bug-entry').value
})
)
document.getElementById('bug-entry').value = ''
setMessage(status, 'Sent.', 'text-success')
} catch (err) {
setMessage(status, err?.message || 'Could not send that report.', '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
profile.name = document.getElementById('settings-name').value
applyTheme(profile.darkMode)
document.getElementById('profile-gender').textContent = profile.gender
setMessage(status, 'Saved in preview only.')
return
}
const fields = {
name: document.getElementById('settings-name').value.trim(),
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
}
pendingAvatar = null
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) {
pendingAvatar = null
return
}
const url = URL.createObjectURL(file)
document.getElementById('settings-avatar').src = url
document.getElementById('profile-avatar').src = url
document.getElementById('header-avatar').src = url
document.getElementById('header-avatar').hidden = false
document.getElementById('header-avatar-fallback').hidden = true
pendingAvatar = {
name: file.name,
type: file.type,
data: await file.arrayBuffer()
}
})
document.getElementById('settings-dark').addEventListener('change', (event) => {
applyTheme(event.target.checked)
})
window.addEventListener('hashchange', route)
/**
* Load a stored session, or stay on login.
*
* @return {Promise<void>}
*/
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()