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) => {