diff --git a/website/logos/bearanticropped.png b/website/logos/bearanticropped.png new file mode 100644 index 0000000..0980aed Binary files /dev/null and b/website/logos/bearanticropped.png differ diff --git a/website/matrix-client.js b/website/matrix-client.js new file mode 100644 index 0000000..bc9ef84 --- /dev/null +++ b/website/matrix-client.js @@ -0,0 +1,1115 @@ +/** + * 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; + } + + // 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)}`); + + // 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/scripts/commands/help.js b/website/scripts/commands/help.js index 8f65cd6..fb59951 100644 --- a/website/scripts/commands/help.js +++ b/website/scripts/commands/help.js @@ -20,6 +20,7 @@ export default { writeClickable(' [command=banner] - Display welcome banner'); writeClickable(' [command=bluesky] - Fetch recent posts from Bluesky'); writeClickable(' [command=chat] - Enter interactive chat (type /quit to exit)'); + writeClickable(' [command=numbermatch] - Play number matching puzzle game'); writeClickable(' [command=github] - Visit GitHub repository'); writeClickable(' [command=contact] - Display contact information'); writeClickable(' [command=privacy] - Display privacy policy'); diff --git a/website/scripts/commands/numbermatch.js b/website/scripts/commands/numbermatch.js new file mode 100644 index 0000000..af1930d --- /dev/null +++ b/website/scripts/commands/numbermatch.js @@ -0,0 +1,905 @@ +/** + * Number Match game command - A terminal-based number matching puzzle game + * Match pairs of numbers that are equal or sum to 10 + */ + +/** + * Play a click/select sound + * @returns {void} + */ +function playClickSound() { + if (window.terminalSounds) { + const sounds = window.terminalSounds; + sounds.play(sounds.pickRandom(sounds.files.typing), 0.25); + } +} + +/** + * Play a match success sound + * @returns {void} + */ +function playMatchSound() { + if (window.terminalSounds) { + const sounds = window.terminalSounds; + sounds.play(sounds.pickRandom(sounds.files.enter), 0.3); + } +} + +/** + * Play an error/invalid sound + * @returns {void} + */ +function playErrorSound() { + if (window.terminalSounds) { + const sounds = window.terminalSounds; + sounds.play(sounds.files.scroll, 0.2); + } +} + +/** + * Shuffle an array in place using the Fisher-Yates algorithm. + * @template T + * @param {T[]} array - Array to shuffle + * @returns {T[]} The shuffled array + */ +function shuffle(array) { + for (let i = array.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + return array; +} + +/** + * Core game state manager for Number Match logic. + */ +class NumberMatchGame { + /** + * @param {{ width?: number, rows?: number }} [options] - Configuration object + */ + constructor(options = {}) { + const { width = 9, rows = 4 } = options; + this.width = width; + this.initialRows = rows; + this.reset(); + } + + /** + * Regenerate the board with a fresh puzzle. + * @returns {void} + */ + reset() { + const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows); + this.tiles = initialState.boardTiles; + } + + /** + * Get a shallow copy of the current tiles. + * @returns {(number|null)[]} Current tile values + */ + getTiles() { + return [...this.tiles]; + } + + /** + * Count the remaining non-null tiles. + * @returns {number} Number of tiles left on the board + */ + getRemainingCount() { + return this.tiles.filter((tile) => tile !== null).length; + } + + /** + * Determine whether the board has been cleared. + * @returns {boolean} True when all tiles have been removed + */ + isComplete() { + return this.tiles.every((tile) => tile === null); + } + + /** + * Attempt to match and remove two indices. + * @param {number} firstIndex - Index of the first tile + * @param {number} secondIndex - Index of the second tile + * @returns {boolean} True if the match succeeded + */ + selectPair(firstIndex, secondIndex) { + if (!this.canPair(firstIndex, secondIndex)) { + return false; + } + + this.tiles[firstIndex] = null; + this.tiles[secondIndex] = null; + this.compactEmptyRows(); + return true; + } + + /** + * Check whether the two indices can form a valid pair. + * @param {number} firstIndex - Index of the first tile + * @param {number} secondIndex - Index of the second tile + * @returns {boolean} True if the tiles satisfy match rules and path constraints + */ + canPair(firstIndex, secondIndex) { + if (firstIndex === secondIndex) { + return false; + } + + if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || + !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) { + return false; + } + + const valueA = this.tiles[firstIndex]; + const valueB = this.tiles[secondIndex]; + + if (valueA === null || valueB === null) { + return false; + } + + if (!(valueA === valueB || valueA + valueB === 10)) { + return false; + } + + return this.hasClearPath(firstIndex, secondIndex); + } + + /** + * Append remaining tiles in reading order. + * @returns {boolean} True when tiles were appended + */ + addNumbers() { + this.compactEmptyRows(); + const remaining = this.tiles.filter((tile) => tile !== null); + if (remaining.length === 0) { + return false; + } + + this.trimTrailingEmptySlots(); + this.tiles.push(...remaining); + return true; + } + + /** + * Remove trailing null placeholders from the board. + * @returns {void} + */ + trimTrailingEmptySlots() { + while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) { + this.tiles.pop(); + } + } + + /** + * Verify that a straight path between two indices is unobstructed. + * @param {number} firstIndex - One tile index + * @param {number} secondIndex - The other tile index + * @returns {boolean} True if any allowed path between the indices is clear + */ + hasClearPath(firstIndex, secondIndex) { + const start = Math.min(firstIndex, secondIndex); + const end = Math.max(firstIndex, secondIndex); + + const rowStart = Math.floor(start / this.width); + const rowEnd = Math.floor(end / this.width); + const colStart = start % this.width; + const colEnd = end % this.width; + + // Horizontal (same row, adjacent or with empty cells between) + if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) { + return true; + } + + // Vertical (same column) + const diff = end - start; + if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) { + return true; + } + + // Diagonal down-right (row increases, col increases) + const rowDelta = rowEnd - rowStart; + const colDelta = colEnd - colStart; + + if (rowDelta === colDelta && rowDelta > 0) { + // Down-right diagonal: step = width + 1 + if (this.isSegmentClear(start, end, this.width + 1)) { + return true; + } + } + + // Diagonal down-left (row increases, col decreases) + if (rowDelta === -colDelta && rowDelta > 0) { + // Down-left diagonal: step = width - 1 + if (this.isSegmentClear(start, end, this.width - 1)) { + return true; + } + } + + // 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 + return true; + } + + return false; + } + + /** + * Remove any fully empty rows from the board to keep the grid compact. + * @returns {void} + */ + compactEmptyRows() { + let index = 0; + while (index < this.tiles.length) { + const remaining = this.tiles.length - index; + const span = Math.min(this.width, remaining); + const slice = this.tiles.slice(index, index + span); + const isEmptyRow = slice.every((value) => value === null); + if (isEmptyRow) { + this.tiles.splice(index, span); + continue; + } + index += this.width; + } + } + + /** + * Verify that the indices along a stepped path are empty. + * @param {number} start - Starting index (inclusive) + * @param {number} end - Ending index (inclusive) + * @param {number} step - Increment applied each iteration + * @returns {boolean} True if no non-null tiles exist between start and end + */ + isSegmentClear(start, end, step) { + for (let current = start + step; current < end; current += step) { + if (this.tiles[current] !== null) { + return false; + } + } + return true; + } + + /** + * Find a valid pair hint. + * @returns {[number, number]|null} A pair of indices or null if none available + */ + findHint() { + const tiles = this.tiles; + for (let i = 0; i < tiles.length; i++) { + if (tiles[i] === null) continue; + for (let j = i + 1; j < tiles.length; j++) { + if (tiles[j] === null) continue; + if (this.canPair(i, j)) { + return [i, j]; + } + } + } + return null; + } + + /** + * Determine whether the provided index is valid for the current tile array. + * @param {number} index - Index to evaluate + * @param {(number|null)[]} tiles - Tile array + * @returns {boolean} True when the index is within bounds + */ + static isValidIndex(index, tiles) { + return index >= 0 && index < tiles.length; + } + + /** + * Create a randomized board configuration. + * @param {number} width - Number of columns + * @param {number} rows - Number of initial rows + * @returns {{ boardTiles: number[] }} Generated tile set + */ + static generateInitialState(width, rows) { + const boardCapacity = width * rows; + const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2)); + const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches); + const forcedAssignments = new Map(); + + const horizontalStarts = []; + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + for (let colIndex = 0; colIndex < width - 1; colIndex += 1) { + horizontalStarts.push(rowIndex * width + colIndex); + } + } + + const shuffledStarts = shuffle(horizontalStarts); + const reserved = new Set(); + let startCursor = 0; + + forcedPairs.forEach((pair) => { + let startIndex = null; + while (startCursor < shuffledStarts.length) { + const candidate = shuffledStarts[startCursor]; + startCursor += 1; + if (reserved.has(candidate) || reserved.has(candidate + 1)) { + continue; + } + startIndex = candidate; + break; + } + + if (startIndex === null) { + return; + } + + forcedAssignments.set(startIndex, pair[0]); + forcedAssignments.set(startIndex + 1, pair[1]); + reserved.add(startIndex); + reserved.add(startIndex + 1); + }); + + const boardTiles = new Array(boardCapacity).fill(null); + + for (let index = 0; index < boardCapacity; index += 1) { + const forcedValue = forcedAssignments.get(index); + if (typeof forcedValue === 'number') { + boardTiles[index] = forcedValue; + continue; + } + + const rawNextForced = forcedAssignments.get(index + 1); + const nextForcedValue = typeof rawNextForced === 'number' ? rawNextForced : null; + boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue); + } + + return { boardTiles }; + } + + /** + * Produce a random pair of values that form a legal match. + * @returns {[number, number]} Two values that either match or sum to ten + */ + static generatePair() { + const complementPairs = [[1, 9], [2, 8], [3, 7], [4, 6], [5, 5]]; + const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + if (Math.random() < 0.5) { + const value = identicalValues[Math.floor(Math.random() * identicalValues.length)]; + return [value, value]; + } + + const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)]; + return [...pair]; + } + + /** + * Create the predetermined matches that appear on the initial board. + * @param {number} target - Total matches to prepare + * @returns {Array<[number, number]>} Ordered list of forced pairs + */ + static getForcedPairs(target) { + const essentialPairs = [[5, 5]]; + const complementPool = shuffle([ + [1, 9], [2, 8], [3, 7], [4, 6], + [9, 1], [8, 2], [7, 3], [6, 4] + ]); + const pairs = []; + + for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) { + pairs.push(essentialPairs[i]); + } + + let complementIndex = 0; + while (pairs.length < target && complementIndex < complementPool.length) { + pairs.push(complementPool[complementIndex]); + complementIndex += 1; + } + + while (pairs.length < target) { + pairs.push(NumberMatchGame.generatePair()); + } + + return shuffle(pairs); + } + + /** + * Pick a filler value that will not immediately create an accessible match. + * @param {Array} tiles - Current partially filled board + * @param {number} width - Board width + * @param {number} index - Index to populate + * @param {number|null} nextForcedValue - Upcoming forced value, if one exists + * @returns {number} Safe filler digit + */ + static pickSafeFillerValue(tiles, width, index, nextForcedValue) { + const attempt = (respectNext) => { + const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null); + const candidates = []; + for (let value = 1; value <= 9; value += 1) { + if (!forbidden.has(value)) { + candidates.push(value); + } + } + return candidates; + }; + + let candidates = attempt(true); + if (candidates.length === 0) { + candidates = attempt(false); + } + + if (candidates.length === 0) { + return Math.floor(Math.random() * 9) + 1; + } + + return candidates[Math.floor(Math.random() * candidates.length)]; + } + + /** + * Collect forbidden values based on already placed neighbors. + * @param {Array} tiles - Current board state + * @param {number} width - Board width + * @param {number} index - Target index + * @param {number|null} nextForcedValue - Forced value to appear next in sequence, if any + * @returns {Set} Values that should not be used at the index + */ + static collectForbiddenValues(tiles, width, index, nextForcedValue) { + const forbidden = new Set(); + const register = NumberMatchGame.registerForbiddenValue; + + if (index > 0) { + register(forbidden, tiles[index - 1]); + } + + const row = Math.floor(index / width); + const col = index % width; + + if (row > 0) { + register(forbidden, tiles[index - width]); + if (col > 0) { + register(forbidden, tiles[index - width - 1]); + } + if (col < width - 1) { + register(forbidden, tiles[index - width + 1]); + } + } + + if (typeof nextForcedValue === 'number') { + register(forbidden, nextForcedValue); + } + + return forbidden; + } + + /** + * Register a value and its complement as forbidden for immediate placement. + * @param {Set} forbidden - Collection of disallowed values + * @param {number|null|undefined} value - Value to mark as forbidden + * @returns {void} + */ + static registerForbiddenValue(forbidden, value) { + if (typeof value !== 'number') { + return; + } + + forbidden.add(value); + const complement = NumberMatchGame.getComplement(value); + forbidden.add(complement); + } + + /** + * Compute the complement digit that forms a sum-to-ten relationship. + * @param {number} value - Value between 1 and 9 + * @returns {number} Complement digit + */ + static getComplement(value) { + if (value === 5) { + return 5; + } + const complement = 10 - value; + return Math.min(9, Math.max(1, complement)); + } +} + +/** + * Game mode state for terminal integration + */ +export const gameMode = { + active: false, + game: null, + selectedIndex: null, + matchesCleared: 0, + term: null, + boardLines: 0, + message: '' +}; + +/** + * Calculate how many lines the board display uses + * @param {NumberMatchGame} game - Game instance + * @returns {number} Number of lines to move up + */ +function getBoardLineCount(game) { + const rows = Math.ceil(game.getTiles().length / game.width); + // header + top border + rows + bottom border + empty + status + message + // (prompt uses write not writeln, so doesn't add a line) + return 1 + 1 + rows + 1 + 1 + 1 + 1; +} + +/** + * Render the game board as ASCII art (initial draw) + * @param {Terminal} term - xterm.js terminal instance + * @param {NumberMatchGame} game - Game instance + * @param {number|null} selectedIndex - Currently selected tile index + * @param {string} message - Status message to display + * @returns {void} + */ +function renderBoard(term, game, selectedIndex, message = '') { + const tiles = game.getTiles(); + const width = game.width; + const rows = Math.ceil(tiles.length / width); + + // Column headers + let header = ' '; + for (let c = 0; c < width; c++) { + header += ` ${c + 1} `; + } + term.writeln(`\x1b[90m${header}\x1b[0m`); + + // Top border + term.writeln(` ┌${'───'.repeat(width)}┐`); + + for (let row = 0; row < rows; row++) { + let line = ` ${String.fromCharCode(65 + row)} │`; + + for (let col = 0; col < width; col++) { + const idx = row * width + col; + if (idx >= tiles.length) { + line += ' '; + continue; + } + + const value = tiles[idx]; + if (value === null) { + line += ' · '; + } else if (idx === selectedIndex) { + // Highlight selected tile + line += `\x1b[7m ${value} \x1b[0m`; + } else { + line += ` ${value} `; + } + } + + line += '│'; + term.writeln(line); + } + + // Bottom border + term.writeln(` └${'───'.repeat(width)}┘`); + + // Status line + term.writeln(''); + const remaining = game.getRemainingCount(); + term.writeln(` \x1b[33mMatches:\x1b[0m ${gameMode.matchesCleared} \x1b[33mRemaining:\x1b[0m ${remaining} `); + + // Message line (padded to clear previous) + const msgText = message || ''; + term.writeln(` ${msgText}`.padEnd(50)); + + // Prompt + term.write('\x1b[33mgame>\x1b[0m '); + + gameMode.boardLines = getBoardLineCount(game); + gameMode.message = message; +} + +/** + * Update the board in place without scrolling + * @param {Terminal} term - xterm.js terminal instance + * @param {NumberMatchGame} game - Game instance + * @param {number|null} selectedIndex - Currently selected tile index + * @param {string} message - Status message to display + * @returns {void} + */ +function updateBoardInPlace(term, game, selectedIndex, message = '') { + const linesToMoveUp = gameMode.boardLines; + + // Move cursor up to start of board, go to column 0, clear to end of screen + term.write(`\x1b[${linesToMoveUp}A\r\x1b[J`); + + // Redraw from current position + renderBoard(term, game, selectedIndex, message); +} + +/** + * Display help text + * @param {Terminal} term - xterm.js terminal instance + * @returns {void} + */ +function renderHelp(term) { + term.writeln(''); + term.writeln(' \x1b[36mCommands:\x1b[0m'); + term.writeln(' A1 B2 - Select two tiles (e.g., A1 A2 or B3 C3)'); + term.writeln(' add - Duplicate remaining numbers to end'); + term.writeln(' hint - Show a valid pair'); + term.writeln(' new - Start a new game'); + term.writeln(' quit - Exit the game'); + term.writeln(''); + term.writeln(' \x1b[36mRules:\x1b[0m Match numbers that are equal or sum to 10'); + term.writeln(' Pairs must be adjacent horizontally or vertically'); + term.writeln(' (with only empty spaces between them)'); +} + +/** + * Parse coordinate input like "A1" to index + * @param {string} coord - Coordinate string (e.g., "A1", "B3") + * @param {number} width - Board width + * @returns {number|null} Tile index or null if invalid + */ +function parseCoordinate(coord, width) { + const match = coord.toUpperCase().match(/^([A-Z])(\d+)$/); + if (!match) return null; + + const row = match[1].charCodeAt(0) - 65; + const col = parseInt(match[2], 10) - 1; + + if (row < 0 || col < 0 || col >= width) return null; + + return row * width + col; +} + +/** + * Process game input + * @param {Terminal} term - xterm.js terminal instance + * @param {string} input - User input + * @returns {boolean} True if game should continue + */ +export function processGameInput(term, input) { + if (!gameMode.active || !gameMode.game) { + return false; + } + + const cmd = input.trim().toLowerCase(); + const game = gameMode.game; + + if (cmd === 'quit' || cmd === 'exit' || cmd === '/quit') { + gameMode.active = false; + gameMode.game = null; + gameMode.selectedIndex = null; + gameMode.matchesCleared = 0; + gameMode.term = null; + gameMode.boardLines = 0; + term.writeln(' Game exited.\r\n'); + return false; + } + + if (cmd === 'new' || cmd === 'reset') { + game.reset(); + gameMode.selectedIndex = null; + gameMode.matchesCleared = 0; + term.writeln(''); + renderBoard(term, game, null, 'New game started!'); + return true; + } + + if (cmd === 'add') { + const added = game.addNumbers(); + term.writeln(''); + if (added) { + renderBoard(term, game, gameMode.selectedIndex, 'Numbers duplicated to end'); + } else { + renderBoard(term, game, gameMode.selectedIndex, '\x1b[31mNo numbers to add\x1b[0m'); + } + return true; + } + + if (cmd === 'hint') { + const hint = game.findHint(); + if (hint) { + const [i, j] = hint; + const width = game.width; + const coord1 = String.fromCharCode(65 + Math.floor(i / width)) + ((i % width) + 1); + const coord2 = String.fromCharCode(65 + Math.floor(j / width)) + ((j % width) + 1); + term.writeln(` \x1b[33mHint:\x1b[0m Try ${coord1} ${coord2}`); + } else { + term.writeln(' \x1b[31mNo matches. Try "add"\x1b[0m'); + } + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + if (cmd === 'help' || cmd === '?') { + renderHelp(term); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + // Try to parse as coordinates + const parts = input.trim().toUpperCase().split(/\s+/); + + if (parts.length === 2) { + const idx1 = parseCoordinate(parts[0], game.width); + const idx2 = parseCoordinate(parts[1], game.width); + + if (idx1 === null || idx2 === null) { + term.writeln(' \x1b[31mInvalid coordinates. Use format: A1 B2\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + const tiles = game.getTiles(); + if (idx1 >= tiles.length || idx2 >= tiles.length) { + term.writeln(' \x1b[31mCoordinates out of range.\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + if (tiles[idx1] === null || tiles[idx2] === null) { + term.writeln(' \x1b[31mOne or both tiles are empty.\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + const v1 = tiles[idx1]; + const v2 = tiles[idx2]; + const matched = game.selectPair(idx1, idx2); + + term.writeln(''); + if (matched) { + gameMode.matchesCleared++; + + if (game.isComplete()) { + term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m'); + term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m'); + term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m'); + term.writeln(''); + term.writeln(` Total matches: ${gameMode.matchesCleared}`); + term.writeln(' Type "new" for another game or "quit" to exit.'); + term.write('\x1b[33mgame>\x1b[0m '); + gameMode.boardLines = 0; + return true; + } + renderBoard(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`); + } else { + if (v1 !== v2 && v1 + v2 !== 10) { + renderBoard(term, game, null, `\x1b[31m${v1} and ${v2} don't match\x1b[0m`); + } else { + renderBoard(term, game, null, `\x1b[31mNo clear path\x1b[0m`); + } + } + return true; + } + + if (parts.length === 1 && parts[0]) { + const idx = parseCoordinate(parts[0], game.width); + if (idx !== null) { + term.writeln(' \x1b[33mEnter two coordinates (e.g., A1 A2)\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + } + + if (cmd) { + term.writeln(' \x1b[31mUnknown command. Type "help"\x1b[0m'); + } + term.write('\x1b[33mgame>\x1b[0m '); + return true; +} + +/** + * Start a new number match game session + * @param {Terminal} term - xterm.js terminal instance + * @returns {void} + */ +export function startGame(term) { + gameMode.active = true; + gameMode.game = new NumberMatchGame({ width: 9, rows: 4 }); + gameMode.selectedIndex = null; + gameMode.matchesCleared = 0; + gameMode.term = term; + gameMode.boardLines = 0; + gameMode.message = ''; + + term.writeln(''); + term.writeln(' ╔════════════════════════════════════╗'); + term.writeln(' ║ NUMBER MATCH ║'); + term.writeln(' ╚════════════════════════════════════╝'); + term.writeln(''); + term.writeln(' Match pairs: equal numbers or sum to 10'); + term.writeln(' \x1b[90mClick tiles or type coordinates (e.g., A1 A2)\x1b[0m'); + term.writeln(' \x1b[90mCommands: add, hint, new, quit\x1b[0m'); + term.writeln(''); + + renderBoard(term, gameMode.game, null, ''); +} + +/** + * Handle a tile click from the terminal link provider + * @param {string} coord - Coordinate string (e.g., "A1", "B3") + * @returns {void} + */ +export function handleTileClick(coord) { + if (!gameMode.active || !gameMode.game || !gameMode.term) { + return; + } + + const term = gameMode.term; + const game = gameMode.game; + const clickedIndex = parseCoordinate(coord, game.width); + + if (clickedIndex === null) { + return; + } + + const tiles = game.getTiles(); + if (clickedIndex >= tiles.length || tiles[clickedIndex] === null) { + return; + } + + // If no tile selected, select this one + if (gameMode.selectedIndex === null) { + gameMode.selectedIndex = clickedIndex; + playClickSound(); + updateBoardInPlace(term, game, clickedIndex, `\x1b[33mSelected ${coord}. Click another to match.\x1b[0m`); + return; + } + + // If same tile clicked, deselect + if (gameMode.selectedIndex === clickedIndex) { + gameMode.selectedIndex = null; + playClickSound(); + updateBoardInPlace(term, game, null, ''); + return; + } + + // Try to match the two tiles + const firstIndex = gameMode.selectedIndex; + const prevTiles = game.getTiles(); + const v1 = prevTiles[firstIndex]; + const v2 = prevTiles[clickedIndex]; + + const matched = game.selectPair(firstIndex, clickedIndex); + gameMode.selectedIndex = null; + + if (matched) { + gameMode.matchesCleared++; + playMatchSound(); + + if (game.isComplete()) { + // Game won - need to redraw fresh for victory screen + playMatchSound(); + term.write(`\x1b[${gameMode.boardLines}A`); + for (let i = 0; i < gameMode.boardLines; i++) { + term.writeln('\x1b[2K'); + } + term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m'); + term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m'); + term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m'); + term.writeln(''); + term.writeln(` Total matches: ${gameMode.matchesCleared}`); + term.writeln(' Type "new" for another game or "quit" to exit.'); + term.write('\x1b[33mgame>\x1b[0m '); + gameMode.boardLines = 0; + return; + } + + updateBoardInPlace(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`); + } else { + playErrorSound(); + if (v1 !== v2 && v1 + v2 !== 10) { + updateBoardInPlace(term, game, null, `\x1b[31m${v1} and ${v2} don't match or sum to 10\x1b[0m`); + } else { + updateBoardInPlace(term, game, null, `\x1b[31mNo clear path between tiles\x1b[0m`); + } + } +} + +/** + * Number Match command export + */ +export default { + description: 'Play the Number Match puzzle game', + execute: (term, writeClickable, VERSION, args) => { + startGame(term); + return null; + } +}; diff --git a/website/terminal.js b/website/terminal.js index f1016fb..617b388 100644 --- a/website/terminal.js +++ b/website/terminal.js @@ -12,10 +12,42 @@ import githubCmd from './scripts/commands/github.js'; import contactCmd from './scripts/commands/contact.js'; import privacyCmd from './scripts/commands/privacy.js'; import blueskyCmd from './scripts/commands/bluesky.js'; +import numbermatchCmd, { gameMode, processGameInput, handleTileClick } from './scripts/commands/numbermatch.js'; + +// Import Matrix client +import { + initMatrixClient, + chatMode, + matrixApi, + fetchPublicLastMessage, + fetchPublicPresence, + formatTimeAgo, + isDisplayNameTaken, + hasVisibleMentionSuggestions, + resetMentionAutocomplete, + refreshMentionSuggestionsFromInput, + commitSelectedMentionSuggestion, + applyMentionAutocomplete, + enterChatMode, + exitChatMode, + updateQuickCommands, + runChatCommand, + renderChatPrompt, + sendChatMessage +} from './matrix-client.js'; // Version const VERSION = '1.0.0'; +// Matrix configuration +const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); +const MATRIX_CONFIG = { + homeserver: 'https://chat.ruv.wtf', + bridgeUrl: 'https://lit.ruv.wtf/matrix-bridge.html', + useBridge: IS_NEOCITIES_HOST, + publicReadToken: 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o' +}; + // Create inline input element const inlineInput = document.createElement('input'); inlineInput.type = 'text'; @@ -174,7 +206,7 @@ const term = new Terminal({ cursorStyle: 'underline', cursorInactiveStyle: 'none', fontFamily: '"Courier New", Courier, monospace', - fontSize: 14, + fontSize: 20, theme: { background: '#001800', foreground: '#00ff00', @@ -227,7 +259,42 @@ term.registerLinkProvider({ const links = []; // Find all command names that match our known commands - const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy']; + const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy', 'numbermatch']; + + // When in game mode, detect clickable tile numbers on board lines + if (gameMode.active && lineText.includes('│')) { + // Board line format: " A │ 5 3 7 ...│" + // Detect row letter at start + const rowMatch = lineText.match(/^\s*([A-Z])\s*│/); + if (rowMatch) { + const rowLetter = rowMatch[1]; + // Find all digits within the board area (between │ characters) + const boardStart = lineText.indexOf('│') + 1; + const boardEnd = lineText.lastIndexOf('│'); + + if (boardStart > 0 && boardEnd > boardStart) { + let col = 0; + for (let i = boardStart; i < boardEnd; i++) { + const char = lineText[i]; + // Each cell is 3 chars wide: " X " + if ((i - boardStart) % 3 === 1 && /[1-9]/.test(char)) { + col = Math.floor((i - boardStart) / 3) + 1; + const coord = `${rowLetter}${col}`; + links.push({ + range: { + start: { x: i + 1, y: bufferLineNumber }, + end: { x: i + 2, y: bufferLineNumber } + }, + text: char, + activate: () => { + handleTileClick(coord); + } + }); + } + } + } + } + } commandNames.forEach(cmd => { let startIndex = 0; @@ -267,13 +334,13 @@ term.registerLinkProvider({ function adjustFontSize() { const width = window.innerWidth; if (width < 350) { - term.options.fontSize = 8; + term.options.fontSize = 11; } else if (width < 480) { - term.options.fontSize = 10; - } else if (width < 768) { - term.options.fontSize = 12; - } else { term.options.fontSize = 14; + } else if (width < 768) { + term.options.fontSize = 17; + } else { + term.options.fontSize = 20; } fitAddon.fit(); } @@ -356,1179 +423,7 @@ let historyIndex = -1; let currentLine = ''; let cursorPosition = 0; -// Chat mode state -let 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 - } -}; - -const MATRIX_PUBLIC_READ_TOKEN = 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o'; -const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); -const MATRIX_BRIDGE_URL = 'https://lit.ruv.wtf/matrix-bridge.html'; - -/** - * 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 (matrixBridge.iframe) { - return matrixBridge.ready; - } - - return new Promise((resolve) => { - const iframe = document.createElement('iframe'); - iframe.src = MATRIX_BRIDGE_URL; - 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 - }, MATRIX_BRIDGE_URL); - }); -} - -/** - * Handle responses from the Matrix bridge - */ -window.addEventListener('message', (event) => { - if (event.origin !== new URL(MATRIX_BRIDGE_URL).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 -const matrixApi = async (endpoint, method = 'GET', body = null) => { - if (!window.matrixSession) return null; - - // Use iframe bridge on Neocities - if (IS_NEOCITIES_HOST) { - 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 for non-CSP-restricted hosts - const homeserver = 'https://chat.ruv.wtf'; - const url = `${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 chat message using the read-only public token. - * @param {string} homeserver - Matrix homeserver URL - * @param {string} roomAlias - Room alias (e.g. #generalchat:example.org) - * @returns {Promise<{time: string, sender: string, text: string, timestamp: number} | null>} Last message summary or null - */ -async function fetchPublicLastMessage(homeserver, roomAlias) { - // Use iframe bridge on Neocities - if (IS_NEOCITIES_HOST) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - if (!matrixBridge.ready) { - return null; - } - - try { - // Resolve room alias first - let roomId = null; - try { - const resolveResult = await matrixBridgeRequest('matrix:resolveAlias', { roomAlias }); - if (resolveResult && !resolveResult.error && resolveResult.room_id) { - roomId = resolveResult.room_id; - } - } catch (error) { - // Continue with fallback - } - - if (!roomId) { - roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; - } - - // Fetch last message - const result = await matrixBridgeRequest('matrix:fetchLastMessage', { roomId }); - if (!result || result.error || !Array.isArray(result.chunk)) { - return null; - } - - const lastTextEvent = result.chunk.find((event) => { - return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; - }); - - if (!lastTextEvent) { - return null; - } - - const timestamp = typeof lastTextEvent.origin_server_ts === 'number' - ? new Date(lastTextEvent.origin_server_ts) - : new Date(); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - const sender = typeof lastTextEvent.sender === 'string' - ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') - : 'unknown'; - const text = lastTextEvent.content.body || ''; - const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' - ? lastTextEvent.origin_server_ts - : Date.now(); - - return { time, sender, text, timestamp: timestampMs }; - } catch (error) { - return null; - } - } - - // Direct API call for non-CSP-restricted hosts - const resolvedAlias = encodeURIComponent(roomAlias); - const authHeaders = { - Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` - }; - - let roomId = null; - try { - const roomResponse = await fetch(`${homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, { - headers: authHeaders - }); - if (roomResponse.ok) { - const roomData = await roomResponse.json(); - roomId = roomData && roomData.room_id ? roomData.room_id : null; - } - } catch (error) { - roomId = null; - } - - if (!roomId) { - roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; - } - - const resolvedRoomId = encodeURIComponent(roomId); - const messagesResponse = await fetch(`${homeserver}/_matrix/client/r0/rooms/${resolvedRoomId}/messages?dir=b&limit=30`, { - headers: authHeaders - }); - if (!messagesResponse.ok) { - return null; - } - - const messagesData = await messagesResponse.json(); - if (!messagesData || !Array.isArray(messagesData.chunk)) { - return null; - } - - const lastTextEvent = messagesData.chunk.find((event) => { - return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; - }); - - if (!lastTextEvent) { - return null; - } - - const timestamp = typeof lastTextEvent.origin_server_ts === 'number' - ? new Date(lastTextEvent.origin_server_ts) - : new Date(); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - const sender = typeof lastTextEvent.sender === 'string' - ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') - : 'unknown'; - const text = lastTextEvent.content.body || ''; - const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' - ? lastTextEvent.origin_server_ts - : Date.now(); - - return { time, sender, text, timestamp: timestampMs }; -} - -/** - * Fetch Matrix presence for a user using the read-only public token. - * @param {string} homeserver - Matrix homeserver URL - * @param {string} userId - Full Matrix user ID - * @returns {Promise} Presence state (online/offline/unavailable) or null when unavailable - */ -async function fetchPublicPresence(homeserver, userId) { - // Use iframe bridge on Neocities - if (IS_NEOCITIES_HOST) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - if (!matrixBridge.ready) { - return null; - } - - try { - const result = await matrixBridgeRequest('matrix:fetchPresence', { userId }); - if (result && !result.error && typeof result.presence === 'string') { - return result.presence; - } - return null; - } catch (error) { - return null; - } - } - - // Direct API call for non-CSP-restricted hosts - const authHeaders = { - Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` - }; - const encodedUserId = encodeURIComponent(userId); - const response = await fetch(`${homeserver}/_matrix/client/r0/presence/${encodedUserId}/status`, { - headers: authHeaders - }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - return data && typeof data.presence === 'string' ? data.presence : null; -} - -/** - * Format a unix-millisecond timestamp as relative age text. - * @param {number} timestampMs - Epoch timestamp in milliseconds - * @returns {string} Relative time label - */ -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`; -} - -/** - * Write a startup MOTD line with the latest public chat message. - * Uses a short timeout so startup stays responsive. - * @returns {Promise} - */ -async function writeStartupChatMotd() { - try { - const result = await Promise.race([ - (async () => { - const [latest, litruvPresence] = await Promise.all([ - fetchPublicLastMessage('https://chat.ruv.wtf', '#generalchat:b.ruv.wtf'), - fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf') - ]); - return { latest, litruvPresence }; - })(), - new Promise((resolve) => setTimeout(() => resolve({ latest: null, litruvPresence: null }), 2500)) - ]); - - if (!result.latest && !result.litruvPresence) { - return; - } - - const lastMessageAge = result.latest - ? formatTimeAgo(result.latest.timestamp) - : null; - const onlineText = result.litruvPresence === 'online' - ? 'online' - : 'offline'; - - if (lastMessageAge) { - term.writeln(` #generalchat · last message ${lastMessageAge} · @litruv:b.ruv.wtf ${onlineText}`); - } else { - term.writeln(` #generalchat · @litruv:b.ruv.wtf ${onlineText}`); - } - writeClickable(` Run [command=chat] to join in on the conversation`); - term.writeln(''); - } catch (error) { - // Ignore MOTD fetch issues to avoid blocking terminal startup. - } -} - -// 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 -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 - */ -function hasVisibleMentionSuggestions() { - return chatMode.active - && mentionSuggestions.style.display !== 'none' - && chatMode.mentionAutocomplete.matches.length > 0; -} - -/** - * Hide mention suggestion UI and clear state. - * @returns {void} - */ -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} - */ -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 - */ -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} - */ -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 -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 channel presence checker for litruv account - const presenceTime = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - const litruvPresence = await fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf'); - const litruvOnlineText = litruvPresence === 'online' ? 'online' : 'offline'; - term.writeln(`\x1b[90m[${presenceTime}]\x1b[0m \x1b[93mSystem:\x1b[0m @litruv:b.ruv.wtf is ${litruvOnlineText}`); - - // 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 -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' - */ -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 - */ -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; - } - - // Execute the command - inlineInput.value = command; - submitInlineInput(); -} - -window.runChatCommand = runChatCommand; - -// 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)}`); - - // 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) -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 -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); - } -} +// Matrix client - imported from matrix-client.js // Available commands const commands = { @@ -1584,6 +479,10 @@ const commands = { description: blueskyCmd.description, execute: async (args) => await blueskyCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, + numbermatch: { + description: numbermatchCmd.description, + execute: (args) => numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory) + }, chat: { description: 'Connect to chat room', execute: async (args) => { @@ -2058,6 +957,17 @@ async function submitInlineInput() { return; } + // Handle game mode + if (gameMode.active) { + term.write(rawValue + '\r\n'); + const continueGame = processGameInput(term, cmd); + if (!continueGame) { + writePrompt(); + } + setTimeout(() => positionInlineInput(), 10); + return; + } + // Normal command mode term.write(rawValue + '\r\n'); @@ -2065,20 +975,85 @@ async function submitInlineInput() { await executeCommand(cmd); } - // Show prompt if not in chat mode - if (!chatMode.active) { + // Show prompt if not in chat mode or game mode + // Game mode handles its own prompt in renderBoard + if (!chatMode.active && !gameMode.active) { writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput(); }, 10); + } else if (gameMode.active) { + setTimeout(() => positionInlineInput(), 10); } term.scrollToBottom(); } +/** + * Write startup chat MOTD with last message and presence + */ +async function writeStartupChatMotd() { + try { + const result = await Promise.race([ + (async () => { + const [latest, presence] = await Promise.all([ + fetchPublicLastMessage('#generalchat:b.ruv.wtf'), + fetchPublicPresence('@litruv:b.ruv.wtf') + ]); + return { latest, presence }; + })(), + new Promise((resolve) => setTimeout(() => resolve({ latest: null, presence: null }), 2500)) + ]); + + if (!result.latest && !result.presence) { + return; + } + + if (result.latest) { + const sender = result.latest.sender.split(':')[0].substring(1); + const age = formatTimeAgo(result.latest.timestamp); + term.write(` \x1b[36m${sender}\x1b[0m: ${result.latest.body.substring(0, 50)}${result.latest.body.length > 50 ? '...' : ''} \x1b[90m(${age})\x1b[0m\r\n`); + } + + if (result.presence) { + const statusColor = result.presence.presence === 'online' ? '\x1b[32m' : '\x1b[90m'; + const lastActive = result.presence.lastActive > 0 ? formatTimeAgo(Date.now() - result.presence.lastActive) : null; + term.write(` \x1b[36mlitruv\x1b[0m ${statusColor}${result.presence.presence}\x1b[0m`); + if (result.presence.presence !== 'online' && lastActive) { + term.write(` \x1b[90m(${lastActive})\x1b[0m`); + } + term.writeln(''); + } + + term.write(' '); + writeClickable('[command=chat]', 'Run \x1b[32mchat\x1b[0m to join in on the conversation!'); + term.writeln('\r\n'); + } catch (error) { + // Silently fail - not critical for startup + } +} + // Initialize terminal async function init() { + // Initialize Matrix client with config and dependencies + initMatrixClient(MATRIX_CONFIG, { + term, + inlineInput, + mentionSuggestions, + writeClickable, + writePrompt, + showInlineInput, + positionInlineInput, + submitInlineInput, + welcomeBannerFull, + welcomeBannerCompact, + welcomeBannerMinimal + }); + + // Expose chat command for onclick handlers + window.runChatCommand = runChatCommand; + // Display welcome banner with clickable commands const cols = term.cols; if (cols >= 78) { @@ -2199,6 +1174,13 @@ async function runQuickCommand(command) { // Make function available globally for onclick handlers window.runQuickCommand = runQuickCommand; +// Expose sound functions for game modules +window.terminalSounds = { + files: TERMINAL_SOUND_FILES, + play: playSoundFile, + pickRandom: pickRandomValue +}; + /** * Write text to terminal with clickable commands * Parses [command=X] syntax to create clickable command links