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

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

View File

@ -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))
})

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}.
*
* @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<object>}
*/
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<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}.
*

View File

@ -154,10 +154,20 @@ const capsule = {
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.
*
* @param {object} payload - id
* @param {object} payload - id, optional markRead
* @return {Promise<object>}
*/
messageView(payload) {
@ -194,6 +204,16 @@ const capsule = {
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.
*

View File

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

View File

@ -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 <20> full width.',
otherUserPretty: 'Sam',
preview: 'Search should stay in the top middle <20> full width.',
lastMessageAt: 'Monday'
}
]

View File

@ -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;
}