From df10268a834830c950a992d154811487a510e9a4 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sun, 15 Mar 2026 12:27:23 +1100 Subject: [PATCH] feat: add MxjsClient for lightweight Matrix client functionality - Implemented core methods for user authentication (register, login, logout). - Added room management features (createRoom, joinRoom, leaveRoom, inviteUser). - Included message handling capabilities (sendMessage, editMessage, redactEvent). - Introduced user moderation actions (kickUser, banUser, unbanUser). - Implemented profile management (getProfile, setDisplayName, setAvatarUrl). - Added support for media uploads and fetching messages from room timelines. - Included event handling with emit and on methods for custom event listeners. - Sanitization of HTML content and mention handling in messages. --- package-lock.json | 10 +- package.json | 1 + website/matrix-client.js | 959 +++++++---------- website/matrix-client.old.js | 1133 +++++++++++++++++++++ website/mxjs-lite.js | 1243 +++++++++++++++++++++++ website/scripts/commands/numbermatch.js | 10 +- website/terminal.js | 13 + 7 files changed, 2785 insertions(+), 584 deletions(-) create mode 100644 website/matrix-client.old.js create mode 100644 website/mxjs-lite.js diff --git a/package-lock.json b/package-lock.json index b4a24a1..55471fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@litruv/mxjs-lite": "^1.0.1", "jimp": "^1.6.0" - }, - "devDependencies": {} + } }, "node_modules/@jimp/core": { "version": "1.6.0", @@ -425,6 +425,12 @@ "node": ">=18" } }, + "node_modules/@litruv/mxjs-lite": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@litruv/mxjs-lite/-/mxjs-lite-1.1.2.tgz", + "integrity": "sha512-kEL/MZdezhibPNQFiQWUWRBPRxfjWTUNnDNpqxmDyLFFG8iLXMQy2mJAMYPBgxw6D+NXARiMcABp5pEWezfC9w==", + "license": "MIT" + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", diff --git a/package.json b/package.json index 91f5be8..231bd9e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "author": "Max Litruv Boonzaayer", "license": "MIT", "dependencies": { + "@litruv/mxjs-lite": "^1.0.1", "jimp": "^1.6.0" }, "repository": { diff --git a/website/matrix-client.js b/website/matrix-client.js index 8684c53..7446794 100644 --- a/website/matrix-client.js +++ b/website/matrix-client.js @@ -1,14 +1,18 @@ /** - * Matrix Client - Generic Matrix protocol library + * Matrix Client - Using mxjs-lite library */ -// Configuration -let config = { - homeserver: 'https://matrix.org', - bridgeUrl: null, - useBridge: false, - publicReadToken: null -}; +import { MxjsClient } from './mxjs-lite.js'; + +/** + * @type {MxjsClient|null} + */ +let mxClient = null; + +/** + * @type {string|null} + */ +let syncToken = null; // Dependencies that will be injected let term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInlineInput, positionInlineInput, submitInlineInput; @@ -17,16 +21,17 @@ let term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInli * Initialize Matrix client with configuration and dependencies * @param {Object} userConfig - Configuration options * @param {string} userConfig.homeserver - Matrix homeserver URL - * @param {string} [userConfig.bridgeUrl] - Bridge iframe URL for CSP-restricted hosts - * @param {boolean} [userConfig.useBridge] - Whether to use iframe bridge * @param {string} [userConfig.publicReadToken] - Public read token for unauthenticated requests * @param {Object} deps - UI dependencies */ export function initMatrixClient(userConfig, deps) { - // Merge config - config = { ...config, ...userConfig }; + // Create new mxjs-lite client + mxClient = new MxjsClient({ + homeserver: userConfig.homeserver || 'https://matrix.org', + publicReadToken: userConfig.publicReadToken || null + }); - // Inject dependencies + // Inject UI dependencies if (deps) { term = deps.term; inlineInput = deps.inlineInput; @@ -37,6 +42,138 @@ export function initMatrixClient(userConfig, deps) { positionInlineInput = deps.positionInlineInput; submitInlineInput = deps.submitInlineInput; } + + // Set up event handlers + setupEventHandlers(); +} + +/** + * Update mxClient with new session credentials + * @param {Object} session - Session data with accessToken and userId + */ +export function updateClientSession(session) { + if (!mxClient) return; + + mxClient.accessToken = session.accessToken; + mxClient.userId = session.userId; +} + +/** + * Setup mxjs-lite event handlers + */ +function setupEventHandlers() { + if (!mxClient) return; + + mxClient.on('message', ({ roomId, event }) => { + if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; + handleNewMessage(event); + }); + + mxClient.on('edit', ({ roomId, edits, newBody, event }) => { + if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; + handleMessageEdit(edits, newBody); + }); + + mxClient.on('redaction', ({ roomId, redacts, event }) => { + if (!chatMode.active || roomId !== window.matrixSession?.roomId) return; + handleMessageRedaction(redacts); + }); + + mxClient.on('typing', ({ roomId, userIds }) => { + // Can add typing indicators here if needed + }); +} + +/** + * Handle incoming message event + * @param {Object} event - Matrix message event + */ +function handleNewMessage(event) { + if (event.content?.msgtype !== 'm.text') return; + + const msgId = event.event_id; + const exists = chatMode.messages.find(m => m.id === msgId); + if (exists) return; + + const timestamp = new Date(event.origin_server_ts); + const time = timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + + const userId = event.sender; + const displayName = chatMode.displayNames[userId] || mxClient.extractLocalpart(userId); + + const newMessage = { + id: msgId, + time: time, + sender: displayName, + userId: userId, + text: event.content.body + }; + + chatMode.messages.push(newMessage); + + // Only keep last 100 messages in memory + if (chatMode.messages.length > 100) { + chatMode.messages = chatMode.messages.slice(-100); + } + + renderChatMessage(newMessage); +} + +/** + * Handle message edit event + * @param {string} originalEventId - ID of the original message being edited + * @param {string} newBody - New message text + */ +function handleMessageEdit(originalEventId, newBody) { + const message = chatMode.messages.find(m => m.id === originalEventId); + if (!message) return; + + message.text = newBody + ' \x1b[90m(edited)\x1b[0m'; + rerenderChatView(); +} + +/** + * Handle message redaction event + * @param {string} redactedEventId - ID of the message being redacted + */ +function handleMessageRedaction(redactedEventId) { + const messageIndex = chatMode.messages.findIndex(m => m.id === redactedEventId); + if (messageIndex === -1) return; + + // Remove the message from the array + chatMode.messages.splice(messageIndex, 1); + rerenderChatView(); +} + +/** + * Re-render the entire chat view + */ +function rerenderChatView() { + if (!chatMode.active) return; + + // Clear terminal and redraw header + term.clear(); + term.writeln('╔════════════════════════════════════════════════════════════╗'); + term.writeln('║ CHAT - #generalchat ║'); + term.writeln('║ Type /help for commands ║'); + term.writeln('╚════════════════════════════════════════════════════════════╝'); + + // Render the last 20 messages + const recent = chatMode.messages.slice(-20); + recent.forEach(msg => { + const color = getUserColor(msg.sender); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + }); + + // Render separator and prompt + term.writeln(''); + term.writeln('─'.repeat(term.cols || 60)); + term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); + term.scrollToBottom(); + setTimeout(() => positionInlineInput(), 10); } // Chat mode state @@ -46,7 +183,7 @@ export const chatMode = { lastSync: null, pollInterval: null, inputLine: '', - displayNames: {}, // Cache for display names + displayNames: {}, mentionDirectory: [], mentionLoadedAt: 0, mentionAutocomplete: { @@ -58,155 +195,17 @@ export const chatMode = { }; /** - * Matrix Bridge - Iframe-based communication for CSP-restricted hosts + * Matrix API helper for backward compatibility + * @param {string} endpoint - API endpoint path + * @param {string} [method='GET'] - HTTP method + * @param {Object|null} [body=null] - Request body + * @returns {Promise} API response */ -const matrixBridge = { - iframe: null, - ready: false, - pendingRequests: new Map(), - requestCounter: 0 -}; - -/** - * Initialize the Matrix iframe bridge - * @returns {Promise} True if bridge loaded successfully - */ -async function initMatrixBridge() { - if (!config.bridgeUrl) { - throw new Error('Bridge URL not configured'); - } - - if (matrixBridge.iframe) { - return matrixBridge.ready; - } - - return new Promise((resolve) => { - const iframe = document.createElement('iframe'); - iframe.src = config.bridgeUrl; - iframe.style.display = 'none'; - iframe.style.position = 'absolute'; - iframe.style.width = '0'; - iframe.style.height = '0'; - iframe.style.border = 'none'; - - const timeout = setTimeout(() => { - console.error('[Matrix Bridge] Failed to load iframe bridge'); - matrixBridge.ready = false; - resolve(false); - }, 10000); - - window.addEventListener('message', (event) => { - if (event.data && event.data.type === 'matrix:bridge:ready') { - clearTimeout(timeout); - matrixBridge.ready = true; - console.log('[Matrix Bridge] Iframe bridge ready'); - resolve(true); - } - }); - - document.body.appendChild(iframe); - matrixBridge.iframe = iframe; - }); -} - -/** - * Send a request to the Matrix bridge via postMessage - * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') - * @param {object} payload - Request payload - * @returns {Promise} Response from bridge - */ -function matrixBridgeRequest(type, payload) { - return new Promise((resolve, reject) => { - if (!matrixBridge.iframe || !matrixBridge.ready) { - reject(new Error('Matrix bridge not initialized')); - return; - } - - const requestId = `req_${++matrixBridge.requestCounter}`; - const timeout = setTimeout(() => { - matrixBridge.pendingRequests.delete(requestId); - reject(new Error('Matrix bridge request timeout')); - }, 30000); - - matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); - - matrixBridge.iframe.contentWindow.postMessage({ - type, - requestId, - payload - }, config.bridgeUrl); - }); -} - -/** - * Handle responses from the Matrix bridge - */ -window.addEventListener('message', (event) => { - if (config.bridgeUrl && event.origin !== new URL(config.bridgeUrl).origin) { - return; - } - - const { type, requestId, payload } = event.data; - - if (!type || !requestId || !type.includes(':response')) { - return; - } - - const pending = matrixBridge.pendingRequests.get(requestId); - if (pending) { - clearTimeout(pending.timeout); - matrixBridge.pendingRequests.delete(requestId); - pending.resolve(payload); - } -}); - -// Matrix API helper export const matrixApi = async (endpoint, method = 'GET', body = null) => { - if (!window.matrixSession) return null; + if (!mxClient) return null; - // Use iframe bridge if configured - if (config.useBridge) { - if (!matrixBridge.ready) { - const initialized = await initMatrixBridge(); - if (!initialized) { - return { - errcode: 'M_BRIDGE_UNAVAILABLE', - error: 'Matrix bridge failed to initialize' - }; - } - } - - try { - const result = await matrixBridgeRequest('matrix:api', { - endpoint, - method, - body, - accessToken: window.matrixSession.accessToken - }); - return result; - } catch (error) { - return { - errcode: 'M_BRIDGE_ERROR', - error: error.message - }; - } - } - - // Direct API call - const url = `${config.homeserver}/_matrix/client/r0${endpoint}`; - const headers = { - 'Content-Type': 'application/json' - }; - - if (window.matrixSession.accessToken) { - headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`; - } - - const options = { method, headers }; - if (body) options.body = JSON.stringify(body); - - const response = await fetch(url, options); - return response.json(); + const accessToken = window.matrixSession?.accessToken || null; + return await mxClient.api(endpoint, method, body, accessToken); }; /** @@ -215,76 +214,8 @@ export const matrixApi = async (endpoint, method = 'GET', body = null) => { * @returns {Promise<{sender: string, body: string, timestamp: number} | null>} */ export async function fetchPublicLastMessage(roomAlias) { - if (!config.publicReadToken) { - console.warn('No public read token configured'); - return null; - } - - try { - // Use iframe bridge if configured - if (config.useBridge) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - const result = await matrixBridgeRequest('matrix:fetchLastMessage', { - roomAlias, - publicToken: config.publicReadToken - }); - - if (!result || result.error || !Array.isArray(result.chunk)) { - return null; - } - - const lastEvent = result.chunk.find(e => - e && e.type === 'm.room.message' && e.content && e.content.body - ); - - if (!lastEvent) return null; - - return { - sender: lastEvent.sender, - body: lastEvent.content.body, - timestamp: lastEvent.origin_server_ts || Date.now() - }; - } - - // Direct API call - const resolvedAlias = encodeURIComponent(roomAlias); - const roomResponse = await fetch( - `${config.homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, - { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } - ); - - if (!roomResponse.ok) return null; - - const roomData = await roomResponse.json(); - const roomId = roomData?.room_id; - if (!roomId) return null; - - const messagesResponse = await fetch( - `${config.homeserver}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=10`, - { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } - ); - - if (!messagesResponse.ok) return null; - - const messagesData = await messagesResponse.json(); - const lastEvent = messagesData.chunk?.find(e => - e && e.type === 'm.room.message' && e.content && e.content.body - ); - - if (!lastEvent) return null; - - return { - sender: lastEvent.sender, - body: lastEvent.content.body, - timestamp: lastEvent.origin_server_ts || Date.now() - }; - } catch (error) { - console.error('Failed to fetch public last message:', error); - return null; - } + if (!mxClient) return null; + return await mxClient.fetchPublicLastMessage(roomAlias); } /** @@ -293,49 +224,9 @@ export async function fetchPublicLastMessage(roomAlias) { * @returns {Promise<{presence: string, lastActive: number} | null>} */ export async function fetchPublicPresence(userId) { - if (!config.publicReadToken) { - console.warn('No public read token configured'); - return null; - } - - try { - // Use iframe bridge if configured - if (config.useBridge) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - const result = await matrixBridgeRequest('matrix:fetchPresence', { - userId, - publicToken: config.publicReadToken - }); - - if (result && !result.error && result.presence) { - return { - presence: result.presence, - lastActive: result.last_active_ago || 0 - }; - } - return null; - } - - // Direct API call - const response = await fetch( - `${config.homeserver}/_matrix/client/r0/presence/${encodeURIComponent(userId)}/status`, - { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } - ); - - if (!response.ok) return null; - - const data = await response.json(); - return { - presence: data.presence, - lastActive: data.last_active_ago || 0 - }; - } catch (error) { - console.error('Failed to fetch presence:', error); - return null; - } + if (!mxClient) return null; + const result = await mxClient.fetchPublicPresence(userId); + return result ? { presence: result.presence, lastActive: result.lastActive } : null; } /** @@ -364,29 +255,40 @@ export function formatTimeAgo(timestampMs) { return `${Math.floor(elapsedSeconds / 86400)}d ago`; } -// Get display name for a user (with caching) +/** + * Get display name for a user (with caching) + * @param {string} userId - Matrix user ID + * @returns {Promise} Display name + */ async function getDisplayName(userId) { - // Check cache first if (chatMode.displayNames[userId]) { return chatMode.displayNames[userId]; } + if (!mxClient) { + const fallback = userId.split(':')[0].substring(1); + chatMode.displayNames[userId] = fallback; + return fallback; + } + try { - const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET'); - const displayName = data.displayname || userId.split(':')[0].substring(1); + const profile = await mxClient.getProfile(userId); + const displayName = profile?.displayName || mxClient.extractLocalpart(userId); chatMode.displayNames[userId] = displayName; return displayName; } catch (error) { - // Fallback to username part - const fallback = userId.split(':')[0].substring(1); + const fallback = mxClient.extractLocalpart(userId); chatMode.displayNames[userId] = fallback; return fallback; } } -// Get color for user based on their ID (consistent hashing) +/** + * Get color for user based on their ID (consistent hashing) + * @param {string} username - Username string + * @returns {string} ANSI color code + */ function getUserColor(username) { - // Color palette that works well on dark green background const colors = [ '\x1b[91m', // bright red '\x1b[92m', // bright green @@ -399,35 +301,37 @@ function getUserColor(username) { '\x1b[36m', // cyan ]; - // Simple hash function let hash = 0; for (let i = 0; i < username.length; i++) { hash = ((hash << 5) - hash) + username.charCodeAt(i); - hash = hash & hash; // Convert to 32bit integer + hash = hash & hash; } return colors[Math.abs(hash) % colors.length]; } -// Check if display name is already taken +/** + * Check if display name is already taken + * @param {string} newName - Proposed display name + * @returns {Promise} True if taken + */ export async function isDisplayNameTaken(newName) { + if (!mxClient || !window.matrixSession?.roomId) return false; + try { - const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); - if (members && members.joined) { - for (const [userId, member] of Object.entries(members.joined)) { - // Skip our own user - if (userId === window.matrixSession.userId) continue; - - const displayName = member.display_name || userId.split(':')[0].substring(1); - if (displayName.toLowerCase() === newName.toLowerCase()) { - return true; - } + const members = await mxClient.getRoomMembers(window.matrixSession.roomId); + if (!members) return false; + + for (const member of members) { + if (member.userId === window.matrixSession.userId) continue; + if (member.displayName.toLowerCase() === newName.toLowerCase()) { + return true; } } return false; } catch (error) { console.error('Error checking display names:', error); - return false; // Allow on error + return false; } } @@ -437,15 +341,11 @@ export async function isDisplayNameTaken(newName) { * @returns {boolean} True when name looks like an auto-generated default */ function isDefaultDisplayName(displayName) { - if (!displayName) { - return true; - } - + if (!displayName) return true; + const trimmedName = displayName.trim(); - if (!trimmedName) { - return true; - } - + if (!trimmedName) return true; + const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidV4Pattern.test(trimmedName); } @@ -455,18 +355,11 @@ function isDefaultDisplayName(displayName) { * @returns {Promise} True when the user should be prompted to change nickname */ async function shouldShowNicknameHint() { - if (!window.matrixSession || !window.matrixSession.userId) { - return false; - } - + if (!mxClient || !window.matrixSession?.userId) return false; + try { - const encodedUserId = encodeURIComponent(window.matrixSession.userId); - const profileData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); - const displayName = profileData && typeof profileData.displayname === 'string' - ? profileData.displayname - : ''; - - return isDefaultDisplayName(displayName); + const profile = await mxClient.getProfile(window.matrixSession.userId); + return isDefaultDisplayName(profile?.displayName || ''); } catch (error) { return false; } @@ -474,37 +367,31 @@ async function shouldShowNicknameHint() { /** * Sync mention directory for autocomplete and mention rendering. - * @param {boolean} force - When true, bypass cache time + * @param {boolean} [force=false] - When true, bypass cache time * @returns {Promise} */ async function syncMentionDirectory(force = false) { - if (!window.matrixSession || !window.matrixSession.roomId) { - return; - } - + if (!mxClient || !window.matrixSession?.roomId) return; + const now = Date.now(); if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { return; } - + try { - const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); - if (!members || !members.joined) { - return; - } - + const members = await mxClient.getRoomMembers(window.matrixSession.roomId); + if (!members) return; + const directory = []; - Object.entries(members.joined).forEach(([userId, member]) => { - const fallbackName = userId.split(':')[0].substring(1); - const displayName = member && member.display_name ? member.display_name : fallbackName; - chatMode.displayNames[userId] = displayName; - directory.push({ userId, displayName }); - }); - + for (const member of members) { + chatMode.displayNames[member.userId] = member.displayName; + directory.push({ userId: member.userId, displayName: member.displayName }); + } + chatMode.mentionDirectory = directory; chatMode.mentionLoadedAt = now; } catch (error) { - // Ignore mention directory refresh failures. + console.warn('Failed to sync mention directory:', error); } } @@ -520,7 +407,7 @@ function getMentionMatches(query) { const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); return display.startsWith(queryLower) || localPart.startsWith(queryLower); }); - + matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); return matches; } @@ -548,21 +435,15 @@ function getMentionTokenContext() { const fullValue = inlineInput.value; const textBeforeCursor = fullValue.slice(0, cursorPosition); const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); - - if (tokenStart === -1) { - return null; - } - + + if (tokenStart === -1) return null; + const atIndex = textBeforeCursor.indexOf('@', tokenStart); - if (atIndex === -1) { - return null; - } - + if (atIndex === -1) return null; + const tokenText = textBeforeCursor.slice(atIndex + 1); - if (!tokenText || tokenText.includes(':')) { - return null; - } - + if (!tokenText || tokenText.includes(':')) return null; + return { atIndex, cursorPosition, tokenText }; } @@ -576,7 +457,7 @@ function renderMentionSuggestions() { mentionSuggestions.style.display = 'none'; return; } - + const limitedMatches = matches.slice(0, 5); mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; @@ -620,20 +501,20 @@ export async function refreshMentionSuggestionsFromInput() { resetMentionAutocomplete(); return; } - + const context = getMentionTokenContext(); if (!context) { resetMentionAutocomplete(); return; } - + await syncMentionDirectory(); const matches = getMentionMatches(context.tokenText); if (matches.length === 0) { resetMentionAutocomplete(); return; } - + chatMode.mentionAutocomplete = { tokenStart: context.atIndex, tokenEnd: context.cursorPosition, @@ -651,10 +532,8 @@ export async function refreshMentionSuggestionsFromInput() { function applyMentionReplacement(selectedEntry) { const rangeStart = chatMode.mentionAutocomplete.tokenStart; const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; - if (rangeStart < 0 || rangeEnd < 0) { - return; - } - + if (rangeStart < 0 || rangeEnd < 0) return; + const fullValue = inlineInput.value; const valueBeforeMention = fullValue.slice(0, rangeStart); const valueAfterMention = fullValue.slice(rangeEnd); @@ -662,7 +541,7 @@ function applyMentionReplacement(selectedEntry) { const hasTrailingSpace = valueAfterMention.startsWith(' '); const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); - + inlineInput.value = nextValue; inlineInput.setSelectionRange(nextCursor, nextCursor); chatMode.inputLine = nextValue; @@ -674,14 +553,12 @@ function applyMentionReplacement(selectedEntry) { * @returns {boolean} True when a suggestion was applied */ export function commitSelectedMentionSuggestion() { - if (!hasVisibleMentionSuggestions()) { - return false; - } - + if (!hasVisibleMentionSuggestions()) return false; + if (chatMode.mentionAutocomplete.index < 0) { chatMode.mentionAutocomplete.index = 0; } - + const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; applyMentionReplacement(selectedEntry); resetMentionAutocomplete(); @@ -694,35 +571,19 @@ export function commitSelectedMentionSuggestion() { * @returns {string} Message with mentions converted to @user:server */ function transformOutgoingMentions(message) { - if (!message || !chatMode.mentionDirectory.length) { - return message; - } - + if (!message || !chatMode.mentionDirectory.length) return message; + return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { const matchedEntry = chatMode.mentionDirectory.find((entry) => { return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); }); - - if (!matchedEntry) { - return fullMatch; - } - + + if (!matchedEntry) return fullMatch; + return `${leadingWhitespace}${matchedEntry.userId}`; }); } -/** - * Build a plain Matrix text payload with canonical mention IDs. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string}} Matrix content payload - */ -function buildPlainMentionMessageContent(message) { - return { - msgtype: 'm.text', - body: transformOutgoingMentions(message) - }; -} - /** * Check whether canonical Matrix mentions exist in text. * @param {string} text - Message text @@ -749,44 +610,19 @@ function buildFormattedMentionBody(resolvedBody) { }); } -/** - * Build rich mention payload without m.mentions for compatibility fallback. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string, format?: string, formatted_body?: string}} Matrix content payload - */ -function buildRichMentionFallbackContent(message) { - const resolvedBody = transformOutgoingMentions(message); - if (!hasCanonicalMentions(resolvedBody)) { - return { - msgtype: 'm.text', - body: resolvedBody - }; - } - - return { - msgtype: 'm.text', - body: resolvedBody, - format: 'org.matrix.custom.html', - formatted_body: buildFormattedMentionBody(resolvedBody) - }; -} - /** * Build Matrix message content with mention metadata and formatted HTML. * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string, "m.mentions"?: object, format?: string, formatted_body?: string}} Matrix content payload + * @returns {Object} Matrix content payload */ function buildMentionMessageContent(message) { const resolvedBody = transformOutgoingMentions(message); const hasMentions = hasCanonicalMentions(resolvedBody); - + if (!hasMentions) { - return { - msgtype: 'm.text', - body: resolvedBody - }; + return { msgtype: 'm.text', body: resolvedBody }; } - + return { msgtype: 'm.text', body: resolvedBody, @@ -802,10 +638,8 @@ function buildMentionMessageContent(message) { * @returns {string} Formatted message text for terminal output */ function formatChatTextWithMentions(text) { - if (!text) { - return ''; - } - + if (!text) return ''; + return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { const userId = `@${userBody}`; const knownDisplayName = chatMode.displayNames[userId]; @@ -820,27 +654,26 @@ function formatChatTextWithMentions(text) { * @returns {Promise} */ export async function applyMentionAutocomplete() { - if (!chatMode.active) { - return; - } - + if (!chatMode.active) return; + if (chatMode.mentionAutocomplete.matches.length === 0) { await refreshMentionSuggestionsFromInput(); - if (chatMode.mentionAutocomplete.matches.length === 0) { - return; - } + if (chatMode.mentionAutocomplete.matches.length === 0) return; chatMode.mentionAutocomplete.index = 0; } else { chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) % chatMode.mentionAutocomplete.matches.length; } - + const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; applyMentionReplacement(selectedEntry); renderMentionSuggestions(); } -// Enter chat mode +/** + * Enter chat mode + * @returns {Promise} + */ export async function enterChatMode() { if (!window.matrixSession || !window.matrixSession.roomId) { term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); @@ -868,7 +701,7 @@ export async function enterChatMode() { // Fetch initial messages await syncChatMessages(); - + // Show nickname setup hint in message history when display name is still default const showNicknameHint = await shouldShowNicknameHint(); if (showNicknameHint) { @@ -879,10 +712,8 @@ export async function enterChatMode() { term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); } - // Start polling for new messages - chatMode.pollInterval = setInterval(async () => { - await syncChatMessages(true); - }, 3000); + // Start sync loop + startSyncLoop(); // Add separator and initial prompt term.writeln(''); @@ -897,14 +728,118 @@ export async function enterChatMode() { updateQuickCommands('chat'); } -// Exit chat mode +/** + * Start Matrix sync loop + */ +function startSyncLoop() { + if (!mxClient) return; + + chatMode.pollInterval = setInterval(async () => { + try { + const data = await mxClient.sync(syncToken, 10000); + if (data) { + syncToken = data.next_batch; + mxClient.processSyncData(data); + } + } catch (error) { + console.error('Sync error:', error); + } + }, 3000); +} + +/** + * Sync messages from Matrix (initial load) + * @param {boolean} [onlyNew=false] - Only fetch new messages + * @returns {Promise} + */ +async function syncChatMessages(onlyNew = false) { + if (!mxClient || !window.matrixSession?.roomId) return; + + try { + const result = await mxClient.getMessages(window.matrixSession.roomId, { limit: 50, dir: 'b' }); + if (!result || !result.messages) return; + + const newMessages = []; + for (const event of result.messages.reverse()) { + if (event.type === 'm.room.message' && event.content?.msgtype === 'm.text') { + const msgId = event.event_id; + const exists = chatMode.messages.find(m => m.id === msgId); + + if (!exists) { + const timestamp = new Date(event.origin_server_ts); + const time = timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + + const displayName = await getDisplayName(event.sender); + + newMessages.push({ + id: msgId, + time: time, + sender: displayName, + userId: event.sender, + text: event.content.body + }); + } + } + } + + if (newMessages.length > 0) { + chatMode.messages.push(...newMessages); + + if (chatMode.messages.length > 100) { + chatMode.messages = chatMode.messages.slice(-100); + } + + if (!onlyNew && chatMode.active) { + const recent = chatMode.messages.slice(-20); + recent.forEach(msg => { + const color = getUserColor(msg.sender); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + }); + } + } + + chatMode.lastSync = Date.now(); + } catch (error) { + console.error('Sync error:', error); + } +} + +/** + * Render a chat message (insert above the prompt area) + * @param {Object} msg - Message object + */ +function renderChatMessage(msg) { + const color = getUserColor(msg.sender); + + term.write('\x1b[1A\x1b[2K\r'); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + + if (msg.text.startsWith('/samsay ')) { + const samMessage = msg.text.substring(8).trim(); + if (samMessage && typeof window.samSpeak === 'function') { + window.samSpeak(samMessage); + } + } + + term.writeln('─'.repeat(term.cols || 60)); + term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); + term.scrollToBottom(); + setTimeout(() => positionInlineInput(), 10); +} + +/** + * Exit chat mode + */ export function exitChatMode() { chatMode.active = false; if (chatMode.pollInterval) { clearInterval(chatMode.pollInterval); chatMode.pollInterval = null; } - chatMode.displayNames = {}; // Clear display name cache + chatMode.displayNames = {}; chatMode.mentionDirectory = []; chatMode.mentionLoadedAt = 0; resetMentionAutocomplete(); @@ -951,7 +886,6 @@ export function runChatCommand(command) { if (!chatMode.active) return; if (command === '/nick') { - // For /nick, just fill in the command prefix inlineInput.value = '/nick '; chatMode.inputLine = inlineInput.value; resetMentionAutocomplete(); @@ -960,7 +894,6 @@ export function runChatCommand(command) { } if (command === '/samsay') { - // For /samsay, just fill in the command prefix inlineInput.value = '/samsay '; chatMode.inputLine = inlineInput.value; resetMentionAutocomplete(); @@ -968,170 +901,46 @@ export function runChatCommand(command) { return; } - // Execute the command inlineInput.value = command; submitInlineInput(); } -// Sync messages from Matrix -async function syncChatMessages(onlyNew = false) { - try { - let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`; - const data = await matrixApi(endpoint); - - if (!data || !data.chunk) return; - - const newMessages = []; - for (const event of data.chunk.reverse()) { - if (event.type === 'm.room.message' && event.content.msgtype === 'm.text') { - const msgId = event.event_id; - const exists = chatMode.messages.find(m => m.id === msgId); - - if (!exists) { - const timestamp = new Date(event.origin_server_ts); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - - // Get display name for sender - const displayName = await getDisplayName(event.sender); - - newMessages.push({ - id: msgId, - time: time, - sender: displayName, - userId: event.sender, - text: event.content.body - }); - } - } - } - - if (newMessages.length > 0) { - chatMode.messages.push(...newMessages); - - // Only keep last 100 messages in memory - if (chatMode.messages.length > 100) { - chatMode.messages = chatMode.messages.slice(-100); - } - - // Render new messages if in chat mode and only updating - if (onlyNew && chatMode.active) { - newMessages.forEach(msg => { - renderChatMessage(msg); - }); - } else if (!onlyNew && chatMode.active) { - // Initial load - show last 20 messages (simple print, no cursor manipulation) - const recent = chatMode.messages.slice(-20); - recent.forEach(msg => { - const color = getUserColor(msg.sender); - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - - // If message starts with /samsay, speak it - if (msg.text.startsWith('/samsay ')) { - const samMessage = msg.text.substring(8).trim(); - if (samMessage && typeof window.samSpeak === 'function') { - window.samSpeak(samMessage); - } - } - }); - } - } - - chatMode.lastSync = Date.now(); - } catch (error) { - console.error('Sync error:', error); - } -} - -// Render a chat message (insert above the prompt area) -function renderChatMessage(msg) { - const color = getUserColor(msg.sender); - - // Move up to separator line and clear it and the prompt line - term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it - - // Write the new message - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - - // If message starts with /samsay, speak it - if (msg.text.startsWith('/samsay ')) { - const samMessage = msg.text.substring(8).trim(); - if (samMessage && typeof window.samSpeak === 'function') { - window.samSpeak(samMessage); - } - } - - // Redraw separator - term.writeln('─'.repeat(term.cols || 60)); - - // Redraw prompt with current input - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); - - // Scroll to bottom to show new message - term.scrollToBottom(); - - // Reposition the inline input after terminal updates - setTimeout(() => positionInlineInput(), 10); -} - -// Render chat input prompt (efficiently) +/** + * Render chat input prompt (efficiently) + */ export function renderChatPrompt() { - // Move to beginning of line, redraw prompt and input - term.write('\r\x1b[K'); // CR + clear rest of line + term.write('\r\x1b[K'); term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); } -// Send chat message +/** + * Send chat message + * @param {string} message - Message to send + * @returns {Promise} + */ export async function sendChatMessage(message) { if (!message.trim()) return; + if (!mxClient || !window.matrixSession?.roomId) return; try { await syncMentionDirectory(); const content = buildMentionMessageContent(message); - const hasMentions = hasCanonicalMentions(content.body); - const txnId = Date.now().toString(); - let sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, - 'PUT', - content + + const result = await mxClient.sendMessage( + window.matrixSession.roomId, + content.body, + content.formatted_body || null ); - - if (sendResult && sendResult.errcode && hasMentions) { - const richFallbackContent = buildRichMentionFallbackContent(message); - sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-richfallback`, - 'PUT', - richFallbackContent - ); - } - - if (sendResult && sendResult.errcode && !hasMentions) { - const fallbackContent = buildPlainMentionMessageContent(message); - sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-fallback`, - 'PUT', - fallbackContent - ); - } - - if (sendResult && sendResult.errcode) { - throw new Error(sendResult.error || sendResult.errcode || 'Failed to send message'); + + if (!result) { + throw new Error('Failed to send message'); } - // Clear the input chatMode.inputLine = ''; resetMentionAutocomplete(); renderChatPrompt(); - - // Reposition input after render setTimeout(() => positionInlineInput(), 10); - - // Immediately sync to show our message - setTimeout(() => syncChatMessages(true), 500); } catch (error) { - // Show error above separator term.write('\x1b[1A\x1b[2K\r'); term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); term.writeln('─'.repeat(term.cols || 60)); diff --git a/website/matrix-client.old.js b/website/matrix-client.old.js new file mode 100644 index 0000000..64b63a5 --- /dev/null +++ b/website/matrix-client.old.js @@ -0,0 +1,1133 @@ +/** + * Matrix Client - Generic Matrix protocol library + */ + +// Configuration +let config = { + homeserver: 'https://matrix.org', + bridgeUrl: null, + useBridge: false, + publicReadToken: null +}; + +// Dependencies that will be injected +let term, inlineInput, mentionSuggestions, writeClickable, writePrompt, showInlineInput, positionInlineInput, submitInlineInput; + +/** + * Initialize Matrix client with configuration and dependencies + * @param {Object} userConfig - Configuration options + * @param {string} userConfig.homeserver - Matrix homeserver URL + * @param {string} [userConfig.bridgeUrl] - Bridge iframe URL for CSP-restricted hosts + * @param {boolean} [userConfig.useBridge] - Whether to use iframe bridge + * @param {string} [userConfig.publicReadToken] - Public read token for unauthenticated requests + * @param {Object} deps - UI dependencies + */ +export function initMatrixClient(userConfig, deps) { + // Merge config + config = { ...config, ...userConfig }; + + // Inject dependencies + if (deps) { + term = deps.term; + inlineInput = deps.inlineInput; + mentionSuggestions = deps.mentionSuggestions; + writeClickable = deps.writeClickable; + writePrompt = deps.writePrompt; + showInlineInput = deps.showInlineInput; + positionInlineInput = deps.positionInlineInput; + submitInlineInput = deps.submitInlineInput; + } +} + +// Chat mode state +export const chatMode = { + active: false, + messages: [], + lastSync: null, + pollInterval: null, + inputLine: '', + displayNames: {}, // Cache for display names + mentionDirectory: [], + mentionLoadedAt: 0, + mentionAutocomplete: { + tokenStart: -1, + tokenEnd: -1, + matches: [], + index: 0 + } +}; + +/** + * Matrix Bridge - Iframe-based communication for CSP-restricted hosts + */ +const matrixBridge = { + iframe: null, + ready: false, + pendingRequests: new Map(), + requestCounter: 0 +}; + +/** + * Initialize the Matrix iframe bridge + * @returns {Promise} True if bridge loaded successfully + */ +async function initMatrixBridge() { + if (!config.bridgeUrl) { + throw new Error('Bridge URL not configured'); + } + + if (matrixBridge.iframe) { + return matrixBridge.ready; + } + + return new Promise((resolve) => { + const iframe = document.createElement('iframe'); + iframe.src = config.bridgeUrl; + iframe.style.display = 'none'; + iframe.style.position = 'absolute'; + iframe.style.width = '0'; + iframe.style.height = '0'; + iframe.style.border = 'none'; + + const timeout = setTimeout(() => { + console.error('[Matrix Bridge] Failed to load iframe bridge'); + matrixBridge.ready = false; + resolve(false); + }, 10000); + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'matrix:bridge:ready') { + clearTimeout(timeout); + matrixBridge.ready = true; + console.log('[Matrix Bridge] Iframe bridge ready'); + resolve(true); + } + }); + + document.body.appendChild(iframe); + matrixBridge.iframe = iframe; + }); +} + +/** + * Send a request to the Matrix bridge via postMessage + * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') + * @param {object} payload - Request payload + * @returns {Promise} Response from bridge + */ +function matrixBridgeRequest(type, payload) { + return new Promise((resolve, reject) => { + if (!matrixBridge.iframe || !matrixBridge.ready) { + reject(new Error('Matrix bridge not initialized')); + return; + } + + const requestId = `req_${++matrixBridge.requestCounter}`; + const timeout = setTimeout(() => { + matrixBridge.pendingRequests.delete(requestId); + reject(new Error('Matrix bridge request timeout')); + }, 30000); + + matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); + + matrixBridge.iframe.contentWindow.postMessage({ + type, + requestId, + payload + }, config.bridgeUrl); + }); +} + +/** + * Handle responses from the Matrix bridge + */ +window.addEventListener('message', (event) => { + if (config.bridgeUrl && event.origin !== new URL(config.bridgeUrl).origin) { + return; + } + + const { type, requestId, payload } = event.data; + + if (!type || !requestId || !type.includes(':response')) { + return; + } + + const pending = matrixBridge.pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeout); + matrixBridge.pendingRequests.delete(requestId); + pending.resolve(payload); + } +}); + +// Matrix API helper +export const matrixApi = async (endpoint, method = 'GET', body = null) => { + if (!window.matrixSession) return null; + + // Use iframe bridge if configured + if (config.useBridge) { + if (!matrixBridge.ready) { + const initialized = await initMatrixBridge(); + if (!initialized) { + return { + errcode: 'M_BRIDGE_UNAVAILABLE', + error: 'Matrix bridge failed to initialize' + }; + } + } + + try { + const result = await matrixBridgeRequest('matrix:api', { + endpoint, + method, + body, + accessToken: window.matrixSession.accessToken + }); + return result; + } catch (error) { + return { + errcode: 'M_BRIDGE_ERROR', + error: error.message + }; + } + } + + // Direct API call + const url = `${config.homeserver}/_matrix/client/r0${endpoint}`; + const headers = { + 'Content-Type': 'application/json' + }; + + if (window.matrixSession.accessToken) { + headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`; + } + + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch(url, options); + return response.json(); +}; + +/** + * Fetch the latest public message from a room + * @param {string} roomAlias - Room alias (e.g. #room:server.com) + * @returns {Promise<{sender: string, body: string, timestamp: number} | null>} + */ +export async function fetchPublicLastMessage(roomAlias) { + if (!config.publicReadToken) { + console.warn('No public read token configured'); + return null; + } + + try { + // Use iframe bridge if configured + if (config.useBridge) { + if (!matrixBridge.ready) { + await initMatrixBridge(); + } + + const result = await matrixBridgeRequest('matrix:fetchLastMessage', { + roomAlias, + publicToken: config.publicReadToken + }); + + if (!result || result.error || !Array.isArray(result.chunk)) { + return null; + } + + const lastEvent = result.chunk.find(e => + e && e.type === 'm.room.message' && e.content && e.content.body + ); + + if (!lastEvent) return null; + + return { + sender: lastEvent.sender, + body: lastEvent.content.body, + timestamp: lastEvent.origin_server_ts || Date.now() + }; + } + + // Direct API call + const resolvedAlias = encodeURIComponent(roomAlias); + const roomResponse = await fetch( + `${config.homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, + { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } + ); + + if (!roomResponse.ok) return null; + + const roomData = await roomResponse.json(); + const roomId = roomData?.room_id; + if (!roomId) return null; + + const messagesResponse = await fetch( + `${config.homeserver}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=10`, + { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } + ); + + if (!messagesResponse.ok) return null; + + const messagesData = await messagesResponse.json(); + const lastEvent = messagesData.chunk?.find(e => + e && e.type === 'm.room.message' && e.content && e.content.body + ); + + if (!lastEvent) return null; + + return { + sender: lastEvent.sender, + body: lastEvent.content.body, + timestamp: lastEvent.origin_server_ts || Date.now() + }; + } catch (error) { + console.error('Failed to fetch public last message:', error); + return null; + } +} + +/** + * Fetch user presence + * @param {string} userId - Full Matrix user ID + * @returns {Promise<{presence: string, lastActive: number} | null>} + */ +export async function fetchPublicPresence(userId) { + if (!config.publicReadToken) { + console.warn('No public read token configured'); + return null; + } + + try { + // Use iframe bridge if configured + if (config.useBridge) { + if (!matrixBridge.ready) { + await initMatrixBridge(); + } + + const result = await matrixBridgeRequest('matrix:fetchPresence', { + userId, + publicToken: config.publicReadToken + }); + + if (result && !result.error && result.presence) { + return { + presence: result.presence, + lastActive: result.last_active_ago || 0 + }; + } + return null; + } + + // Direct API call + const response = await fetch( + `${config.homeserver}/_matrix/client/r0/presence/${encodeURIComponent(userId)}/status`, + { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } + ); + + if (!response.ok) return null; + + const data = await response.json(); + return { + presence: data.presence, + lastActive: data.last_active_ago || 0 + }; + } catch (error) { + console.error('Failed to fetch presence:', error); + return null; + } +} + +/** + * Format a unix-millisecond timestamp as relative age text. + * @param {number} timestampMs - Epoch timestamp in milliseconds + * @returns {string} Relative time label + */ +export function formatTimeAgo(timestampMs) { + if (typeof timestampMs !== 'number') { + return 'unknown'; + } + + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)); + if (elapsedSeconds < 60) { + return 'just now'; + } + + if (elapsedSeconds < 3600) { + return `${Math.floor(elapsedSeconds / 60)}m ago`; + } + + if (elapsedSeconds < 86400) { + return `${Math.floor(elapsedSeconds / 3600)}h ago`; + } + + return `${Math.floor(elapsedSeconds / 86400)}d ago`; +} + +// Get display name for a user (with caching) +async function getDisplayName(userId) { + // Check cache first + if (chatMode.displayNames[userId]) { + return chatMode.displayNames[userId]; + } + + try { + const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET'); + const displayName = data.displayname || userId.split(':')[0].substring(1); + chatMode.displayNames[userId] = displayName; + return displayName; + } catch (error) { + // Fallback to username part + const fallback = userId.split(':')[0].substring(1); + chatMode.displayNames[userId] = fallback; + return fallback; + } +} + +// Get color for user based on their ID (consistent hashing) +function getUserColor(username) { + // Color palette that works well on dark green background + const colors = [ + '\x1b[91m', // bright red + '\x1b[92m', // bright green + '\x1b[93m', // bright yellow + '\x1b[94m', // bright blue + '\x1b[95m', // bright magenta + '\x1b[96m', // bright cyan + '\x1b[33m', // yellow + '\x1b[35m', // magenta + '\x1b[36m', // cyan + ]; + + // Simple hash function + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = ((hash << 5) - hash) + username.charCodeAt(i); + hash = hash & hash; // Convert to 32bit integer + } + + return colors[Math.abs(hash) % colors.length]; +} + +// Check if display name is already taken +export async function isDisplayNameTaken(newName) { + try { + const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); + if (members && members.joined) { + for (const [userId, member] of Object.entries(members.joined)) { + // Skip our own user + if (userId === window.matrixSession.userId) continue; + + const displayName = member.display_name || userId.split(':')[0].substring(1); + if (displayName.toLowerCase() === newName.toLowerCase()) { + return true; + } + } + } + return false; + } catch (error) { + console.error('Error checking display names:', error); + return false; // Allow on error + } +} + +/** + * Determine if a Matrix display name appears unchanged/default. + * @param {string} displayName - Current display name + * @returns {boolean} True when name looks like an auto-generated default + */ +function isDefaultDisplayName(displayName) { + if (!displayName) { + return true; + } + + const trimmedName = displayName.trim(); + if (!trimmedName) { + return true; + } + + const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidV4Pattern.test(trimmedName); +} + +/** + * Check whether to show a nickname setup hint for the current user. + * @returns {Promise} True when the user should be prompted to change nickname + */ +async function shouldShowNicknameHint() { + if (!window.matrixSession || !window.matrixSession.userId) { + return false; + } + + try { + const encodedUserId = encodeURIComponent(window.matrixSession.userId); + const profileData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); + const displayName = profileData && typeof profileData.displayname === 'string' + ? profileData.displayname + : ''; + + return isDefaultDisplayName(displayName); + } catch (error) { + return false; + } +} + +/** + * Sync mention directory for autocomplete and mention rendering. + * @param {boolean} force - When true, bypass cache time + * @returns {Promise} + */ +async function syncMentionDirectory(force = false) { + if (!window.matrixSession || !window.matrixSession.roomId) { + return; + } + + const now = Date.now(); + if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { + return; + } + + try { + const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); + if (!members || !members.joined) { + return; + } + + const directory = []; + Object.entries(members.joined).forEach(([userId, member]) => { + const fallbackName = userId.split(':')[0].substring(1); + const displayName = member && member.display_name ? member.display_name : fallbackName; + chatMode.displayNames[userId] = displayName; + directory.push({ userId, displayName }); + }); + + chatMode.mentionDirectory = directory; + chatMode.mentionLoadedAt = now; + } catch (error) { + // Ignore mention directory refresh failures. + } +} + +/** + * Get mention autocomplete matches for query. + * @param {string} query - Partial display name text without @ + * @returns {Array<{userId: string, displayName: string}>} Sorted matches + */ +function getMentionMatches(query) { + const queryLower = query.toLowerCase(); + const matches = chatMode.mentionDirectory.filter((entry) => { + const display = entry.displayName.toLowerCase(); + const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); + return display.startsWith(queryLower) || localPart.startsWith(queryLower); + }); + + matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); + return matches; +} + +/** + * Escape text for HTML output. + * @param {string} value - Raw text + * @returns {string} HTML-escaped text + */ +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Extract current mention token context from the input cursor. + * @returns {{atIndex: number, cursorPosition: number, tokenText: string} | null} Token info or null + */ +function getMentionTokenContext() { + const cursorPosition = inlineInput.selectionStart; + const fullValue = inlineInput.value; + const textBeforeCursor = fullValue.slice(0, cursorPosition); + const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); + + if (tokenStart === -1) { + return null; + } + + const atIndex = textBeforeCursor.indexOf('@', tokenStart); + if (atIndex === -1) { + return null; + } + + const tokenText = textBeforeCursor.slice(atIndex + 1); + if (!tokenText || tokenText.includes(':')) { + return null; + } + + return { atIndex, cursorPosition, tokenText }; +} + +/** + * Render mention suggestions above the inline input. + * @returns {void} + */ +function renderMentionSuggestions() { + const { matches, index } = chatMode.mentionAutocomplete; + if (!chatMode.active || matches.length === 0) { + mentionSuggestions.style.display = 'none'; + return; + } + + const limitedMatches = matches.slice(0, 5); + mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { + const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; + return `@${escapeHtml(entry.displayName)}`; + }).join(''); + mentionSuggestions.style.display = 'block'; + positionInlineInput(); +} + +/** + * Check whether mention suggestions are currently visible. + * @returns {boolean} True when suggestion strip is active + */ +export function hasVisibleMentionSuggestions() { + return chatMode.active + && mentionSuggestions.style.display !== 'none' + && chatMode.mentionAutocomplete.matches.length > 0; +} + +/** + * Hide mention suggestion UI and clear state. + * @returns {void} + */ +export function resetMentionAutocomplete() { + chatMode.mentionAutocomplete = { + tokenStart: -1, + tokenEnd: -1, + matches: [], + index: 0 + }; + mentionSuggestions.style.display = 'none'; + mentionSuggestions.innerHTML = ''; +} + +/** + * Refresh mention suggestions based on current cursor token. + * @returns {Promise} + */ +export async function refreshMentionSuggestionsFromInput() { + if (!chatMode.active) { + resetMentionAutocomplete(); + return; + } + + const context = getMentionTokenContext(); + if (!context) { + resetMentionAutocomplete(); + return; + } + + await syncMentionDirectory(); + const matches = getMentionMatches(context.tokenText); + if (matches.length === 0) { + resetMentionAutocomplete(); + return; + } + + chatMode.mentionAutocomplete = { + tokenStart: context.atIndex, + tokenEnd: context.cursorPosition, + matches, + index: -1 + }; + renderMentionSuggestions(); +} + +/** + * Replace selected mention token in input with selected display name. + * @param {{userId: string, displayName: string}} selectedEntry - Selected mention entry + * @returns {void} + */ +function applyMentionReplacement(selectedEntry) { + const rangeStart = chatMode.mentionAutocomplete.tokenStart; + const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; + if (rangeStart < 0 || rangeEnd < 0) { + return; + } + + const fullValue = inlineInput.value; + const valueBeforeMention = fullValue.slice(0, rangeStart); + const valueAfterMention = fullValue.slice(rangeEnd); + const mentionText = `@${selectedEntry.displayName}`; + const hasTrailingSpace = valueAfterMention.startsWith(' '); + const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; + const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); + + inlineInput.value = nextValue; + inlineInput.setSelectionRange(nextCursor, nextCursor); + chatMode.inputLine = nextValue; + chatMode.mentionAutocomplete.tokenEnd = nextCursor; +} + +/** + * Commit the currently selected mention suggestion into input text. + * @returns {boolean} True when a suggestion was applied + */ +export function commitSelectedMentionSuggestion() { + if (!hasVisibleMentionSuggestions()) { + return false; + } + + if (chatMode.mentionAutocomplete.index < 0) { + chatMode.mentionAutocomplete.index = 0; + } + + const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; + applyMentionReplacement(selectedEntry); + resetMentionAutocomplete(); + return true; +} + +/** + * Resolve typed @displayname mentions to Matrix user IDs before sending. + * @param {string} message - Outgoing message text + * @returns {string} Message with mentions converted to @user:server + */ +function transformOutgoingMentions(message) { + if (!message || !chatMode.mentionDirectory.length) { + return message; + } + + return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { + const matchedEntry = chatMode.mentionDirectory.find((entry) => { + return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); + }); + + if (!matchedEntry) { + return fullMatch; + } + + return `${leadingWhitespace}${matchedEntry.userId}`; + }); +} + +/** + * Build a plain Matrix text payload with canonical mention IDs. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string}} Matrix content payload + */ +function buildPlainMentionMessageContent(message) { + return { + msgtype: 'm.text', + body: transformOutgoingMentions(message) + }; +} + +/** + * Check whether canonical Matrix mentions exist in text. + * @param {string} text - Message text + * @returns {boolean} True when one or more @user:server mentions are present + */ +function hasCanonicalMentions(text) { + return /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/.test(text); +} + +/** + * Build formatted HTML body with matrix.to links for mentions. + * @param {string} resolvedBody - Message body with canonical mention IDs + * @returns {string} HTML formatted body + */ +function buildFormattedMentionBody(resolvedBody) { + const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; + return escapeHtml(resolvedBody).replace(mentionRegex, (matchedValue, userBody) => { + const userId = `@${userBody}`; + const knownDisplayName = chatMode.displayNames[userId]; + const fallbackDisplayName = userId.split(':')[0].substring(1); + const displayName = knownDisplayName || fallbackDisplayName; + const matrixToUrl = `https://matrix.to/#/${userId}`; + return `@${escapeHtml(displayName)}`; + }); +} + +/** + * Build rich mention payload without m.mentions for compatibility fallback. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string, format?: string, formatted_body?: string}} Matrix content payload + */ +function buildRichMentionFallbackContent(message) { + const resolvedBody = transformOutgoingMentions(message); + if (!hasCanonicalMentions(resolvedBody)) { + return { + msgtype: 'm.text', + body: resolvedBody + }; + } + + return { + msgtype: 'm.text', + body: resolvedBody, + format: 'org.matrix.custom.html', + formatted_body: buildFormattedMentionBody(resolvedBody) + }; +} + +/** + * Build Matrix message content with mention metadata and formatted HTML. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string, "m.mentions"?: object, format?: string, formatted_body?: string}} Matrix content payload + */ +function buildMentionMessageContent(message) { + const resolvedBody = transformOutgoingMentions(message); + const hasMentions = hasCanonicalMentions(resolvedBody); + + if (!hasMentions) { + return { + msgtype: 'm.text', + body: resolvedBody + }; + } + + return { + msgtype: 'm.text', + body: resolvedBody, + 'm.mentions': {}, + format: 'org.matrix.custom.html', + formatted_body: buildFormattedMentionBody(resolvedBody) + }; +} + +/** + * Render Matrix user mentions as highlighted @displayname values. + * @param {string} text - Chat message body + * @returns {string} Formatted message text for terminal output + */ +function formatChatTextWithMentions(text) { + if (!text) { + return ''; + } + + return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { + const userId = `@${userBody}`; + const knownDisplayName = chatMode.displayNames[userId]; + const fallbackDisplayName = userId.split(':')[0].substring(1); + const displayName = knownDisplayName || fallbackDisplayName; + return `\x1b[1;96m@${displayName}\x1b[0m`; + }); +} + +/** + * Cycle mention suggestions and apply selected display name. + * @returns {Promise} + */ +export async function applyMentionAutocomplete() { + if (!chatMode.active) { + return; + } + + if (chatMode.mentionAutocomplete.matches.length === 0) { + await refreshMentionSuggestionsFromInput(); + if (chatMode.mentionAutocomplete.matches.length === 0) { + return; + } + chatMode.mentionAutocomplete.index = 0; + } else { + chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) + % chatMode.mentionAutocomplete.matches.length; + } + + const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; + applyMentionReplacement(selectedEntry); + renderMentionSuggestions(); +} + +// Enter chat mode +export async function enterChatMode() { + if (!window.matrixSession || !window.matrixSession.roomId) { + term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); + return; + } + + chatMode.active = true; + chatMode.messages = []; + chatMode.lastSync = null; + chatMode.inputLine = ''; + chatMode.mentionAutocomplete = { + tokenStart: -1, + tokenEnd: -1, + matches: [], + index: 0 + }; + resetMentionAutocomplete(); + await syncMentionDirectory(true); + + term.clear(); + term.writeln('╔════════════════════════════════════════════════════════════╗'); + term.writeln('║ CHAT - #generalchat ║'); + term.writeln('║ Type /help for commands ║'); + term.writeln('╚════════════════════════════════════════════════════════════╝'); + + // Fetch initial messages + await syncChatMessages(); + + // Show nickname setup hint in message history when display name is still default + const showNicknameHint = await shouldShowNicknameHint(); + if (showNicknameHint) { + const hintTime = new Date().toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); + } + + // Start polling for new messages + chatMode.pollInterval = setInterval(async () => { + await syncChatMessages(true); + }, 3000); + + // Add separator and initial prompt + term.writeln(''); + term.writeln('─'.repeat(term.cols || 60)); + term.write('\x1b[1;32m>\x1b[0m '); + + // Delay showing input to ensure terminal has rendered and cursor is positioned + setTimeout(() => { + showInlineInput(); + }, 50); + + updateQuickCommands('chat'); +} + +// Exit chat mode +export function exitChatMode() { + chatMode.active = false; + if (chatMode.pollInterval) { + clearInterval(chatMode.pollInterval); + chatMode.pollInterval = null; + } + chatMode.displayNames = {}; // Clear display name cache + chatMode.mentionDirectory = []; + chatMode.mentionLoadedAt = 0; + resetMentionAutocomplete(); + term.clear(); + term.writeln(' Exited chat mode.'); + writePrompt(); + showInlineInput(); + updateQuickCommands('terminal'); +} + +/** + * Update quick command buttons based on context + * @param {string} mode - 'terminal' or 'chat' + */ +export function updateQuickCommands(mode) { + const container = document.querySelector('.quick-commands'); + const statusLeft = document.querySelector('.status-left'); + if (!container) return; + + if (mode === 'chat') { + if (statusLeft) statusLeft.textContent = 'Chat Mode'; + container.innerHTML = ` + + + + + `; + } else { + if (statusLeft) statusLeft.textContent = 'Ready'; + container.innerHTML = ` + + + + + `; + } +} + +/** + * Run a chat command from button click + * @param {string} command - The chat command to run + */ +export function runChatCommand(command) { + if (!chatMode.active) return; + + if (command === '/nick') { + // For /nick, just fill in the command prefix + inlineInput.value = '/nick '; + chatMode.inputLine = inlineInput.value; + resetMentionAutocomplete(); + inlineInput.focus(); + return; + } + + if (command === '/samsay') { + // For /samsay, just fill in the command prefix + inlineInput.value = '/samsay '; + chatMode.inputLine = inlineInput.value; + resetMentionAutocomplete(); + inlineInput.focus(); + return; + } + + // Execute the command + inlineInput.value = command; + submitInlineInput(); +} + +// Sync messages from Matrix +async function syncChatMessages(onlyNew = false) { + try { + let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`; + const data = await matrixApi(endpoint); + + if (!data || !data.chunk) return; + + const newMessages = []; + for (const event of data.chunk.reverse()) { + if (event.type === 'm.room.message' && event.content.msgtype === 'm.text') { + const msgId = event.event_id; + const exists = chatMode.messages.find(m => m.id === msgId); + + if (!exists) { + const timestamp = new Date(event.origin_server_ts); + const time = timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + + // Get display name for sender + const displayName = await getDisplayName(event.sender); + + newMessages.push({ + id: msgId, + time: time, + sender: displayName, + userId: event.sender, + text: event.content.body + }); + } + } + } + + if (newMessages.length > 0) { + chatMode.messages.push(...newMessages); + + // Only keep last 100 messages in memory + if (chatMode.messages.length > 100) { + chatMode.messages = chatMode.messages.slice(-100); + } + + // Render new messages if in chat mode and only updating + if (onlyNew && chatMode.active) { + newMessages.forEach(msg => { + renderChatMessage(msg); + }); + } else if (!onlyNew && chatMode.active) { + // Initial load - show last 20 messages (simple print, no cursor manipulation) + const recent = chatMode.messages.slice(-20); + recent.forEach(msg => { + const color = getUserColor(msg.sender); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + }); + } + } + + chatMode.lastSync = Date.now(); + } catch (error) { + console.error('Sync error:', error); + } +} + +// Render a chat message (insert above the prompt area) +function renderChatMessage(msg) { + const color = getUserColor(msg.sender); + + // Move up to separator line and clear it and the prompt line + term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it + + // Write the new message + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + + // If message starts with /samsay, speak it + if (msg.text.startsWith('/samsay ')) { + const samMessage = msg.text.substring(8).trim(); + if (samMessage && typeof window.samSpeak === 'function') { + window.samSpeak(samMessage); + } + } + + // Redraw separator + term.writeln('─'.repeat(term.cols || 60)); + + // Redraw prompt with current input + term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); + + // Scroll to bottom to show new message + term.scrollToBottom(); + + // Reposition the inline input after terminal updates + setTimeout(() => positionInlineInput(), 10); +} + +// Render chat input prompt (efficiently) +export function renderChatPrompt() { + // Move to beginning of line, redraw prompt and input + term.write('\r\x1b[K'); // CR + clear rest of line + term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); +} + +// Send chat message +export async function sendChatMessage(message) { + if (!message.trim()) return; + + try { + await syncMentionDirectory(); + const content = buildMentionMessageContent(message); + const hasMentions = hasCanonicalMentions(content.body); + const txnId = Date.now().toString(); + let sendResult = await matrixApi( + `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, + 'PUT', + content + ); + + if (sendResult && sendResult.errcode && hasMentions) { + const richFallbackContent = buildRichMentionFallbackContent(message); + sendResult = await matrixApi( + `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-richfallback`, + 'PUT', + richFallbackContent + ); + } + + if (sendResult && sendResult.errcode && !hasMentions) { + const fallbackContent = buildPlainMentionMessageContent(message); + sendResult = await matrixApi( + `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-fallback`, + 'PUT', + fallbackContent + ); + } + + if (sendResult && sendResult.errcode) { + throw new Error(sendResult.error || sendResult.errcode || 'Failed to send message'); + } + + // Clear the input + chatMode.inputLine = ''; + resetMentionAutocomplete(); + renderChatPrompt(); + + // Reposition input after render + setTimeout(() => positionInlineInput(), 10); + + // Immediately sync to show our message + setTimeout(() => syncChatMessages(true), 500); + } catch (error) { + // Show error above separator + term.write('\x1b[1A\x1b[2K\r'); + term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); + term.writeln('─'.repeat(term.cols || 60)); + term.write(`\x1b[1;32m>\x1b[0m `); + setTimeout(() => positionInlineInput(), 10); + } +} diff --git a/website/mxjs-lite.js b/website/mxjs-lite.js new file mode 100644 index 0000000..0bbfc06 --- /dev/null +++ b/website/mxjs-lite.js @@ -0,0 +1,1243 @@ +const SANITIZE_ALLOWED_TAGS = new Set([ + "a", + "b", + "strong", + "i", + "em", + "code", + "del", + "s", + "strike", + "u", + "span", + "br", +]); + +const enc = encodeURIComponent; +const cerr = console.error.bind(console); +const M_MSG = "m.room.message"; +const M_MEMBER = "m.room.member"; +const M_REACT = "m.reaction"; +const M_REDACTION = "m.room.redaction"; +const M_RNAME = "m.room.name"; +const M_RTOPIC = "m.room.topic"; +const M_RAVATAR = "m.room.avatar"; +const M_REL = "m.relates_to"; +const M_NEWCONT = "m.new_content"; +const M_REPLACE = "m.replace"; +const M_ANNOT = "m.annotation"; +const M_LPWD = "m.login.password"; +const M_IDUSER = "m.id.user"; +const M_TEXT = "m.text"; +const M_IMAGE = "m.image"; +const M_HTML = "org.matrix.custom.html"; +const MATRIX_TO = "https://matrix.to/#/"; + +/** + * A lightweight Matrix client for interacting with the Matrix homeserver API. + */ +export class MxjsClient { + /** @type {Map>} */ + #handlers = new Map(); + + /** @type {Set} Tracks room IDs seen in sync responses to detect new joins. */ + #knownRoomIds = new Set(); + + /** + * @param {object} [options] + * @param {string} [options.homeserver="https://matrix.org"] - The Matrix homeserver base URL. + * @param {string|null} [options.publicReadToken=null] - Access token used for unauthenticated public read operations. + */ + constructor({ + homeserver = "https://matrix.org", + publicReadToken = null, + } = {}) { + this.homeserver = homeserver; + this.publicReadToken = publicReadToken; + this.accessToken = null; + this.userId = null; + } + + /** + * Makes a raw Matrix Client-Server API request. + * @param {string} endpoint - The endpoint path relative to `/_matrix/client/r0`. + * @param {string} [method="GET"] - HTTP method. + * @param {Object|null} [body=null] - Request body, serialized as JSON. + * @param {string|null} [accessToken=this.accessToken] - Bearer token override. + * @returns {Promise} The parsed JSON response. + */ + async api( + endpoint, + method = "GET", + body = null, + accessToken = this.accessToken, + ) { + const url = `${this.homeserver}/_matrix/client/r0${endpoint}`; + const headers = { "Content-Type": "application/json" }; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + const response = await fetch(url, options); + const data = await response.json(); + if (data.errcode === "M_LIMIT_EXCEEDED") { + await new Promise((r) => setTimeout(r, data.retry_after_ms ?? 1000)); + return (await fetch(url, options)).json(); + } + return data; + } + + /** + * Performs a UIAA (User-Interactive Authentication) two-step POST request. + * @param {string} endpoint - API endpoint path. + * @param {Object} firstBody - Initial request body to retrieve the UIAA session. + * @param {function(string): Object} buildAuthBody - Callback receiving the session ID and returning the final auth body. + * @param {string|null} [accessToken=this.accessToken] - Bearer token override. + * @returns {Promise} The final response data. + */ + async #uiaaRequest( + endpoint, + firstBody, + buildAuthBody, + accessToken = this.accessToken, + ) { + const headers = { "Content-Type": "application/json" }; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + const url = `${this.homeserver}/_matrix/client/r0${endpoint}`; + const initRes = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(firstBody), + }); + const initData = await initRes.json(); + if (initRes.ok) return initData; + if (!initData.session) throw new Error(initData.error || initData.errcode); + const authData = await ( + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(buildAuthBody(initData.session)), + }) + ).json(); + if (authData.errcode) throw new Error(authData.error || authData.errcode); + return authData; + } + + /** + * Stores session credentials from a login or register response. + * @param {Object} data - Response containing `access_token` and `user_id`. + * @returns {{accessToken: string, userId: string}} + */ + #storeSession(data) { + this.accessToken = data.access_token; + this.userId = data.user_id; + const session = { accessToken: data.access_token, userId: data.user_id }; + this.emit('connect', session); + return session; + } + + /** + * Registers a new account on the homeserver. + * @param {string} username + * @param {string} password + * @returns {Promise<{accessToken: string, userId: string}|null>} Session info, or `null` on failure. + */ + async register(username, password) { + try { + return this.#storeSession( + await this.#uiaaRequest( + "/register", + { username, password }, + (s) => ({ + username, + password, + auth: { type: "m.login.dummy", session: s }, + }), + null, + ), + ); + } catch (e) { + cerr("register:", e); + return null; + } + } + + /** + * Registers an anonymous guest account on the homeserver. + * @returns {Promise<{accessToken: string, userId: string}|null>} Session info, or `null` on failure. + */ + async registerGuest() { + try { + const data = await this.api("/register?kind=guest", "POST", {}, null); + if (data.errcode) throw new Error(data.error || data.errcode); + return this.#storeSession(data); + } catch (e) { + cerr("guest:", e); + return null; + } + } + + /** + * Logs in with a username and password. + * @param {string} username + * @param {string} password + * @returns {Promise<{accessToken: string, userId: string}|null>} Session info, or `null` on failure. + */ + async login(username, password) { + try { + const data = await this.api( + "/login", + "POST", + { + type: M_LPWD, + identifier: { type: M_IDUSER, user: username }, + password, + }, + null, + ); + if (data.errcode) throw new Error(data.error || data.errcode); + return this.#storeSession(data); + } catch (e) { + cerr("login:", e); + return null; + } + } + + /** + * Clears the locally stored access token and user ID. + */ + logout() { + this.accessToken = null; + this.userId = null; + this.#knownRoomIds.clear(); + this.emit('disconnect'); + } + + /** + * Permanently deactivates the current user's account. + * @param {string} password - Current account password for UIAA confirmation. + * @returns {Promise} `true` on success. + */ + async deactivateAccount(password) { + try { + await this.#uiaaRequest("/account/deactivate", {}, (s) => ({ + auth: { + type: M_LPWD, + session: s, + identifier: { type: M_IDUSER, user: this.userId }, + password, + }, + })); + this.logout(); + return true; + } catch (e) { + cerr("deactivate:", e); + return false; + } + } + + /** + * Changes the current user's password. + * @param {string} oldPassword + * @param {string} newPassword + * @returns {Promise} `true` on success. + */ + async changePassword(oldPassword, newPassword) { + try { + await this.#uiaaRequest( + "/account/password", + { new_password: newPassword }, + (s) => ({ + new_password: newPassword, + auth: { + type: M_LPWD, + session: s, + identifier: { type: M_IDUSER, user: this.userId }, + password: oldPassword, + }, + }), + ); + return true; + } catch (e) { + cerr("password:", e); + return false; + } + } + + /** + * Fetches the display name and avatar URL for a user. + * @param {string} [userId=this.userId] - The user ID to look up. + * @returns {Promise<{displayName: string|null, avatarUrl: string|null}|null>} + */ + async getProfile(userId = this.userId) { + try { + const data = await this.api(`/profile/${enc(userId)}`); + return data.errcode + ? null + : { + displayName: data.displayname || null, + avatarUrl: data.avatar_url || null, + }; + } catch (e) { + cerr("profile:", e); + return null; + } + } + + /** + * Sets the display name for the current user. + * @param {string} displayName + * @returns {Promise} `true` on success. + */ + async setDisplayName(displayName) { + const result = await this.api( + `/profile/${this.userId}/displayname`, + "PUT", + { displayname: displayName }, + ); + return !result.errcode; + } + + /** + * Sets the avatar URL for the current user. + * @param {string} avatarUrl - An `mxc://` URI. + * @returns {Promise} `true` on success. + */ + async setAvatarUrl(avatarUrl) { + const result = await this.api(`/profile/${this.userId}/avatar_url`, "PUT", { + avatar_url: avatarUrl, + }); + return !result.errcode; + } + + /** + * Converts an `mxc://` URI to an HTTP download URL for the current homeserver. + * @param {string} mxcUrl - An `mxc://` URI. + * @returns {string|null} The HTTP URL, or `null` if the input is invalid. + */ + mxcToHttp(mxcUrl) { + if (!mxcUrl?.startsWith("mxc://")) return null; + return `${this.homeserver}/_matrix/media/r0/download/${mxcUrl.slice(6)}`; + } + + /** + * Resolves a room alias (e.g. `#room:server`) to a room ID. + * @param {string} roomAlias + * @returns {Promise} The room ID, or `null` on failure. + */ + async resolveRoomAlias(roomAlias) { + try { + return ( + (await this.api(`/directory/room/${enc(roomAlias)}`)).room_id || null + ); + } catch (e) { + cerr("alias:", e); + return null; + } + } + + /** + * Joins a room by its ID or alias. + * @param {string} roomIdOrAlias + * @returns {Promise<{roomId: string}|null>} The joined room ID, or `null` on failure. + */ + async joinRoom(roomIdOrAlias) { + try { + const result = await this.api(`/join/${enc(roomIdOrAlias)}`, "POST", {}); + return result.errcode ? null : { roomId: result.room_id }; + } catch (e) { + cerr("join:", e); + return null; + } + } + + /** + * Creates a new room. + * @param {Object} options - Room creation options passed directly to the Matrix API. + * @returns {Promise<{roomId: string}|null>} The new room ID, or `null` on failure. + */ + async createRoom(options) { + const result = await this.api("/createRoom", "POST", options); + if (result.errcode) { + cerr("create:", result.errcode); + return null; + } + return { roomId: result.room_id }; + } + + /** + * Leaves a room. + * @param {string} roomId + * @returns {Promise} `true` on success. + */ + async leaveRoom(roomId) { + try { + return !(await this.api(`/rooms/${roomId}/leave`, "POST", {})).errcode; + } catch (e) { + cerr("leave:", e); + return false; + } + } + + /** + * Invites a user to a room. + * @param {string} roomId + * @param {string} userId + * @returns {Promise} `true` on success. + */ + async inviteUser(roomId, userId) { + try { + return !( + await this.api(`/rooms/${roomId}/invite`, "POST", { user_id: userId }) + ).errcode; + } catch (e) { + cerr("invite:", e); + return false; + } + } + + /** + * Performs a moderation action on a user in a room. + * @param {string} action - `"kick"` or `"ban"`. + * @param {string} roomId + * @param {string} userId + * @param {string} [reason=""] + * @returns {Promise} `true` on success. + */ + async #userModAction(action, roomId, userId, reason = "") { + try { + const body = { user_id: userId }; + if (reason) body.reason = reason; + return !(await this.api(`/rooms/${roomId}/${action}`, "POST", body)) + .errcode; + } catch (e) { + cerr(`${action}:`, e); + return false; + } + } + + /** + * Kicks a user from a room. + * @param {string} roomId + * @param {string} userId + * @param {string} [reason=""] + * @returns {Promise} `true` on success. + */ + async kickUser(roomId, userId, reason = "") { + return this.#userModAction("kick", roomId, userId, reason); + } + + /** + * Bans a user from a room. + * @param {string} roomId + * @param {string} userId + * @param {string} [reason=""] + * @returns {Promise} `true` on success. + */ + async banUser(roomId, userId, reason = "") { + return this.#userModAction("ban", roomId, userId, reason); + } + + /** + * Unbans a user from a room. + * @param {string} roomId + * @param {string} userId + * @returns {Promise} `true` on success. + */ + async unbanUser(roomId, userId) { + try { + return !( + await this.api(`/rooms/${roomId}/unban`, "POST", { user_id: userId }) + ).errcode; + } catch (e) { + cerr("unban:", e); + return false; + } + } + + /** + * Fetches the current joined members of a room. + * @param {string} roomId + * @returns {Promise|null>} + */ + async getRoomMembers(roomId) { + try { + const result = await this.api(`/rooms/${roomId}/members`); + if (result.errcode || !result.chunk) return null; + return result.chunk + .filter((e) => e.content?.membership === "join") + .map((e) => ({ + userId: e.state_key, + displayName: + e.content.displayname || e.state_key.split(":")[0].substring(1), + })); + } catch (e) { + cerr("members:", e); + return null; + } + } + + /** + * Sends a room event via a PUT request using a timestamp as transaction ID. + * @param {string} roomId + * @param {string} type - Matrix event type (e.g. `m.room.message`). + * @param {Object} content - Event content. + * @param {string} [errLabel="send"] - Label used in error logs. + * @returns {Promise<{eventId: string}|null>} + */ + async #sendRoomEvent(roomId, type, content, errLabel = "send") { + try { + const result = await this.api( + `/rooms/${roomId}/send/${type}/${Date.now()}`, + "PUT", + content, + ); + return result.errcode ? null : { eventId: result.event_id }; + } catch (e) { + cerr(`${errLabel}:`, e); + return null; + } + } + + /** + * Sends a plain text (or optionally HTML-formatted) message to a room. + * @param {string} roomId + * @param {string} message - Plain text body. + * @param {string|null} [formattedBody=null] - Optional HTML-formatted body. + * @returns {Promise<{eventId: string}|null>} + */ + async sendMessage(roomId, message, formattedBody = null) { + const content = { msgtype: M_TEXT, body: message }; + if (formattedBody) { + content.format = M_HTML; + content.formatted_body = formattedBody; + } + return this.#sendRoomEvent(roomId, M_MSG, content); + } + + /** + * Sends an image message to a room. + * @param {string} roomId + * @param {string} url - An `mxc://` URI for the image. + * @param {string} [body="Image"] - Alt text / fallback body. + * @param {Object} [info={}] - Optional image metadata (e.g. `w`, `h`, `mimetype`). + * @returns {Promise<{eventId: string}|null>} + */ + async sendImage(roomId, url, body = "Image", info = {}) { + const content = { msgtype: M_IMAGE, body, url }; + if (Object.keys(info).length) content.info = info; + return this.#sendRoomEvent(roomId, M_MSG, content, "send image"); + } + + /** + * Edits a previously sent message using the `m.replace` relation. + * @param {string} roomId + * @param {string} eventId - The event ID of the original message. + * @param {string} newMessage - The replacement text body. + * @returns {Promise<{eventId: string}|null>} + */ + async editMessage(roomId, eventId, newMessage) { + return this.#sendRoomEvent( + roomId, + M_MSG, + { + msgtype: M_TEXT, + body: `* ${newMessage}`, + [M_NEWCONT]: { msgtype: M_TEXT, body: newMessage }, + [M_REL]: { rel_type: M_REPLACE, event_id: eventId }, + }, + "edit", + ); + } + + /** + * Redacts (deletes) a room event. + * @param {string} roomId + * @param {string} eventId + * @param {string} [reason=""] - Optional reason for the redaction. + * @returns {Promise<{eventId: string}|null>} + */ + async redactEvent(roomId, eventId, reason = "") { + try { + const result = await this.api( + `/rooms/${roomId}/redact/${eventId}/${Date.now()}`, + "PUT", + reason ? { reason } : {}, + ); + return result.errcode ? null : { eventId: result.event_id }; + } catch (e) { + cerr("redact:", e); + return null; + } + } + + /** + * Sends a reaction annotation to a message. + * @param {string} roomId + * @param {string} eventId - The event to react to. + * @param {string} reaction - The reaction key (typically an emoji). + * @returns {Promise<{eventId: string}|null>} + */ + async reactToMessage(roomId, eventId, reaction) { + return this.#sendRoomEvent( + roomId, + M_REACT, + { [M_REL]: { rel_type: M_ANNOT, event_id: eventId, key: reaction } }, + "react", + ); + } + + /** + * Sends a state event to a room. + * @param {string} roomId + * @param {string} type - Matrix state event type. + * @param {Object} content - Event content. + * @param {string} [stateKey=""] - Optional state key. + * @returns {Promise<{eventId: string}|null>} + */ + async sendStateEvent(roomId, type, content, stateKey = "") { + try { + const result = await this.api( + `/rooms/${roomId}/state/${enc(type)}/${enc(stateKey)}`, + "PUT", + content, + ); + return result.errcode ? null : { eventId: result.event_id }; + } catch (e) { + cerr("state event:", e); + return null; + } + } + + /** + * Sets the name of a room. + * @param {string} roomId + * @param {string} name + * @returns {Promise<{eventId: string}|null>} + */ + async setRoomName(roomId, name) { + return this.sendStateEvent(roomId, M_RNAME, { name }); + } + + /** + * Sets the topic of a room. + * @param {string} roomId + * @param {string} topic + * @returns {Promise<{eventId: string}|null>} + */ + async setRoomTopic(roomId, topic) { + return this.sendStateEvent(roomId, M_RTOPIC, { topic }); + } + + /** + * Sets the avatar for a room. + * @param {string} roomId + * @param {string} url - An `mxc://` URI. + * @returns {Promise<{eventId: string}|null>} + */ + async setRoomAvatar(roomId, url) { + return this.sendStateEvent(roomId, M_RAVATAR, { url }); + } + + /** + * Fetches a specific state event from a room. + * @param {string} roomId + * @param {string} type - Matrix state event type. + * @param {string} [stateKey=""] + * @returns {Promise} The state event content, or `null` on failure. + */ + async getRoomState(roomId, type, stateKey = "") { + try { + const result = await this.api( + `/rooms/${roomId}/state/${enc(type)}/${enc(stateKey)}`, + ); + return result.errcode ? null : result; + } catch (e) { + cerr("get state:", e); + return null; + } + } + + /** + * Gets the name of a room. + * @param {string} roomId + * @returns {Promise} + */ + async getRoomName(roomId) { + return (await this.getRoomState(roomId, M_RNAME))?.name ?? null; + } + + /** + * Gets the topic of a room. + * @param {string} roomId + * @returns {Promise} + */ + async getRoomTopic(roomId) { + return (await this.getRoomState(roomId, M_RTOPIC))?.topic ?? null; + } + + /** + * Fetches a snapshot of common room state (name, topic, avatar, power levels, members). + * @param {string} roomId + * @returns {Promise<{name: string|null, topic: string|null, avatarUrl: string|null, canonicalAlias: string|null, powerLevels: Object|null, members: Array<{userId: string, displayName: string|null, membership: string}>}|null>} + */ + async getRoomAllState(roomId) { + try { + const result = await this.api(`/rooms/${roomId}/state`); + if (!Array.isArray(result)) return null; + const find = (type, key = "") => + result.find((e) => e.type === type && (e.state_key ?? "") === key) + ?.content ?? null; + return { + name: find(M_RNAME)?.name ?? null, + topic: find(M_RTOPIC)?.topic ?? null, + avatarUrl: find(M_RAVATAR)?.url ?? null, + canonicalAlias: find("m.room.canonical_alias")?.alias ?? null, + powerLevels: find("m.room.power_levels"), + members: result + .filter((e) => e.type === M_MEMBER) + .map((e) => ({ + userId: e.state_key, + displayName: e.content?.displayname || null, + membership: e.content?.membership || "leave", + })), + }; + } catch (e) { + cerr("all state:", e); + return null; + } + } + + /** + * Removes a reaction by redacting its event. + * @param {string} roomId + * @param {string} reactionEventId - The event ID of the reaction to remove. + * @returns {Promise} `true` on success. + */ + async removeReaction(roomId, reactionEventId) { + const result = await this.redactEvent(roomId, reactionEventId); + return result !== null; + } + + /** + * Fetches a page of messages from a room's timeline. + * @param {string} roomId + * @param {object} [options] + * @param {string|null} [options.from=null] - Pagination token to start from. + * @param {number} [options.limit=50] - Maximum number of events to return. + * @param {string} [options.dir="b"] - Direction: `"b"` (backwards) or `"f"` (forwards). + * @returns {Promise<{messages: Object[], start: string, end: string}|null>} + */ + async getMessages(roomId, { from = null, limit = 50, dir = "b" } = {}) { + try { + const endpoint = `/rooms/${roomId}/messages?dir=${dir}&limit=${limit}${from ? "&from=" + enc(from) : ""}`; + const result = await this.api(endpoint); + return result.errcode + ? null + : { + messages: result.chunk || [], + start: result.start, + end: result.end, + }; + } catch (e) { + cerr("messages:", e); + return null; + } + } + + /** + * Sends a typing notification to a room. + * @param {string} roomId + * @param {boolean} typing - `true` to indicate typing, `false` to stop. + * @param {number} [timeout=30000] - How long (ms) the typing indicator should remain active. + * @returns {Promise} `true` on success. + */ + async sendTyping(roomId, typing, timeout = 30000) { + try { + return !( + await this.api( + `/rooms/${roomId}/typing/${this.userId}`, + "PUT", + typing ? { typing: true, timeout } : { typing: false }, + ) + ).errcode; + } catch (e) { + cerr("typing:", e); + return false; + } + } + + /** + * Marks an event as read by sending a read receipt. + * @param {string} roomId + * @param {string} eventId + * @returns {Promise} `true` on success. + */ + async sendReadReceipt(roomId, eventId) { + try { + return !( + await this.api( + `/rooms/${roomId}/receipt/m.read/${enc(eventId)}`, + "POST", + {}, + ) + ).errcode; + } catch (e) { + cerr("receipt:", e); + return false; + } + } + + /** + * Uploads binary media to the homeserver's media repository. + * @param {Blob|ArrayBuffer|FormData} data - The media data to upload. + * @param {string} contentType - MIME type (e.g. `"image/png"`). + * @param {string} [filename=""] - Optional filename hint. + * @returns {Promise<{contentUri: string}|null>} The `mxc://` content URI, or `null` on failure. + */ + async uploadMedia(data, contentType, filename = "") { + try { + const qs = filename ? `?filename=${enc(filename)}` : ""; + const headers = { "Content-Type": contentType }; + if (this.accessToken) + headers.Authorization = `Bearer ${this.accessToken}`; + for (const v of ["v3", "r0"]) { + const response = await fetch( + `${this.homeserver}/_matrix/media/${v}/upload${qs}`, + { method: "POST", headers, body: data }, + ); + if (response.status === 404) continue; + const result = await response.json(); + return result.errcode ? null : { contentUri: result.content_uri }; + } + return null; + } catch (e) { + cerr("upload:", e); + return null; + } + } + + /** + * Performs a single `/sync` poll to retrieve new events from the homeserver. + * Pass the returned data to {@link processSyncData} to receive named events. + * @param {string|null} [since=null] - The sync token from a previous sync response. + * @param {number} [timeout=0] - Long-poll timeout in milliseconds. + * @returns {Promise} The raw sync response, or `null` on failure. + */ + async sync(since = null, timeout = 0) { + try { + const result = await this.api( + `/sync?timeout=${timeout}${since ? "&since=" + since : ""}`, + ); + return result.errcode ? null : result; + } catch (e) { + cerr("sync:", e); + return null; + } + } + + /** + * Processes a sync response and emits structured events for new activity. + * Call this with the data returned by {@link sync} after each poll. + * + * Emits: + * - `roomJoin` `{ roomId }` — a room appeared in the sync response for the first time. + * - `roomLeave` `{ roomId }` — the client has left or been removed from a room. + * - `invite` `{ roomId }` — the client received a room invitation. + * - `message` `{ roomId, event }` — a new (non-edit) `m.room.message` event. + * - `edit` `{ roomId, edits, newBody, event }` — a message was edited; `edits` is the event ID of the original message, `newBody` is the new text. + * - `memberUpdate` `{ roomId, change, event }` — a membership change; `change` is the + * object returned by {@link getMembershipChange}. + * - `redaction` `{ roomId, redacts, event }` — an event was redacted (deleted); `redacts` is the event ID that was deleted. + * - `typing` `{ roomId, userIds }` — the current set of typing users in a room changed. + * + * @param {Object} data - The sync response as returned by {@link sync}. + */ + processSyncData(data) { + if (!data) return; + + for (const [roomId, roomData] of Object.entries(data.rooms?.join ?? {})) { + if (!this.#knownRoomIds.has(roomId)) { + this.#knownRoomIds.add(roomId); + this.emit('roomJoin', { roomId }); + } + + for (const event of roomData.timeline?.events ?? []) { + if (event.type === M_MSG && !this.isEditEvent(event)) { + this.emit('message', { roomId, event }); + } + if (event.type === M_MSG && this.isEditEvent(event)) { + const rel = this.getEventRelation(event); + const newBody = this.getEditedBody(event); + this.emit('edit', { roomId, edits: rel.event_id, newBody, event }); + } + if (event.type === M_MEMBER) { + const change = this.getMembershipChange(event); + if (change) this.emit('memberUpdate', { roomId, change, event }); + } + if (event.type === M_REDACTION) { + this.emit('redaction', { roomId, redacts: event.redacts, event }); + } + } + + for (const event of roomData.ephemeral?.events ?? []) { + if (event.type === 'm.typing') { + this.emit('typing', { roomId, userIds: event.content?.user_ids ?? [] }); + } + } + } + + for (const roomId of Object.keys(data.rooms?.leave ?? {})) { + this.#knownRoomIds.delete(roomId); + this.emit('roomLeave', { roomId }); + } + + for (const roomId of Object.keys(data.rooms?.invite ?? {})) { + this.emit('invite', { roomId }); + } + } + + /** + * Registers a listener for a named event. + * @param {string} event - Event name. + * @param {function} fn - Listener callback. + * @returns {this} + */ + on(event, fn) { + if (!this.#handlers.has(event)) this.#handlers.set(event, new Set()); + this.#handlers.get(event).add(fn); + return this; + } + + /** + * Removes a listener (or all listeners) for a named event. + * @param {string} event - Event name. + * @param {function} [fn] - Specific listener to remove. Omit to remove all listeners for the event. + * @returns {this} + */ + off(event, fn) { + if (fn) this.#handlers.get(event)?.delete(fn); + else this.#handlers.delete(event); + return this; + } + + /** + * Emits a named event, invoking all registered listeners with the provided arguments. + * @param {string} event - Event name. + * @param {...*} args - Arguments forwarded to each listener. + */ + emit(event, ...args) { + this.#handlers.get(event)?.forEach((fn) => { + try { + fn(...args); + } catch (e) { + cerr("emit", event, e); + } + }); + } + + /** + * Checks whether a message event mentions a user. + * @param {Object} event - A Matrix room event. + * @param {string} userId - The user ID to check for. + * @returns {boolean} + */ + isMention(event, userId) { + if (!event?.content || !userId) return false; + if (event.type !== M_MSG) return false; + if (event.sender === userId) return false; + const body = event.content.body || ""; + const formattedBody = event.content.formatted_body || ""; + return body.includes(userId) || formattedBody.includes(userId); + } + + /** + * Returns the `m.relates_to` relation object from an event, if present. + * @param {Object} event - A Matrix room event. + * @returns {Object|null} + */ + getEventRelation(event) { + return event?.content?.[M_REL] ?? null; + } + + /** + * Checks whether an event is a message edit (`m.replace` relation). + * @param {Object} event - A Matrix room event. + * @returns {boolean} + */ + isEditEvent(event) { + if (event?.type !== M_MSG) return false; + const rel = this.getEventRelation(event); + return rel?.rel_type === M_REPLACE && !!rel.event_id; + } + + /** + * Checks whether an event is a reaction annotation (`m.annotation`). + * @param {Object} event - A Matrix room event. + * @returns {boolean} + */ + isReactionEvent(event) { + return ( + event?.type === M_REACT && + this.getEventRelation(event)?.rel_type === M_ANNOT + ); + } + + /** + * Extracts the text body from an edited message event. + * Falls back to the regular `body` if no `m.new_content` is present. + * @param {Object} event - A Matrix room event. + * @returns {string|null} + */ + getEditedBody(event) { + if (!event?.content) return null; + return event.content[M_NEWCONT]?.body || event.content.body || null; + } + + /** + * Returns the previous content (`unsigned.prev_content`) of a state event, if present. + * @param {Object} event - A Matrix room event. + * @returns {Object|null} + */ + getPrevContent(event) { + return event?.unsigned?.prev_content ?? null; + } + + /** + * Interprets an `m.room.member` event and returns a structured description of the membership change. + * @param {Object} event - A Matrix `m.room.member` state event. + * @returns {{type: "join"|"rename"|"leave"|"kick"|"ban"|"unknown", userId: string, displayName: string|null, prevDisplayName: string|null, kicker: string|null}|null} + * Returns `null` if the event is not an `m.room.member` type or produces no meaningful change. + */ + getMembershipChange(event) { + if (event?.type !== M_MEMBER) return null; + const userId = event.state_key; + const prevContent = this.getPrevContent(event); + const current = event.content?.membership; + const prev = prevContent?.membership; + const displayName = event.content?.displayname ?? null; + const prevDisplayName = prevContent?.displayname ?? null; + const kicker = event.sender !== userId ? event.sender : null; + + if (current === "join" && prev !== "join") { + return { + type: "join", + userId, + displayName, + prevDisplayName: null, + kicker: null, + }; + } else if (current === "join" && prev === "join") { + if (prevDisplayName && displayName !== prevDisplayName) { + return { + type: "rename", + userId, + displayName, + prevDisplayName, + kicker: null, + }; + } + return null; + } else if (current === "leave" && prev === "join") { + return { + type: kicker ? "kick" : "leave", + userId, + displayName, + prevDisplayName, + kicker, + }; + } else if (current === "ban") { + return { type: "ban", userId, displayName, prevDisplayName, kicker }; + } + return { + type: "unknown", + userId, + displayName, + prevDisplayName, + kicker: null, + }; + } + + /** + * Checks whether an event is an image message. + * @param {Object} event - A Matrix room event. + * @returns {boolean} + */ + isImageMessage(event) { + return event?.type === M_MSG && event.content?.msgtype === M_IMAGE; + } + + /** + * Checks whether a message event contains an HTML-formatted body. + * @param {Object} event - A Matrix room event. + * @returns {boolean} + */ + hasFormattedBody(event) { + return event?.content?.format === M_HTML && !!event.content.formatted_body; + } + + /** + * Extracts the localpart from a Matrix user ID (the segment before the colon). + * @param {string} userId - A Matrix user ID (e.g. `@alice:example.com`). + * @returns {string} The localpart, or `"?"` if extraction fails. + */ + extractLocalpart(userId) { + return userId?.match(/^@([^:]+):/)?.[1] ?? userId ?? "?"; + } + + /** + * Escapes HTML special characters in a plain-text string. + * @param {string} text + * @returns {string} + */ + #escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">"); + } + + /** + * Replaces `@user:server` patterns in plain text with HTML anchor mention links. + * @param {string} text - Plain text possibly containing Matrix user IDs. + * @param {function(string): string} getDisplayName - Callback to resolve a user ID to a display name. + * @returns {string|null} HTML string with mentions linked, or `null` if no mentions were found. + */ + buildMentionHtml(text, getDisplayName) { + const result = text.replace(/@(\S+:\S+)/g, (match, id) => { + const userId = `@${id}`; + const displayName = getDisplayName(userId); + return `@${this.#escapeHtml(displayName)}`; + }); + return result === text ? null : result; + } + + /** + * Sanitizes an HTML string, permitting only a safe subset of tags and converting + * Matrix mention links into `` elements. + * @param {string} html - Raw HTML string to sanitize. + * @returns {string} The sanitized HTML string. + */ + sanitizeHtml(html) { + if (typeof DOMParser === "undefined") return html; + + const doc = new DOMParser().parseFromString(html, "text/html"); + const parts = []; + + const walk = (node) => { + for (const child of [...node.childNodes]) { + if (child.nodeType === Node.TEXT_NODE) { + parts.push(this.#escapeHtml(child.textContent || "")); + } else if (child.nodeType === Node.ELEMENT_NODE) { + const tag = child.tagName.toLowerCase(); + const href = tag === "a" ? (child.getAttribute("href") ?? "") : ""; + + if (tag === "a" && href.startsWith(MATRIX_TO + "@")) { + const userId = decodeURIComponent(href.slice(MATRIX_TO.length)); + parts.push( + '' + + this.#escapeHtml(child.textContent || userId) + + "", + ); + continue; + } + + if (SANITIZE_ALLOWED_TAGS.has(tag)) { + const tagName = tag === "strike" ? "s" : tag; + if (tag === "span" && child.getAttribute("class") === "mention") { + const title = child.getAttribute("title"); + parts.push( + '", + ); + walk(child); + parts.push(""); + } else { + parts.push("<" + tagName + ">"); + walk(child); + parts.push(""); + } + } else { + walk(child); + } + } + } + }; + + walk(doc.body); + return parts.join(""); + } + + /** + * Fetches the most recent text message from a public room using the public read token. + * @param {string} roomAlias - The room alias to look up (e.g. `#room:server`). + * @returns {Promise<{sender: string, body: string, timestamp: number}|null>} + */ + async fetchPublicLastMessage(roomAlias) { + if (!this.publicReadToken) { + console.warn("No public read token"); + return null; + } + try { + const roomId = ( + await this.api( + `/directory/room/${enc(roomAlias)}`, + "GET", + null, + this.publicReadToken, + ) + )?.room_id; + if (!roomId) return null; + const lastEvent = ( + await this.api( + `/rooms/${enc(roomId)}/messages?dir=b&limit=10`, + "GET", + null, + this.publicReadToken, + ) + ).chunk?.find((e) => e?.type === M_MSG && e.content?.body); + return lastEvent + ? { + sender: lastEvent.sender, + body: lastEvent.content.body, + timestamp: lastEvent.origin_server_ts || Date.now(), + } + : null; + } catch (e) { + cerr("public msg:", e); + return null; + } + } + + /** + * Fetches the presence status of a user using the public read token. + * @param {string} userId + * @returns {Promise<{presence: string, lastActive: number}|null>} + */ + async fetchPublicPresence(userId) { + if (!this.publicReadToken) { + console.warn("No public read token"); + return null; + } + try { + const data = await this.api( + `/presence/${enc(userId)}/status`, + "GET", + null, + this.publicReadToken, + ); + return data.errcode + ? null + : { presence: data.presence, lastActive: data.last_active_ago || 0 }; + } catch (e) { + cerr("presence:", e); + return null; + } + } +} + +export default MxjsClient; diff --git a/website/scripts/commands/numbermatch.js b/website/scripts/commands/numbermatch.js index cc22654..81f2fbb 100644 --- a/website/scripts/commands/numbermatch.js +++ b/website/scripts/commands/numbermatch.js @@ -253,13 +253,9 @@ class NumberMatchGame { } } - // Wrap-around horizontal (end of one row to start of next) - if (rowEnd === rowStart + 1 && colStart === this.width - 1 && colEnd === 0) { - // Adjacent via wrap - return true; - } - if (diff === 1 || diff === this.width) { - // Directly adjacent + // Wrap-around: scan reading order across row boundaries (right to end of row, + // down to next row, left-to-right until reaching the second tile) + if (rowStart !== rowEnd && this.isSegmentClear(start, end, 1)) { return true; } diff --git a/website/terminal.js b/website/terminal.js index bd2924b..595b402 100644 --- a/website/terminal.js +++ b/website/terminal.js @@ -18,6 +18,7 @@ import samsayCmd from './scripts/commands/samsay.js'; // Import Matrix client import { initMatrixClient, + updateClientSession, chatMode, matrixApi, fetchPublicLastMessage, @@ -581,6 +582,14 @@ const commands = { }; } + // Sync session to mxClient if exists + if (window.matrixSession.accessToken && window.matrixSession.userId) { + updateClientSession({ + accessToken: window.matrixSession.accessToken, + userId: window.matrixSession.userId + }); + } + // Register/login user if not logged in if (!window.matrixSession.accessToken) { try { @@ -627,6 +636,8 @@ const commands = { localStorage.setItem('matrix_username', username); localStorage.setItem('matrix_password', password); + updateClientSession({ accessToken: regData.access_token, userId: regData.user_id }); + term.writeln(` Registered as: ${regData.user_id}\r\n`); } else if (regData.errcode === 'M_USER_IN_USE') { // Username exists, try to login @@ -653,6 +664,8 @@ const commands = { localStorage.setItem('matrix_username', username); localStorage.setItem('matrix_password', password); + updateClientSession({ accessToken: loginData.access_token, userId: loginData.user_id }); + term.writeln(` Logged in as: ${loginData.user_id}\r\n`); } else { return ` Error: Failed to login - ${loginData.error || loginData.errcode || 'Unknown error'}`;