diff --git a/CHANGELOG.md b/CHANGELOG.md index 48de511..e7c4624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. - Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token. - 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 and messages. 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. diff --git a/README.md b/README.md index abc6474..1bd5d47 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,10 @@ All HTTP runs in the **main process**. The renderer never sees the token and nev | MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. | | MFA reset | `POST /api/login/mfa/{loginCode}/reset` | Clears the chosen method so the picker shows again. | | Confirm identity | `GET /api/profile` | Bearer user token. Also used to hydrate username after login. `GET /api/users/find/{username}` remains available. | -| Workspace | `GET /api/notifications`, `GET /api/messages` | First page after connect. Plugin-off responses show as unavailable. | +| Workspace | `GET /api/notifications`, `GET /api/messages`, `GET /api/messages/recent` | First inbox page plus the header dropdown after connect. Plugin-off responses show as unavailable. | | Search | `GET /api/search` | Header search. `q`, `resource`, `page`. | | Profile save | `POST /api/profile/update` | Name, avatar, prefs. | -| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, delete. | +| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. | | Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. | | Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. | diff --git a/src/main/apiIpc.js b/src/main/apiIpc.js index 9d1073f..c7b312f 100644 --- a/src/main/apiIpc.js +++ b/src/main/apiIpc.js @@ -14,10 +14,12 @@ import { listNotifications, readMessage, readNotification, + recentMessages, replyMessage, searchSite, sendBugreport, sendContact, + unreadMessage, unwrapApi, updateProfile, viewMessage @@ -107,16 +109,18 @@ export function registerApiIpc() { session.apiReady = true writeSession(session) - const [notifications, messages] = await Promise.all([ + const [notifications, messages, recent] = await Promise.all([ listNotifications(session.siteUrl, session.token, 1), - listMessages(session.siteUrl, session.token, 1) + listMessages(session.siteUrl, session.token, 1), + recentMessages(session.siteUrl, session.token, 5) ]) return { session: publicSession(session), profile, notifications: optionalList(notifications), - messages: optionalList(messages) + messages: optionalList(messages), + recentMessages: optionalList(recent) } } catch (err) { dropDeadToken(session, err) @@ -164,8 +168,22 @@ export function registerApiIpc() { } }) + ipcMain.handle('capsule:messagesRecent', async (_event, payload) => { + const session = requireSession() + try { + return optionalList( + await recentMessages(session.siteUrl, session.token, payload?.limit || 5) + ) + } catch (err) { + dropDeadToken(session, err) + throw err + } + }) + ipcMain.handle('capsule:messageView', async (_event, payload) => { - return runUserApi((session) => viewMessage(session.siteUrl, session.token, payload?.id)) + return runUserApi((session) => + viewMessage(session.siteUrl, session.token, payload?.id, payload?.markRead !== false) + ) }) ipcMain.handle('capsule:messageCreate', async (_event, payload) => { @@ -184,6 +202,10 @@ export function registerApiIpc() { return runUserApi((session) => readMessage(session.siteUrl, session.token, payload?.id)) }) + ipcMain.handle('capsule:messageUnread', async (_event, payload) => { + return runUserApi((session) => unreadMessage(session.siteUrl, session.token, payload?.id)) + }) + ipcMain.handle('capsule:messageDelete', async (_event, payload) => { return runUserApi((session) => deleteMessage(session.siteUrl, session.token, payload?.id)) }) diff --git a/src/main/ttpClient.js b/src/main/ttpClient.js index 7bae3d5..17ab443 100644 --- a/src/main/ttpClient.js +++ b/src/main/ttpClient.js @@ -474,20 +474,40 @@ export async function listMessages(siteUrl, token, page = 1) { }) } +/** + * Recent conversations for the header dropdown. GET api/messages/recent. + * + * @param {string} siteUrl - canonical site base + * @param {string} token - Bearer token + * @param {number} [limit=5] - max rows + * @return {Promise} + */ +export async function recentMessages(siteUrl, token, limit = 5) { + return ttpRequest({ + siteUrl, + path: '/api/messages/recent', + method: 'GET', + token, + query: { limit } + }) +} + /** * One conversation. GET api/messages/view/{id}. * * @param {string} siteUrl - canonical site base * @param {string} token - Bearer token * @param {string|number} id - conversation id + * @param {boolean} [markRead=true] - false sends markRead=0 * @return {Promise} */ -export async function viewMessage(siteUrl, token, id) { +export async function viewMessage(siteUrl, token, id, markRead = true) { return ttpRequest({ siteUrl, path: `/api/messages/view/${encodeURIComponent(id)}`, method: 'GET', - token + token, + query: markRead ? undefined : { markRead: 0 } }) } @@ -546,6 +566,23 @@ export async function readMessage(siteUrl, token, id) { }) } +/** + * Mark a conversation unread. POST api/messages/unread/{id}. + * + * @param {string} siteUrl - canonical site base + * @param {string} token - Bearer token + * @param {string|number} id - conversation id + * @return {Promise} + */ +export async function unreadMessage(siteUrl, token, id) { + return ttpRequest({ + siteUrl, + path: `/api/messages/unread/${encodeURIComponent(id)}`, + method: 'POST', + token + }) +} + /** * Hide a conversation. POST api/messages/delete/{id}. * diff --git a/src/preload/index.js b/src/preload/index.js index eb18f70..4fb5cad 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -154,10 +154,20 @@ const capsule = { return ipcRenderer.invoke('capsule:messages', payload) }, + /** + * Recent conversations for the header dropdown. + * + * @param {object} [payload] - limit + * @return {Promise} + */ + messagesRecent(payload) { + return ipcRenderer.invoke('capsule:messagesRecent', payload) + }, + /** * One conversation thread. * - * @param {object} payload - id + * @param {object} payload - id, optional markRead * @return {Promise} */ messageView(payload) { @@ -194,6 +204,16 @@ const capsule = { return ipcRenderer.invoke('capsule:messageRead', payload) }, + /** + * Mark a conversation unread. + * + * @param {object} payload - id + * @return {Promise} + */ + messageUnread(payload) { + return ipcRenderer.invoke('capsule:messageUnread', payload) + }, + /** * Hide a conversation. * diff --git a/src/renderer/index.html b/src/renderer/index.html index b265e21..ed383dc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -559,6 +559,7 @@ With Last message Updated + Actions diff --git a/src/renderer/src/demo.js b/src/renderer/src/demo.js index 856cfdb..f2f6fc5 100644 --- a/src/renderer/src/demo.js +++ b/src/renderer/src/demo.js @@ -33,6 +33,7 @@ export const demoMessages = [ id: 'm1', unread: true, otherUser: 'Alex', + otherUserPretty: 'Alex', preview: 'Did the desktop shell pick up the new header?', lastMessageAt: 'Today' }, @@ -40,7 +41,8 @@ export const demoMessages = [ id: 'm2', unread: false, otherUser: 'Sam', - preview: 'Search should stay in the top middle full width.', + otherUserPretty: 'Sam', + preview: 'Search should stay in the top middle full width.', lastMessageAt: 'Monday' } ] diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 8f7c16f..e7d4dda 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -354,6 +354,13 @@ header .form-select:focus { white-space: normal; } +.capsule-drop-avatar { + width: 40px; + height: 40px; + object-fit: cover; + flex-shrink: 0; +} + .is-unread { font-weight: 600; }