691 lines
18 KiB
TypeScript
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 };
|