Files
cinny/src/app/paarrot-api.ts

691 lines
18 KiB
TypeScript

/**
* Paarrot API Handler
*
* This module handles API actions from the Electron API server.
* It receives actions via IPC and executes them on the Matrix client.
*
* To use this, import and call initPaarrotAPI() early in your app initialization.
*/
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 {
getNotificationType,
getUnreadInfo,
reactionOrEditEvent,
roomHaveNotification,
roomHaveUnread,
} from './utils/room';
import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room';
/**
* Global reference to the router navigate function
* This is set by the router and used to navigate programmatically
*/
let navigateFunction: ((path: string) => void) | null = null;
/**
* Track if the API has been initialized to prevent duplicate listeners
*/
let isInitialized = false;
/**
* @param {any} room
* @returns {boolean}
*/
function isSpaceLikeRoom(room: any): boolean {
if (!room) return false;
if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) return true;
const roomKindEvent = room.currentState?.getStateEvents?.(StateEvent.PaarrotRoomKind, '');
const roomKind = roomKindEvent?.getContent?.()?.kind;
if (roomKind === 'forum_space') return true;
const createEvent = room.currentState?.getStateEvents?.(StateEvent.RoomCreate, '');
const roomType = createEvent?.getContent?.()?.type;
return roomType === RoomType.Forum;
}
/**
* Set the navigate function for programmatic navigation
* Call this from your router setup
*/
export function setPaarrotNavigate(navigate: (path: string) => void) {
navigateFunction = navigate;
console.log('Paarrot API: Navigate function registered');
}
/**
* Initialize the Paarrot API handler
* This should be called once during app startup
*/
export function initPaarrotAPI(matrixClient: MatrixClient) {
// Check if we're running in Electron
if (!(window as any).electron?.api?.onAction) {
console.log('Paarrot API: Not running in Electron, skipping API initialization');
return;
}
// Prevent duplicate registrations
if (isInitialized) {
console.log('Paarrot API: Already initialized, skipping');
return;
}
isInitialized = true;
console.log('Paarrot API: Initializing API handler');
// Listen for API actions from the Electron API server
(window as any).electron.api.onAction(async (action: {
action: string;
params: any;
responseChannel: string;
}) => {
console.log('Paarrot API: Received action:', action.action, action.params);
try {
let result;
switch (action.action) {
case 'get-status':
result = await getStatus(matrixClient);
break;
case 'toggle-mute':
result = await toggleMute(matrixClient);
break;
case 'set-mute':
result = await setMute(matrixClient, action.params.muted);
break;
case 'toggle-deafen':
result = await toggleDeafen(matrixClient);
break;
case 'set-deafen':
result = await setDeafen(matrixClient, action.params.deafened);
break;
case 'change-channel':
result = await changeChannel(matrixClient, action.params.roomId);
break;
case 'get-channels':
result = await getChannels(matrixClient);
break;
case 'send-message':
result = await sendMessage(matrixClient, action.params.roomId, action.params.message);
break;
case 'send-message-current':
result = await sendMessageCurrent(matrixClient, action.params.message);
break;
case 'get-current-room':
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}`);
}
// Send success response back
(window as any).electron.api.sendResponse(action.responseChannel, {
success: true,
data: result,
});
console.log('Paarrot API: Action completed successfully:', action.action);
} catch (error: any) {
console.error('Paarrot API: Action failed:', action.action, error);
// Send error response back
(window as any).electron.api.sendResponse(action.responseChannel, {
success: false,
error: error.message || 'Unknown error',
});
}
});
console.log('Paarrot API: Handler initialized');
}
/**
* Get current app status
*/
async function getStatus(matrixClient: MatrixClient) {
const currentRoom = getCurrentRoomId();
// Get actual mute/deafen state from CallService if available
const callService = getCallService();
const activeCall = callService?.getActiveCall();
return {
muted: activeCall?.isMuted ?? false,
deafened: activeCall?.isDeafened ?? false,
currentRoom,
connected: matrixClient.isInitialSyncComplete() || false,
userId: matrixClient.getUserId(),
};
}
/**
* Toggle microphone mute
*/
async function toggleMute(matrixClient: MatrixClient) {
const callService = getCallService();
if (!callService) {
return {
muted: false,
message: 'CallService not initialized - no active call',
};
}
const activeCall = callService.getActiveCall();
if (!activeCall) {
return {
muted: false,
message: 'No active call to mute/unmute',
};
}
// Toggle mute state
const newMuteState = callService.toggleMute();
return {
muted: newMuteState,
};
}
/**
* Set microphone mute state
*/
async function setMute(matrixClient: MatrixClient, muted: boolean) {
const callService = getCallService();
if (!callService) {
return {
muted,
message: 'CallService not initialized - no active call',
};
}
const activeCall = callService.getActiveCall();
if (!activeCall) {
return {
muted,
message: 'No active call to mute/unmute',
};
}
// Set mute to desired state if it's different
if (activeCall.isMuted !== muted) {
callService.toggleMute();
}
return {
muted,
};
}
/**
* Toggle deafen (mute speakers)
*/
async function toggleDeafen(matrixClient: MatrixClient) {
const callService = getCallService();
if (!callService) {
return {
deafened: false,
message: 'CallService not initialized - no active call',
};
}
const activeCall = callService.getActiveCall();
if (!activeCall) {
return {
deafened: false,
message: 'No active call to deafen/undeafen',
};
}
// Toggle deafen state
const newDeafenState = callService.toggleDeafen();
return {
deafened: newDeafenState,
};
}
/**
* Set deafen state
*/
async function setDeafen(matrixClient: MatrixClient, deafened: boolean) {
const callService = getCallService();
if (!callService) {
return {
deafened,
message: 'CallService not initialized - no active call',
};
}
const activeCall = callService.getActiveCall();
if (!activeCall) {
return {
deafened,
message: 'No active call to deafen/undeafen',
};
}
// Set deafen to desired state if it's different
if (activeCall.isDeafened !== deafened) {
callService.toggleDeafen();
}
return {
deafened,
};
}
/**
* Change to a different room/channel
*/
async function changeChannel(matrixClient: any, roomId: string) {
const room = matrixClient?.getRoom(roomId);
if (!room) {
throw new Error(`Room not found: ${roomId}`);
}
if (!navigateFunction) {
throw new Error('Navigate function not registered. Call setPaarrotNavigate from your router setup.');
}
// Determine the appropriate path based on room type
const roomIdOrAlias = getCanonicalAliasOrRoomId(matrixClient, roomId);
let path: string;
// Check if it's a direct message
const isDirect = room.guessDMUserId() !== null;
if (isDirect) {
// Navigate to direct message
path = getDirectRoomPath(roomIdOrAlias);
} else if (isSpaceLikeRoom(room)) {
// Navigate to space
path = getSpaceRoomPath(roomIdOrAlias, roomIdOrAlias);
} else {
// Navigate to regular room in home
path = getHomeRoomPath(roomIdOrAlias);
}
// Navigate to the room
navigateFunction(path);
return {
roomId,
roomName: room.name || 'Unnamed Room',
path,
};
}
/**
* Get list of rooms/channels
*/
async function getChannels(matrixClient: any) {
const rooms = matrixClient?.getRooms() || [];
return rooms
.filter((room: any) => !isSpaceLikeRoom(room))
.map((room: any) => {
// Extract server domain from roomId (!localpart:domain.tld)
const serverMatch = room.roomId.match(/:(.+)$/);
const server = serverMatch ? serverMatch[1] : null;
return {
roomId: room.roomId,
name: room.name || 'Unnamed Room',
server: server,
isDirect: room.getMyMembership() === 'invite' ? false : room.guessDMUserId() !== null,
avatar: room.getMxcAvatarUrl() || null,
};
})
.sort((a: any, b: any) => a.name.localeCompare(b.name));
}
/**
* Send a message to a specific room
*/
async function sendMessage(matrixClient: any, roomId: string, message: string) {
const room = matrixClient?.getRoom(roomId);
if (!room) {
throw new Error(`Room not found: ${roomId}`);
}
const content = {
msgtype: 'm.text',
body: message.trim(),
};
const result = await matrixClient.sendMessage(roomId, content);
return {
eventId: result.event_id,
roomId,
};
}
/**
* Send a message to the current room
*/
async function sendMessageCurrent(matrixClient: any, message: string) {
const currentRoomId = getCurrentRoomId();
if (!currentRoomId) {
throw new Error('No room currently active');
}
return sendMessage(matrixClient, currentRoomId, message);
}
/**
* Get information about the current room
*/
async function getCurrentRoom(matrixClient: any) {
const currentRoomId = getCurrentRoomId();
if (!currentRoomId) {
throw new Error('No room currently active');
}
const room = matrixClient?.getRoom(currentRoomId);
if (!room) {
throw new Error(`Current room not found: ${currentRoomId}`);
}
// Extract server domain from roomId (!localpart:domain.tld)
const serverMatch = room.roomId.match(/:(.+)$/);
const server = serverMatch ? serverMatch[1] : null;
return {
roomId: room.roomId,
name: room.name || 'Unnamed Room',
server: server,
avatar: room.getMxcAvatarUrl() || null,
isDirect: room.guessDMUserId() !== null,
};
}
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
*/
function getCurrentRoomId(): string | null {
try {
// Get the pathname from URL
// Support both normal routing and hash routing
let pathname = window.location.pathname;
// If using hash router, parse from hash
if (window.location.hash) {
const hashMatch = window.location.hash.match(/#(.+)/);
if (hashMatch) {
pathname = hashMatch[1];
}
}
// URL patterns:
// /home/:roomIdOrAlias/:eventId?/
// /direct/:roomIdOrAlias/:eventId?/
// /:spaceIdOrAlias/:roomIdOrAlias/:eventId?/
// Try to match room patterns
const patterns = [
/^\/home\/([^/]+)/, // /home/:roomIdOrAlias
/^\/direct\/([^/]+)/, // /direct/:roomIdOrAlias
/^\/[^/]+\/([^/]+)/, // /:spaceIdOrAlias/:roomIdOrAlias (space rooms)
];
for (const pattern of patterns) {
const match = pathname.match(pattern);
if (match && match[1]) {
// Decode URI component in case the room ID was encoded
return decodeURIComponent(match[1]);
}
}
return null;
} catch (error) {
console.error('Paarrot API: Error getting current room ID:', error);
return null;
}
}
export default { initPaarrotAPI };