avatar fix

This commit is contained in:
Joey Kimsey
2026-09-12 15:56:36 -04:00
parent 8eba550b1d
commit c0c257d5a4
4 changed files with 263 additions and 52 deletions

View File

@ -15,3 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site. - Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data. - After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box. - Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box.
### Fixed
- Avatar file previews are allowed (`blob:` on `img-src`). Choosing a photo no longer shows the missing-image icon. A `{ "error": "not found" }` from the site is reported as a missing Capsule API action (the live TTP install still needs `POST /api/profile/update`).

View File

@ -140,6 +140,8 @@ export function apiErrorMessage(code, errors) {
return 'Choose how you want to authenticate.' return 'Choose how you want to authenticate.'
case 'user token required': case 'user token required':
return 'This action needs a personal (user) token, not an app token.' 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: default:
return code || 'The site returned an error.' 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) * @param {object} [avatar] - name, type, data (ArrayBuffer or typed array)
* @return {Blob|null} * @return {Blob|null}
*/ */
export function avatarBlob(avatar) { export function avatarBlob(avatar) {
if (!avatar?.data) { const bytes = avatarBytes(avatar?.data)
if (!bytes) {
return null return null
} }
const bytes = Buffer.isBuffer(avatar.data) const type = avatar.type || 'application/octet-stream'
? avatar.data const name = avatar.name || 'avatar.jpg'
: avatar.data instanceof ArrayBuffer if (typeof File === 'function') {
? Buffer.from(avatar.data) return new File([bytes], name, { type })
: Buffer.from(avatar.data) }
return new Blob([bytes], { type: avatar.type || 'application/octet-stream' }) return new Blob([bytes], { type })
} }
/** /**
@ -386,7 +422,11 @@ export async function updateProfile(siteUrl, token, fields, avatar) {
} }
data.append(key, value) 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({ return ttpRequest({
siteUrl, siteUrl,
path: '/api/profile/update', path: '/api/profile/update',

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
http-equiv="Content-Security-Policy" 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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Capsule</title> <title>Capsule</title>

View File

@ -52,9 +52,12 @@ let profile = { ...demoProfile }
let plugins = [] let plugins = []
let notifications = { items: [], unread: 0 } let notifications = { items: [], unread: 0 }
let messages = { items: [], unread: 0 } let messages = { items: [], unread: 0 }
let recentMessages = null
let canSendMessages = true
let thread = null let thread = null
let searchState = { items: [], q: '', resource: 'all', resources: [], page: 1, pages: 0, total: 0 } let searchState = { items: [], q: '', resource: 'all', resources: [], page: 1, pages: 0, total: 0 }
let pendingAvatar = null let pendingAvatar = null
let pendingAvatarPreview = null
let workspaceSeq = 0 let workspaceSeq = 0
/** /**
@ -156,17 +159,47 @@ function displaySiteName() {
* @return {string} - image URL * @return {string} - image URL
*/ */
function avatarUrl() { function avatarUrl() {
if (profile.avatarUrl) { if (pendingAvatarPreview) {
return profile.avatarUrl 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 '' return ''
} }
const path = profile.avatar || 'images/defaultAvatar.png' if (/^https?:\/\//i.test(value)) {
if (/^https?:\/\//i.test(path)) { return value
return path
} }
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 avatar.hidden = true
avatarFallback.hidden = false avatarFallback.hidden = false
} }
avatar.onload = () => {
avatar.hidden = false
avatarFallback.hidden = true
}
logo.hidden = !nextLogo logo.hidden = !nextLogo
if (nextLogo) { if (nextLogo) {
@ -344,9 +381,10 @@ function renderChrome() {
document.getElementById('settings-link-phone').href = phone document.getElementById('settings-link-phone').href = phone
const noteItems = notifications.items || [] 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 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 noteBadge = document.getElementById('notification-badge')
const mailBadge = document.getElementById('message-badge') const mailBadge = document.getElementById('message-badge')
noteBadge.hidden = unreadNotes === 0 noteBadge.hidden = unreadNotes === 0
@ -356,35 +394,49 @@ function renderChrome() {
fillDropdown( fillDropdown(
document.getElementById('notification-menu'), document.getElementById('notification-menu'),
noteItems noteItems.length
.slice(0, 5) ? noteItems
.map( .slice(0, 5)
(item) => ` .map(
(item) => `
<li data-demo-item> <li data-demo-item>
<a href="#/notifications" class="dropdown-item dropdown-item-block" data-route="notifications"> <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> <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> <span>${escapeHtml(item.text || item.html || '')}</span>
</a> </a>
</li>` </li>`
) )
.join('') .join('')
: `<li data-demo-item class="px-3 text-center"><strong>No Notifications</strong></li>`
) )
fillDropdown( fillDropdown(
document.getElementById('message-menu'), document.getElementById('message-menu'),
mailItems mailItems.length
.slice(0, 5) ? mailItems
.map( .slice(0, 5)
(item) => ` .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> <li data-demo-item>
<a href="#/messages/${encodeURIComponent(item.id)}" class="dropdown-item dropdown-item-block"> <a href="#/messages/${encodeURIComponent(item.id)}" class="dropdown-item dropdown-item-block">
<strong>${escapeHtml(item.otherUser || '')}</strong> <div class="d-flex gap-2">
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${escapeHtml(formatTime(item.lastMessageAt))}</p> ${img}
<span>${escapeHtml(item.preview || '')}</span> <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> </a>
</li>` </li>`
) })
.join('') .join('')
: `<li data-demo-item class="px-3 text-center"><strong>No Messages</strong></li>`
) )
renderFooter() renderFooter()
@ -430,6 +482,30 @@ function fillDropdown(list, html) {
list.insertAdjacentHTML('afterbegin', 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. * Paint the MFA picker or code form from a pending challenge.
* *
@ -549,17 +625,33 @@ function renderPages() {
} else { } else {
mailEmpty.hidden = true mailEmpty.hidden = true
document.getElementById('message-list').innerHTML = messages.items document.getElementById('message-list').innerHTML = messages.items
.map( .map((item) => {
(item) => ` const name = escapeHtml(item.otherUserPretty || item.otherUser || '')
<tr class="capsule-row-link ${item.unread ? 'is-unread' : ''}" data-message-id="${escapeHtml(item.id)}"> const id = escapeHtml(item.id)
<td>${escapeHtml(item.otherUser || '')}</td> return `
<tr class="capsule-row-link ${item.unread ? 'is-unread' : ''}" data-message-id="${id}">
<td>${name}</td>
<td>${escapeHtml(item.preview || '')}</td> <td>${escapeHtml(item.preview || '')}</td>
<td class="text-muted">${escapeHtml(formatTime(item.lastMessageAt))}</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>` </tr>`
) })
.join('') .join('')
} }
document.getElementById('messages-new').hidden = !canSendMessages || Boolean(messages.unavailable)
document.getElementById('thread-reply').hidden = !canSendMessages
const displayName = profile.name || profile.username || session.username || '' const displayName = profile.name || profile.username || session.username || ''
document.getElementById('profile-name').textContent = displayName document.getElementById('profile-name').textContent = displayName
document.getElementById('profile-registered').textContent = profile.registered || '' document.getElementById('profile-registered').textContent = profile.registered || ''
@ -840,9 +932,11 @@ function applySession(next) {
plugins = [] plugins = []
notifications = { items: [], unread: 0 } notifications = { items: [], unread: 0 }
messages = { items: [], unread: 0 } messages = { items: [], unread: 0 }
recentMessages = null
canSendMessages = true
thread = null thread = null
profile = { ...demoProfile } profile = { ...demoProfile }
pendingAvatar = null clearPendingAvatar()
} else if (!window.location.hash) { } else if (!window.location.hash) {
window.location.hash = '#/' window.location.hash = '#/'
} }
@ -856,6 +950,8 @@ function applySession(next) {
if (session?.preview) { if (session?.preview) {
notifications = { items: demoNotifications, unread: demoNotifications.filter((item) => item.unread).length } notifications = { items: demoNotifications, unread: demoNotifications.filter((item) => item.unread).length }
messages = { items: demoMessages, unread: demoMessages.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 } profile = { ...demoProfile }
plugins = [] plugins = []
} else if (session?.connected && window.capsule) { } else if (session?.connected && window.capsule) {
@ -885,6 +981,9 @@ async function loadWorkspace() {
applyProfile(data.profile) applyProfile(data.profile)
notifications = data.notifications || { items: [], unread: 0 } notifications = data.notifications || { items: [], unread: 0 }
messages = data.messages || { items: [], unread: 0 } messages = data.messages || { items: [], unread: 0 }
recentMessages = data.recentMessages || null
applyCanSend(messages)
applyCanSend(recentMessages)
renderChrome() renderChrome()
renderPages() renderPages()
} catch (err) { } catch (err) {
@ -909,16 +1008,42 @@ async function loadThread(id) {
return return
} }
thread = data thread = data
applyCanSend(data)
renderThread() renderThread()
messages = (await runApi(() => window.capsule.messages({ page: 1 }))) || messages await refreshMail()
renderChrome()
renderPages()
showMessagesPane('thread') showMessagesPane('thread')
} catch (err) { } catch (err) {
setMessage(document.getElementById('thread-error'), err?.message || 'Could not load that conversation.') 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. * 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]') const row = event.target.closest('[data-message-id]')
if (!row) { if (!row) {
return return
@ -1283,7 +1450,7 @@ document.getElementById('settings-form').addEventListener('submit', async (event
if (!data) { if (!data) {
return return
} }
pendingAvatar = null clearPendingAvatar()
document.getElementById('settings-avatar-file').value = '' document.getElementById('settings-avatar-file').value = ''
applyProfile(data) applyProfile(data)
renderChrome() renderChrome()
@ -1297,20 +1464,20 @@ document.getElementById('settings-form').addEventListener('submit', async (event
document.getElementById('settings-avatar-file').addEventListener('change', async (event) => { document.getElementById('settings-avatar-file').addEventListener('change', async (event) => {
const file = event.target.files?.[0] const file = event.target.files?.[0]
if (!file) { if (!file) {
pendingAvatar = null clearPendingAvatar()
renderChrome()
return return
} }
const url = URL.createObjectURL(file) if (pendingAvatarPreview) {
document.getElementById('settings-avatar').src = url URL.revokeObjectURL(pendingAvatarPreview)
document.getElementById('profile-avatar').src = url }
document.getElementById('header-avatar').src = url pendingAvatarPreview = URL.createObjectURL(file)
document.getElementById('header-avatar').hidden = false
document.getElementById('header-avatar-fallback').hidden = true
pendingAvatar = { pendingAvatar = {
name: file.name, name: file.name,
type: file.type, type: file.type,
data: await file.arrayBuffer() data: await file.arrayBuffer()
} }
renderChrome()
}) })
document.getElementById('settings-dark').addEventListener('change', (event) => { document.getElementById('settings-dark').addEventListener('change', (event) => {