From 82b22fa739195f7b0758c5214622b7ea61c5e7c0 Mon Sep 17 00:00:00 2001 From: litruv Date: Mon, 3 Aug 2026 15:08:18 +1000 Subject: [PATCH] Add local API actions for recent messages and unreads by scope. --- docs/API-QUICKSTART.md | 7 + docs/API.md | 117 +++++++++++ docs/handoff/features/local-api/HANDOFF.md | 6 + src/app/paarrot-api.ts | 234 ++++++++++++++++++++- 4 files changed, 363 insertions(+), 1 deletion(-) diff --git a/docs/API-QUICKSTART.md b/docs/API-QUICKSTART.md index ffbfaf6..b92fa26 100644 --- a/docs/API-QUICKSTART.md +++ b/docs/API-QUICKSTART.md @@ -55,6 +55,13 @@ curl -X POST http://127.0.0.1:33384/message/current \ | `/channels` | GET | Get list of rooms | | `/channel` | POST | Switch to a room | | `/message/current` | POST | Send message to current room | +| `/room/current` | GET | Get current room info | +| `/messages/groups` | GET | Last N messages across groups (`?limit=10`) | +| `/messages/dms` | GET | Last N messages across DMs (`?limit=10`) | +| `/messages/combined` | GET | Last N messages across groups + DMs (`?limit=10`) | +| `/unreads/groups` | GET | Unread group conversations | +| `/unreads/dms` | GET | Unread DM conversations | +| `/unreads/combined` | GET | Unread groups + DMs | ## 🎮 Stream Deck Integration diff --git a/docs/API.md b/docs/API.md index 14ec122..98ebae5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -280,6 +280,123 @@ Get information about the currently active room. --- +### Get Recent Messages (Groups) + +Get the most recent messages across joined group channels (non-DMs). + +**Endpoint**: `GET /messages/groups` + +**Query Parameters**: +- `limit` (optional) — number of messages to return (1–100, default `10`) + +**Response**: +```json +{ + "success": true, + "data": { + "scope": "groups", + "limit": 10, + "count": 2, + "messages": [ + { + "eventId": "$event1", + "roomId": "!abc123:matrix.org", + "roomName": "General", + "isDirect": false, + "sender": "@alice:matrix.org", + "senderName": "Alice", + "timestamp": 1710000000000, + "msgtype": "m.text", + "body": "Hello!" + } + ] + } +} +``` + +--- + +### Get Recent Messages (DMs) + +Get the most recent messages across joined direct messages. + +**Endpoint**: `GET /messages/dms` + +**Query Parameters**: +- `limit` (optional) — number of messages to return (1–100, default `10`) + +**Response**: same shape as groups, with `"scope": "dms"`. + +--- + +### Get Recent Messages (Combined) + +Get the most recent messages across groups and DMs together. + +**Endpoint**: `GET /messages/combined` + +**Query Parameters**: +- `limit` (optional) — number of messages to return (1–100, default `10`) + +**Response**: same shape as groups, with `"scope": "combined"`. + +--- + +### Get Unread Conversations (Groups) + +List joined group channels that currently have unread activity. + +**Endpoint**: `GET /unreads/groups` + +**Response**: +```json +{ + "success": true, + "data": { + "scope": "groups", + "count": 1, + "unreads": [ + { + "roomId": "!abc123:matrix.org", + "name": "General", + "isDirect": false, + "avatar": "mxc://matrix.org/abc123", + "total": 3, + "highlight": 1, + "latest": { + "eventId": "$event1", + "sender": "@alice:matrix.org", + "senderName": "Alice", + "timestamp": 1710000000000, + "msgtype": "m.text", + "body": "Hello!" + } + } + ] + } +} +``` + +Muted rooms are excluded. Sorted by latest activity, then highlight/total counts. + +--- + +### Get Unread Conversations (DMs) + +**Endpoint**: `GET /unreads/dms` + +**Response**: same shape as groups, with `"scope": "dms"`. + +--- + +### Get Unread Conversations (Combined) + +**Endpoint**: `GET /unreads/combined` + +**Response**: same shape as groups, with `"scope": "combined"`. + +--- + ## Error Responses All endpoints return error responses in the following format: diff --git a/docs/handoff/features/local-api/HANDOFF.md b/docs/handoff/features/local-api/HANDOFF.md index c1d4adb..ad58d3d 100644 --- a/docs/handoff/features/local-api/HANDOFF.md +++ b/docs/handoff/features/local-api/HANDOFF.md @@ -50,6 +50,12 @@ No persistent storage. Actions read/write ephemeral app state (current room, cal | `send-message` | `{ roomId, message }` | send result | | `send-message-current` | `{ message }` | send to active room | | `get-current-room` | — | current room id | +| `get-messages-groups` | `{ limit? }` | recent messages across group channels | +| `get-messages-dms` | `{ limit? }` | recent messages across DMs | +| `get-messages-combined` | `{ limit? }` | recent messages across groups + DMs | +| `get-unreads-groups` | — | unread group conversations | +| `get-unreads-dms` | — | unread DM conversations | +| `get-unreads-combined` | — | unread groups + DMs | ## Dependencies diff --git a/src/app/paarrot-api.ts b/src/app/paarrot-api.ts index 2344ba5..33084ca 100644 --- a/src/app/paarrot-api.ts +++ b/src/app/paarrot-api.ts @@ -11,7 +11,14 @@ import { MatrixClient } from 'matrix-js-sdk'; import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils'; import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix'; import { getCallService } from './features/call/useCall'; -import { RoomType, StateEvent } from '../types/matrix/room'; +import { + getNotificationType, + getUnreadInfo, + reactionOrEditEvent, + roomHaveNotification, + roomHaveUnread, +} from './utils/room'; +import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room'; /** * Global reference to the router navigate function @@ -120,6 +127,30 @@ export function initPaarrotAPI(matrixClient: MatrixClient) { result = await getCurrentRoom(matrixClient); break; + case 'get-messages-groups': + result = await getMessagesByScope(matrixClient, 'groups', action.params.limit); + break; + + case 'get-messages-dms': + result = await getMessagesByScope(matrixClient, 'dms', action.params.limit); + break; + + case 'get-messages-combined': + result = await getMessagesByScope(matrixClient, 'combined', action.params.limit); + break; + + case 'get-unreads-groups': + result = await getUnreadsByScope(matrixClient, 'groups'); + break; + + case 'get-unreads-dms': + result = await getUnreadsByScope(matrixClient, 'dms'); + break; + + case 'get-unreads-combined': + result = await getUnreadsByScope(matrixClient, 'combined'); + break; + default: throw new Error(`Unknown action: ${action.action}`); } @@ -410,6 +441,207 @@ async function getCurrentRoom(matrixClient: any) { }; } +const DEFAULT_MESSAGE_LIMIT = 10; +const MAX_MESSAGE_LIMIT = 100; + +type MessageScope = 'groups' | 'dms' | 'combined'; + +type ApiMessage = { + eventId: string; + roomId: string; + roomName: string; + isDirect: boolean; + sender: string; + senderName: string; + timestamp: number; + msgtype: string; + body: string; +}; + +function isDirectRoom(room: any): boolean { + return room?.guessDMUserId?.() !== null; +} + +function parseMessageLimit(limit?: number | string): number { + const parsedLimit = Number(limit); + return Math.min( + Math.max(Number.isFinite(parsedLimit) ? Math.floor(parsedLimit) : DEFAULT_MESSAGE_LIMIT, 1), + MAX_MESSAGE_LIMIT + ); +} + +/** + * Collect recent room messages from the local live timeline (newest last). + * Skips reactions, edits, and redacted events. + */ +function collectRecentMessages(room: any, limit: number): ApiMessage[] { + const liveEvents = room.getLiveTimeline?.()?.getEvents?.() ?? []; + const isDirect = isDirectRoom(room); + const roomId = room.roomId; + const roomName = room.name || 'Unnamed Room'; + const messages: ApiMessage[] = []; + + for (let i = liveEvents.length - 1; i >= 0 && messages.length < limit; i -= 1) { + const evt = liveEvents[i]; + if (!evt) continue; + if (typeof evt.isRedacted === 'function' && evt.isRedacted()) continue; + if (reactionOrEditEvent(evt)) continue; + + const type = evt.getType?.(); + if ( + type !== MessageEvent.RoomMessage && + type !== MessageEvent.RoomMessageEncrypted && + type !== MessageEvent.Sticker + ) { + continue; + } + + const content = evt.getContent?.() ?? {}; + const sender = evt.getSender?.() ?? ''; + const member = sender ? room.getMember?.(sender) : null; + + if (type === MessageEvent.RoomMessageEncrypted && !content.msgtype) { + messages.push({ + eventId: evt.getId?.() ?? '', + roomId, + roomName, + isDirect, + sender, + senderName: member?.name ?? sender, + timestamp: evt.getTs?.() ?? 0, + msgtype: 'm.encrypted', + body: '[encrypted]', + }); + continue; + } + + messages.push({ + eventId: evt.getId?.() ?? '', + roomId, + roomName, + isDirect, + sender, + senderName: member?.name ?? sender, + timestamp: evt.getTs?.() ?? 0, + msgtype: content.msgtype || (type === MessageEvent.Sticker ? 'm.sticker' : 'm.text'), + body: content.body || '', + }); + } + + return messages.reverse(); +} + +function getRoomsForScope(matrixClient: any, scope: MessageScope) { + const rooms = (matrixClient?.getRooms?.() || []).filter( + (room: any) => !isSpaceLikeRoom(room) && room.getMyMembership?.() === 'join' + ); + + if (scope === 'groups') { + return rooms.filter((room: any) => !isDirectRoom(room)); + } + if (scope === 'dms') { + return rooms.filter((room: any) => isDirectRoom(room)); + } + return rooms; +} + +/** + * Get recent messages across groups, DMs, or both (combined). + */ +async function getMessagesByScope( + matrixClient: any, + scope: MessageScope, + limit?: number | string +) { + const limitNum = parseMessageLimit(limit); + const rooms = getRoomsForScope(matrixClient, scope); + + const candidates: ApiMessage[] = []; + for (const room of rooms) { + candidates.push(...collectRecentMessages(room, limitNum)); + } + + candidates.sort((a, b) => a.timestamp - b.timestamp); + const messages = candidates.slice(-limitNum); + + return { + scope, + limit: limitNum, + count: messages.length, + messages, + }; +} + +/** + * List unread conversations across groups, DMs, or both (combined). + * Uses the same unread rules as the app UI (notification counts + read receipts, muted excluded). + */ +async function getUnreadsByScope(matrixClient: any, scope: MessageScope) { + const rooms = getRoomsForScope(matrixClient, scope); + const unreads: Array<{ + roomId: string; + name: string; + isDirect: boolean; + avatar: string | null; + total: number; + highlight: number; + latest?: { + eventId: string; + sender: string; + senderName: string; + timestamp: number; + msgtype: string; + body: string; + }; + }> = []; + + for (const room of rooms) { + if (getNotificationType(matrixClient, room.roomId) === NotificationType.Mute) { + continue; + } + if (!roomHaveNotification(room) && !roomHaveUnread(matrixClient, room)) { + continue; + } + + const unreadInfo = getUnreadInfo(room); + const latestMessages = collectRecentMessages(room, 1); + const latest = latestMessages[0]; + + unreads.push({ + roomId: room.roomId, + name: room.name || 'Unnamed Room', + isDirect: isDirectRoom(room), + avatar: room.getMxcAvatarUrl?.() || null, + total: unreadInfo.total, + highlight: unreadInfo.highlight, + latest: latest + ? { + eventId: latest.eventId, + sender: latest.sender, + senderName: latest.senderName, + timestamp: latest.timestamp, + msgtype: latest.msgtype, + body: latest.body, + } + : undefined, + }); + } + + unreads.sort((a, b) => { + const aTs = a.latest?.timestamp ?? 0; + const bTs = b.latest?.timestamp ?? 0; + if (bTs !== aTs) return bTs - aTs; + if (b.highlight !== a.highlight) return b.highlight - a.highlight; + return b.total - a.total; + }); + + return { + scope, + count: unreads.length, + unreads, + }; +} + /** * Get the currently active room ID * Extracts room ID from the current URL pathname