avatar fix
This commit is contained in:
@ -140,6 +140,8 @@ export function apiErrorMessage(code, errors) {
|
||||
return 'Choose how you want to authenticate.'
|
||||
case 'user token required':
|
||||
return 'This action needs a personal (user) token, not an app token.'
|
||||
case 'not found':
|
||||
return 'This site is missing that Capsule API action. Pull the latest TTP on the server.'
|
||||
default:
|
||||
return code || 'The site returned an error.'
|
||||
}
|
||||
@ -338,21 +340,55 @@ export function unwrapApi(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob for an avatar sent over IPC.
|
||||
* Bytes for an avatar sent over IPC.
|
||||
*
|
||||
* @param {unknown} data - ArrayBuffer, typed array, Buffer, or serialized Buffer
|
||||
* @return {Buffer|null}
|
||||
*/
|
||||
function avatarBytes(data) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return data
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data)
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
if (data.type === 'Buffer' && Array.isArray(data.data)) {
|
||||
return Buffer.from(data.data)
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.from(data)
|
||||
}
|
||||
try {
|
||||
const bytes = Buffer.from(data)
|
||||
return bytes.length ? bytes : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File/Blob for an avatar sent over IPC.
|
||||
*
|
||||
* @param {object} [avatar] - name, type, data (ArrayBuffer or typed array)
|
||||
* @return {Blob|null}
|
||||
*/
|
||||
export function avatarBlob(avatar) {
|
||||
if (!avatar?.data) {
|
||||
const bytes = avatarBytes(avatar?.data)
|
||||
if (!bytes) {
|
||||
return null
|
||||
}
|
||||
const bytes = Buffer.isBuffer(avatar.data)
|
||||
? avatar.data
|
||||
: avatar.data instanceof ArrayBuffer
|
||||
? Buffer.from(avatar.data)
|
||||
: Buffer.from(avatar.data)
|
||||
return new Blob([bytes], { type: avatar.type || 'application/octet-stream' })
|
||||
const type = avatar.type || 'application/octet-stream'
|
||||
const name = avatar.name || 'avatar.jpg'
|
||||
if (typeof File === 'function') {
|
||||
return new File([bytes], name, { type })
|
||||
}
|
||||
return new Blob([bytes], { type })
|
||||
}
|
||||
|
||||
/**
|
||||
@ -386,7 +422,11 @@ export async function updateProfile(siteUrl, token, fields, avatar) {
|
||||
}
|
||||
data.append(key, value)
|
||||
})
|
||||
data.append('avatar', file, avatar.name || 'avatar.jpg')
|
||||
if (typeof File === 'function' && file instanceof File) {
|
||||
data.append('avatar', file)
|
||||
} else {
|
||||
data.append('avatar', file, avatar.name || 'avatar.jpg')
|
||||
}
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/profile/update',
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src https://cdnjs.cloudflare.com; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https: http:;"
|
||||
content="default-src 'self'; style-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src https://cdnjs.cloudflare.com; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: blob: https: http:;"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Capsule</title>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user