add messages

This commit is contained in:
Joey Kimsey
2026-09-12 15:33:52 -04:00
parent ded1e1e069
commit 8eba550b1d
8 changed files with 100 additions and 11 deletions

View File

@ -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. - 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. - 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. - 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. - 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.

View File

@ -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 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. | | 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. | | 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`. | | Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Name, avatar, prefs. | | 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. | | 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. | | 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. |

View File

@ -14,10 +14,12 @@ import {
listNotifications, listNotifications,
readMessage, readMessage,
readNotification, readNotification,
recentMessages,
replyMessage, replyMessage,
searchSite, searchSite,
sendBugreport, sendBugreport,
sendContact, sendContact,
unreadMessage,
unwrapApi, unwrapApi,
updateProfile, updateProfile,
viewMessage viewMessage
@ -107,16 +109,18 @@ export function registerApiIpc() {
session.apiReady = true session.apiReady = true
writeSession(session) writeSession(session)
const [notifications, messages] = await Promise.all([ const [notifications, messages, recent] = await Promise.all([
listNotifications(session.siteUrl, session.token, 1), 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 { return {
session: publicSession(session), session: publicSession(session),
profile, profile,
notifications: optionalList(notifications), notifications: optionalList(notifications),
messages: optionalList(messages) messages: optionalList(messages),
recentMessages: optionalList(recent)
} }
} catch (err) { } catch (err) {
dropDeadToken(session, 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) => { 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) => { ipcMain.handle('capsule:messageCreate', async (_event, payload) => {
@ -184,6 +202,10 @@ export function registerApiIpc() {
return runUserApi((session) => readMessage(session.siteUrl, session.token, payload?.id)) 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) => { ipcMain.handle('capsule:messageDelete', async (_event, payload) => {
return runUserApi((session) => deleteMessage(session.siteUrl, session.token, payload?.id)) return runUserApi((session) => deleteMessage(session.siteUrl, session.token, payload?.id))
}) })

View File

@ -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<object>}
*/
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}. * One conversation. GET api/messages/view/{id}.
* *
* @param {string} siteUrl - canonical site base * @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token * @param {string} token - Bearer token
* @param {string|number} id - conversation id * @param {string|number} id - conversation id
* @param {boolean} [markRead=true] - false sends markRead=0
* @return {Promise<object>} * @return {Promise<object>}
*/ */
export async function viewMessage(siteUrl, token, id) { export async function viewMessage(siteUrl, token, id, markRead = true) {
return ttpRequest({ return ttpRequest({
siteUrl, siteUrl,
path: `/api/messages/view/${encodeURIComponent(id)}`, path: `/api/messages/view/${encodeURIComponent(id)}`,
method: 'GET', 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<object>}
*/
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}. * Hide a conversation. POST api/messages/delete/{id}.
* *

View File

@ -154,10 +154,20 @@ const capsule = {
return ipcRenderer.invoke('capsule:messages', payload) return ipcRenderer.invoke('capsule:messages', payload)
}, },
/**
* Recent conversations for the header dropdown.
*
* @param {object} [payload] - limit
* @return {Promise<object>}
*/
messagesRecent(payload) {
return ipcRenderer.invoke('capsule:messagesRecent', payload)
},
/** /**
* One conversation thread. * One conversation thread.
* *
* @param {object} payload - id * @param {object} payload - id, optional markRead
* @return {Promise<object>} * @return {Promise<object>}
*/ */
messageView(payload) { messageView(payload) {
@ -194,6 +204,16 @@ const capsule = {
return ipcRenderer.invoke('capsule:messageRead', payload) return ipcRenderer.invoke('capsule:messageRead', payload)
}, },
/**
* Mark a conversation unread.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageUnread(payload) {
return ipcRenderer.invoke('capsule:messageUnread', payload)
},
/** /**
* Hide a conversation. * Hide a conversation.
* *

View File

@ -559,6 +559,7 @@
<th>With</th> <th>With</th>
<th>Last message</th> <th>Last message</th>
<th>Updated</th> <th>Updated</th>
<th class="visually-hidden">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="message-list"></tbody> <tbody id="message-list"></tbody>

View File

@ -33,6 +33,7 @@ export const demoMessages = [
id: 'm1', id: 'm1',
unread: true, unread: true,
otherUser: 'Alex', otherUser: 'Alex',
otherUserPretty: 'Alex',
preview: 'Did the desktop shell pick up the new header?', preview: 'Did the desktop shell pick up the new header?',
lastMessageAt: 'Today' lastMessageAt: 'Today'
}, },
@ -40,7 +41,8 @@ export const demoMessages = [
id: 'm2', id: 'm2',
unread: false, unread: false,
otherUser: 'Sam', otherUser: 'Sam',
preview: 'Search should stay in the top middle <20> full width.', otherUserPretty: 'Sam',
preview: 'Search should stay in the top middle <20> full width.',
lastMessageAt: 'Monday' lastMessageAt: 'Monday'
} }
] ]

View File

@ -354,6 +354,13 @@ header .form-select:focus {
white-space: normal; white-space: normal;
} }
.capsule-drop-avatar {
width: 40px;
height: 40px;
object-fit: cover;
flex-shrink: 0;
}
.is-unread { .is-unread {
font-weight: 600; font-weight: 600;
} }