1233 lines
38 KiB
JavaScript
1233 lines
38 KiB
JavaScript
/**
|
||
* 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, '<')
|
||
.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)
|
||
}
|
||
|
||
/**
|
||
* 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()
|