|
|
|
|
@ -52,9 +52,12 @@ let profile = { ...demoProfile }
|
|
|
|
|
let plugins = []
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@ -156,17 +159,47 @@ function displaySiteName() {
|
|
|
|
|
* @return {string} - image URL
|
|
|
|
|
*/
|
|
|
|
|
function avatarUrl() {
|
|
|
|
|
if (profile.avatarUrl) {
|
|
|
|
|
return profile.avatarUrl
|
|
|
|
|
if (pendingAvatarPreview) {
|
|
|
|
|
return pendingAvatarPreview
|
|
|
|
|
}
|
|
|
|
|
if (!session?.siteUrl) {
|
|
|
|
|
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 ''
|
|
|
|
|
}
|
|
|
|
|
const path = profile.avatar || 'images/defaultAvatar.png'
|
|
|
|
|
if (/^https?:\/\//i.test(path)) {
|
|
|
|
|
return path
|
|
|
|
|
if (/^https?:\/\//i.test(value)) {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
return `${session.siteUrl.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@ -319,6 +352,10 @@ function renderChrome() {
|
|
|
|
|
avatar.hidden = true
|
|
|
|
|
avatarFallback.hidden = false
|
|
|
|
|
}
|
|
|
|
|
avatar.onload = () => {
|
|
|
|
|
avatar.hidden = false
|
|
|
|
|
avatarFallback.hidden = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logo.hidden = !nextLogo
|
|
|
|
|
if (nextLogo) {
|
|
|
|
|
@ -344,9 +381,10 @@ function renderChrome() {
|
|
|
|
|
document.getElementById('settings-link-phone').href = phone
|
|
|
|
|
|
|
|
|
|
const noteItems = notifications.items || []
|
|
|
|
|
const mailItems = messages.items || []
|
|
|
|
|
const mailMenu = dropdownMail()
|
|
|
|
|
const mailItems = mailMenu.items || []
|
|
|
|
|
const unreadNotes = Number(notifications.unread ?? noteItems.filter((item) => item.unread).length)
|
|
|
|
|
const unreadMail = Number(messages.unread ?? mailItems.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
|
|
|
|
|
@ -356,35 +394,49 @@ function renderChrome() {
|
|
|
|
|
|
|
|
|
|
fillDropdown(
|
|
|
|
|
document.getElementById('notification-menu'),
|
|
|
|
|
noteItems
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
.map(
|
|
|
|
|
(item) => `
|
|
|
|
|
noteItems.length
|
|
|
|
|
? 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('')
|
|
|
|
|
)
|
|
|
|
|
.join('')
|
|
|
|
|
: `<li data-demo-item class="px-3 text-center"><strong>No Notifications</strong></li>`
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fillDropdown(
|
|
|
|
|
document.getElementById('message-menu'),
|
|
|
|
|
mailItems
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
.map(
|
|
|
|
|
(item) => `
|
|
|
|
|
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
|
|
|
|
|
? `<img class="capsule-drop-avatar" src="${src}" alt="" width="40" height="40" />`
|
|
|
|
|
: ''
|
|
|
|
|
return `
|
|
|
|
|
<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>
|
|
|
|
|
<div class="d-flex gap-2">
|
|
|
|
|
${img}
|
|
|
|
|
<div>
|
|
|
|
|
<strong>${name}</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>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</a>
|
|
|
|
|
</li>`
|
|
|
|
|
)
|
|
|
|
|
.join('')
|
|
|
|
|
})
|
|
|
|
|
.join('')
|
|
|
|
|
: `<li data-demo-item class="px-3 text-center"><strong>No Messages</strong></li>`
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
renderFooter()
|
|
|
|
|
@ -430,6 +482,30 @@ function fillDropdown(list, html) {
|
|
|
|
|
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.
|
|
|
|
|
*
|
|
|
|
|
@ -549,17 +625,33 @@ function renderPages() {
|
|
|
|
|
} 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>
|
|
|
|
|
.map((item) => {
|
|
|
|
|
const name = escapeHtml(item.otherUserPretty || item.otherUser || '')
|
|
|
|
|
const id = escapeHtml(item.id)
|
|
|
|
|
return `
|
|
|
|
|
<tr class="capsule-row-link ${item.unread ? 'is-unread' : ''}" data-message-id="${id}">
|
|
|
|
|
<td>${name}</td>
|
|
|
|
|
<td>${escapeHtml(item.preview || '')}</td>
|
|
|
|
|
<td class="text-muted">${escapeHtml(formatTime(item.lastMessageAt))}</td>
|
|
|
|
|
<td class="text-nowrap" data-message-actions>
|
|
|
|
|
<button type="button" class="btn btn-sm btn-info" data-message-read="${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-secondary" data-message-unread="${id}" title="Mark as unread" ${item.unread ? 'disabled' : ''}>
|
|
|
|
|
<i class="fa-solid fa-fw fa-envelope"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-danger" data-message-delete="${id}" title="Hide">
|
|
|
|
|
<i class="fa-solid fa-fw fa-trash"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>`
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.join('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('messages-new').hidden = !canSendMessages || Boolean(messages.unavailable)
|
|
|
|
|
document.getElementById('thread-reply').hidden = !canSendMessages
|
|
|
|
|
|
|
|
|
|
const displayName = profile.name || profile.username || session.username || ''
|
|
|
|
|
document.getElementById('profile-name').textContent = displayName
|
|
|
|
|
document.getElementById('profile-registered').textContent = profile.registered || ''
|
|
|
|
|
@ -840,9 +932,11 @@ function applySession(next) {
|
|
|
|
|
plugins = []
|
|
|
|
|
notifications = { items: [], unread: 0 }
|
|
|
|
|
messages = { items: [], unread: 0 }
|
|
|
|
|
recentMessages = null
|
|
|
|
|
canSendMessages = true
|
|
|
|
|
thread = null
|
|
|
|
|
profile = { ...demoProfile }
|
|
|
|
|
pendingAvatar = null
|
|
|
|
|
clearPendingAvatar()
|
|
|
|
|
} else if (!window.location.hash) {
|
|
|
|
|
window.location.hash = '#/'
|
|
|
|
|
}
|
|
|
|
|
@ -856,6 +950,8 @@ function applySession(next) {
|
|
|
|
|
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 = []
|
|
|
|
|
} else if (session?.connected && window.capsule) {
|
|
|
|
|
@ -885,6 +981,9 @@ async function loadWorkspace() {
|
|
|
|
|
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) {
|
|
|
|
|
@ -909,16 +1008,42 @@ async function loadThread(id) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
thread = data
|
|
|
|
|
applyCanSend(data)
|
|
|
|
|
renderThread()
|
|
|
|
|
messages = (await runApi(() => window.capsule.messages({ page: 1 }))) || messages
|
|
|
|
|
renderChrome()
|
|
|
|
|
renderPages()
|
|
|
|
|
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<void>}
|
|
|
|
|
*/
|
|
|
|
|
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.
|
|
|
|
|
*
|
|
|
|
|
@ -1129,7 +1254,49 @@ document.getElementById('notification-list').addEventListener('click', async (ev
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
document.getElementById('message-list').addEventListener('click', (event) => {
|
|
|
|
|
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
|
|
|
|
|
@ -1283,7 +1450,7 @@ document.getElementById('settings-form').addEventListener('submit', async (event
|
|
|
|
|
if (!data) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
pendingAvatar = null
|
|
|
|
|
clearPendingAvatar()
|
|
|
|
|
document.getElementById('settings-avatar-file').value = ''
|
|
|
|
|
applyProfile(data)
|
|
|
|
|
renderChrome()
|
|
|
|
|
@ -1297,20 +1464,20 @@ document.getElementById('settings-form').addEventListener('submit', async (event
|
|
|
|
|
document.getElementById('settings-avatar-file').addEventListener('change', async (event) => {
|
|
|
|
|
const file = event.target.files?.[0]
|
|
|
|
|
if (!file) {
|
|
|
|
|
pendingAvatar = null
|
|
|
|
|
clearPendingAvatar()
|
|
|
|
|
renderChrome()
|
|
|
|
|
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
|
|
|
|
|
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) => {
|
|
|
|
|
|