diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7c4624..6cc4af2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
- 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.
+
+### 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`).
diff --git a/src/main/ttpClient.js b/src/main/ttpClient.js
index 17ab443..6fa3fa4 100644
--- a/src/main/ttpClient.js
+++ b/src/main/ttpClient.js
@@ -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',
diff --git a/src/renderer/index.html b/src/renderer/index.html
index ed383dc..4471420 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -4,7 +4,7 @@
Capsule
diff --git a/src/renderer/src/main.js b/src/renderer/src/main.js
index c41cf70..059bc3c 100644
--- a/src/renderer/src/main.js
+++ b/src/renderer/src/main.js
@@ -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) => `
${escapeHtml(formatTime(item.createdAt))}
${escapeHtml(item.text || item.html || '')}
`
- )
- .join('')
+ )
+ .join('')
+ : `No Notifications`
)
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
+ ? `
`
+ : ''
+ return `
- ${escapeHtml(item.otherUser || '')}
- ${escapeHtml(formatTime(item.lastMessageAt))}
- ${escapeHtml(item.preview || '')}
+
+ ${img}
+
+
${name}
+
${escapeHtml(formatTime(item.lastMessageAt))}
+
${escapeHtml(item.preview || '')}
+
+
`
- )
- .join('')
+ })
+ .join('')
+ : `No Messages`
)
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) => `
-
- | ${escapeHtml(item.otherUser || '')} |
+ .map((item) => {
+ const name = escapeHtml(item.otherUserPretty || item.otherUser || '')
+ const id = escapeHtml(item.id)
+ return `
+
+ | ${name} |
${escapeHtml(item.preview || '')} |
${escapeHtml(formatTime(item.lastMessageAt))} |
+
+
+
+
+ |
`
- )
+ })
.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}
+ */
+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) => {