// ============================================================================= // MATCHA SOCIAL — Renderer UI Module // State machine: intro → login/register → keyDisplay → keyVerify → app // App sub-views: friends, globalChat, dm, profile // ============================================================================= const matcha = window.electronAPI?.matcha; if (!matcha) console.warn('[Matcha] electronAPI.matcha not available'); // Logger that forwards to main process log file const mlog = { log: (...args) => { console.log('[Matcha]', ...args); matcha?.log('info', ...args); }, warn: (...args) => { console.warn('[Matcha]', ...args); matcha?.log('warn', ...args); }, error: (...args) => { console.error('[Matcha]', ...args); matcha?.log('error', ...args); } }; let avatarCacheBust = Date.now(); let avatarDeletedUsers = new Set(); // Track users with deleted avatars let friendsPollTimer = null; let requestsCollapsed = false; let matchaState = { view: 'intro', // intro | login | register | keyDisplay | keyVerify | app appTab: 'friends', // friends | globalChat panelOpen: false, user: null, wsConnected: false, // Registration flow pendingId: null, proofId: null, pendingHandle: null, // Friends friends: [], incomingRequests: [], outgoingRequests: [], // Chat globalMessages: [], globalCursor: null, globalHasMore: true, // DM dmTarget: null, dmMessages: [], dmCursor: null, dmHasMore: true, // Unread unreadGlobal: 0, unreadDms: {}, // Reply replyTo: null, // Loading (separate per chat type) globalLoading: false, dmLoading: false }; // ============================================================================= // REACTIVE STATE — setUser triggers UI updates everywhere // ============================================================================= function setUser(userData) { const hadUser = !!matchaState.user?.id; matchaState.user = userData; mlog.log('setUser:', userData ? `${userData.id} ${userData.handle}` : '(cleared)'); updateHeaderAvatar(); // If user data just became available and panel is open, re-render to fix isOwn checks etc. if (!hadUser && userData?.id && matchaState.panelOpen) { renderCurrentView(); } } // ============================================================================= // PANEL TOGGLE // ============================================================================= function toggleMatchaPanel() { const panel = document.getElementById('matchaPanel'); const backdrop = document.getElementById('matchaPanelBackdrop'); if (!panel || !backdrop) return; matchaState.panelOpen = !matchaState.panelOpen; if (matchaState.panelOpen) { panel.classList.add('open'); backdrop.classList.add('active'); // Defer heavy content rendering until after the slide-in transition completes. // Rendering large DOM (e.g. global chat messages) during CSS transform transition // causes layout thrashing in Electron's Chromium, shrinking the main content. let rendered = false; const onDone = () => { if (rendered) return; rendered = true; panel.removeEventListener('transitionend', onDone); renderCurrentView(); }; panel.addEventListener('transitionend', onDone); // Safety fallback if transitionend doesn't fire (e.g. reduced motion) setTimeout(onDone, 350); } else { panel.classList.remove('open'); backdrop.classList.remove('active'); } } function closeMatchaPanel() { matchaState.panelOpen = false; document.getElementById('matchaPanel')?.classList.remove('open'); document.getElementById('matchaPanelBackdrop')?.classList.remove('active'); } // ============================================================================= // VIEW ROUTER // ============================================================================= function renderCurrentView() { const body = document.getElementById('matchaPanelBody'); if (!body) return; const header = document.getElementById('matchaPanelHeaderContent'); switch (matchaState.view) { case 'intro': renderIntro(body, header); break; case 'login': renderLogin(body, header); break; case 'register': renderRegister(body, header); break; case 'keyDisplay': renderKeyDisplay(body, header); break; case 'keyVerify': renderKeyVerify(body, header); break; case 'app': renderApp(body, header); break; } } function setView(view) { matchaState.view = view; renderCurrentView(); } // ============================================================================= // INTRO SCREEN // ============================================================================= function renderIntro(body, header) { header.innerHTML = 'Social'; body.innerHTML = `

Matcha!

Connect with other Hytale players

Friends & Presence
Global Chat
Direct Messages

Powered by Matcha!

`; body.querySelector('#matchaSignInBtn').addEventListener('click', () => setView('login')); body.querySelector('#matchaCreateBtn').addEventListener('click', () => setView('register')); const poweredLink = body.querySelector('.matcha-powered-link'); if (poweredLink) { poweredLink.addEventListener('click', (e) => { e.preventDefault(); window.electronAPI?.openExternal(poweredLink.dataset.url); }); } } // ============================================================================= // LOGIN SCREEN // ============================================================================= function renderLogin(body, header) { header.innerHTML = ` Sign In `; body.innerHTML = `
`; header.querySelector('#matchaLoginBack').addEventListener('click', () => setView('intro')); body.querySelector('#matchaLoginToRegister').addEventListener('click', (e) => { e.preventDefault(); setView('register'); }); const handleInput = body.querySelector('#matchaLoginHandle'); const passInput = body.querySelector('#matchaLoginPassword'); const submitBtn = body.querySelector('#matchaLoginSubmit'); const errorEl = body.querySelector('#matchaLoginError'); async function doLogin() { const handle = handleInput.value.trim(); const password = passInput.value; if (!handle || !password) { errorEl.textContent = 'Please fill in all fields'; return; } submitBtn.disabled = true; submitBtn.textContent = 'Signing in...'; errorEl.textContent = ''; try { const res = await matcha.login(handle, password); mlog.log('Login result:', res.ok ? 'success' : 'failed', res.ok ? '' : res.error); if (res.ok) { setUser(res.data.user || null); setView('app'); refreshAppData(); } else { errorEl.textContent = res.error || 'Login failed'; submitBtn.disabled = false; submitBtn.textContent = 'Sign In'; } } catch (err) { mlog.error('Login error:', err?.message || err); errorEl.textContent = 'Connection error. Please try again.'; submitBtn.disabled = false; submitBtn.textContent = 'Sign In'; } } submitBtn.addEventListener('click', doLogin); passInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); handleInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') passInput.focus(); }); setTimeout(() => handleInput.focus(), 100); } // ============================================================================= // REGISTER SCREEN // ============================================================================= function renderRegister(body, header) { header.innerHTML = ` Create Account `; body.innerHTML = `
`; header.querySelector('#matchaRegBack').addEventListener('click', () => setView('intro')); body.querySelector('#matchaRegToLogin').addEventListener('click', (e) => { e.preventDefault(); setView('login'); }); const userInput = body.querySelector('#matchaRegUsername'); const passInput = body.querySelector('#matchaRegPassword'); const confirmInput = body.querySelector('#matchaRegConfirm'); const submitBtn = body.querySelector('#matchaRegSubmit'); const errorEl = body.querySelector('#matchaRegError'); submitBtn.addEventListener('click', async () => { const username = userInput.value.trim(); const password = passInput.value; const password2 = confirmInput.value; if (!username || !password || !password2) { errorEl.textContent = 'Please fill in all fields'; return; } submitBtn.disabled = true; submitBtn.textContent = 'Creating...'; errorEl.textContent = ''; try { // Send both passwords to server for validation (matches Butter API) const res = await matcha.register(username, password, password2); mlog.log('Register result:', res.ok ? 'success' : 'failed', res.ok ? JSON.stringify(Object.keys(res.data || {})) : res.error); if (res.ok) { matchaState.pendingId = res.data.pendingId || null; // API may return proofId or masterKey matchaState.proofId = res.data.proofId || res.data.masterKey || null; matchaState.pendingHandle = res.data.handle || null; mlog.log('Registration pending:', matchaState.pendingId, 'handle:', matchaState.pendingHandle, 'hasProof:', !!matchaState.proofId); setView('keyDisplay'); } else { errorEl.textContent = res.error || 'Registration failed'; submitBtn.disabled = false; submitBtn.textContent = 'Create Account'; } } catch (err) { mlog.error('Register error:', err?.message || err); errorEl.textContent = 'Connection error. Please try again.'; submitBtn.disabled = false; submitBtn.textContent = 'Create Account'; } }); setTimeout(() => userInput.focus(), 100); } // ============================================================================= // KEY DISPLAY SCREEN // ============================================================================= function renderKeyDisplay(body, header) { header.innerHTML = 'Save Your Key'; body.innerHTML = `
Save this information — it cannot be recovered!
${escapeHtml(matchaState.pendingHandle || '')}
${escapeHtml(matchaState.proofId || '')}

This key is your password recovery method. Without it, you cannot reset your password.

`; // Copy buttons body.querySelectorAll('.matcha-copy-btn').forEach(btn => { btn.addEventListener('click', () => { navigator.clipboard.writeText(btn.dataset.copy); const icon = btn.querySelector('i'); icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }); }); // Enable button after 5s const savedBtn = body.querySelector('#matchaKeySaved'); setTimeout(() => { savedBtn.disabled = false; }, 5000); savedBtn.addEventListener('click', () => setView('keyVerify')); } // ============================================================================= // KEY VERIFY SCREEN // ============================================================================= function renderKeyVerify(body, header) { header.innerHTML = ` Verify Your Key `; body.innerHTML = `

Enter your master key to confirm you saved it.

`; header.querySelector('#matchaVerifyBack').addEventListener('click', () => setView('keyDisplay')); const keyInput = body.querySelector('#matchaVerifyKey'); const submitBtn = body.querySelector('#matchaVerifySubmit'); const errorEl = body.querySelector('#matchaVerifyError'); submitBtn.addEventListener('click', async () => { const key = keyInput.value.trim(); if (key !== matchaState.proofId) { errorEl.textContent = 'Key does not match. Please try again.'; return; } submitBtn.disabled = true; submitBtn.textContent = 'Confirming...'; errorEl.textContent = ''; const res = await matcha.confirmRegister(matchaState.pendingId, matchaState.proofId); mlog.log('Confirm result:', res.ok ? 'success' : 'failed', res.ok ? '' : res.error); if (res.ok) { // API returns { ok, token, user } — user may be at res.data.user or res.data directly const userData = res.data?.user || null; if (userData) setUser(userData); matchaState.pendingId = null; matchaState.proofId = null; matchaState.pendingHandle = null; setView('app'); refreshAppData(); } else { errorEl.textContent = res.error || 'Confirmation failed'; submitBtn.disabled = false; submitBtn.textContent = 'Verify & Continue'; } }); setTimeout(() => keyInput.focus(), 100); } // ============================================================================= // APP VIEW (post-login) // ============================================================================= function renderApp(body, header) { // If viewing DM, render DM if (matchaState.dmTarget) { renderDmChat(body, header); return; } // If viewing profile if (matchaState.appTab === 'profile') { renderProfile(body, header); return; } // User bar + tabs const avUrl = avatarUrl(matchaState.user?.id); header.innerHTML = `
${avUrl ? `` : ``}
${escapeHtml(matchaState.user?.handle || 'User')}
`; header.querySelector('#matchaProfileBtn')?.addEventListener('click', () => { matchaState.appTab = 'profile'; renderCurrentView(); }); header.querySelector('#matchaUserBar')?.addEventListener('click', (e) => { if (e.target.closest('#matchaProfileBtn')) return; matchaState.appTab = 'profile'; renderCurrentView(); }); // Tab bar + content if (matchaState.appTab === 'globalChat') { renderGlobalChat(body); } else { renderFriends(body); } } // ============================================================================= // TAB BAR // ============================================================================= function renderTabBar(container) { const globalBadge = matchaState.unreadGlobal > 0 ? `${matchaState.unreadGlobal}` : ''; const tabBar = document.createElement('div'); tabBar.className = 'matcha-tab-bar'; tabBar.innerHTML = ` `; tabBar.querySelectorAll('.matcha-tab').forEach(tab => { tab.addEventListener('click', () => { matchaState.appTab = tab.dataset.tab; renderCurrentView(); }); }); container.prepend(tabBar); } // ============================================================================= // FRIENDS VIEW // ============================================================================= function renderFriends(body) { body.innerHTML = `
Loading friends...
`; renderTabBar(body); const addInput = body.querySelector('#matchaAddFriendInput'); const addBtn = body.querySelector('#matchaAddFriendBtn'); async function addFriend() { const handle = addInput.value.trim(); if (!handle) return; addBtn.disabled = true; const origIcon = addBtn.innerHTML; addBtn.innerHTML = ''; const res = await matcha.friendRequest(handle); addBtn.disabled = false; addBtn.innerHTML = origIcon; if (res.ok) { addInput.value = ''; showToast('Friend request sent!'); loadFriends(); } else { showToast(res.error || 'Failed to send request'); } } addBtn.addEventListener('click', addFriend); addInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addFriend(); }); loadFriends(); } async function loadFriends() { try { const res = await matcha.getFriends(); if (!res.ok) { mlog.warn('loadFriends failed:', res.error); return; } const data = res.data; matchaState.friends = data.friends || []; matchaState.incomingRequests = data.incoming || []; matchaState.outgoingRequests = data.outgoing || []; mlog.log('Friends loaded:', matchaState.friends.length, 'friends,', matchaState.incomingRequests.length, 'incoming,', matchaState.outgoingRequests.length, 'outgoing'); renderFriendRequests(); renderFriendsList(); updateUnreadBadges(); } catch (err) { mlog.error('loadFriends exception:', err?.message || err); } } function renderFriendRequests() { const container = document.getElementById('matchaFriendRequests'); if (!container) return; const incoming = matchaState.incomingRequests; const outgoing = matchaState.outgoingRequests; const totalRequests = incoming.length + outgoing.length; if (totalRequests === 0) { container.innerHTML = ''; return; } const chevronIcon = requestsCollapsed ? 'fa-chevron-right' : 'fa-chevron-down'; let html = `
Requests (${totalRequests}) ${incoming.length > 0 ? `${incoming.length} new` : ''}
`; if (!requestsCollapsed) { if (incoming.length > 0) { html += `
Incoming (${incoming.length})
`; incoming.forEach(req => { const avUrl = avatarUrl(req.fromId); html += `
${avUrl ? `` : `
`}
${escapeHtml(req.fromHandle || 'Unknown')}
`; }); } if (outgoing.length > 0) { html += `
Sent (${outgoing.length})
`; outgoing.forEach(req => { const avUrl = avatarUrl(req.toId); html += `
${avUrl ? `` : `
`}
${escapeHtml(req.toHandle || 'Unknown')}
`; }); } } html += '
'; container.innerHTML = html; // Toggle collapse container.querySelector('#matchaRequestsToggle')?.addEventListener('click', () => { requestsCollapsed = !requestsCollapsed; renderFriendRequests(); }); container.querySelectorAll('[data-accept]').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; btn.innerHTML = ''; const res = await matcha.friendAccept(btn.dataset.accept); if (res.ok) { showToast('Friend request accepted!'); } else { showToast(res.error || 'Failed'); btn.disabled = false; btn.innerHTML = ''; } loadFriends(); }); }); container.querySelectorAll('[data-reject]').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; btn.innerHTML = ''; const res = await matcha.friendReject(btn.dataset.reject); if (res.ok) { showToast('Request rejected'); } else { showToast(res.error || 'Failed'); btn.disabled = false; btn.innerHTML = ''; } loadFriends(); }); }); container.querySelectorAll('[data-cancel]').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; btn.innerHTML = ''; const res = await matcha.friendCancel(btn.dataset.cancel); if (res.ok) { showToast('Request cancelled'); } else { showToast(res.error || 'Failed'); btn.disabled = false; btn.innerHTML = ''; } loadFriends(); }); }); } function renderFriendsList() { const container = document.getElementById('matchaFriendsList'); if (!container) return; const friends = matchaState.friends; if (friends.length === 0) { container.innerHTML = '

No friends yet. Add someone!

'; return; } // Sort: online first, then alphabetical const sorted = [...friends].sort((a, b) => { const aOnline = a.presence !== 'offline' ? 1 : 0; const bOnline = b.presence !== 'offline' ? 1 : 0; if (aOnline !== bOnline) return bOnline - aOnline; return (a.handle || '').localeCompare(b.handle || ''); }); const onlineCount = sorted.filter(f => f.presence !== 'offline').length; let html = `
Friends \u2014 ${onlineCount} connected (${friends.length} total)
`; sorted.forEach(f => { const presenceClass = f.presence === 'offline' ? 'offline' : (f.presence === 'in_game' ? 'ingame' : 'online'); const presenceText = f.presence === 'offline' ? 'Offline' : (f.presence === 'in_game' ? 'In Game' : 'Online'); const unread = matchaState.unreadDms[f.id] || 0; const unreadBadge = unread > 0 ? `${unread}` : ''; const avUrl = avatarUrl(f.id); html += `
${avUrl ? `` : ``}
${escapeHtml(f.handle || 'Unknown')} ${presenceText}
${unreadBadge}
`; }); container.innerHTML = html; // DM button clicks container.querySelectorAll('[data-dm]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); matchaState.dmTarget = { id: btn.dataset.dm, handle: btn.dataset.dmHandle }; matchaState.dmMessages = []; matchaState.dmCursor = null; matchaState.dmHasMore = true; renderCurrentView(); }); }); // Remove friend button clicks container.querySelectorAll('[data-remove]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const handle = btn.dataset.removeHandle; if (!confirm(`Remove ${handle} from friends?`)) return; btn.disabled = true; btn.innerHTML = ''; const res = await matcha.friendRemove(btn.dataset.remove); if (res.ok) { showToast(`${handle} removed`); loadFriends(); } else { showToast(res.error || 'Failed to remove friend'); btn.disabled = false; btn.innerHTML = ''; } }); }); // Row click also opens DM container.querySelectorAll('.matcha-friend-row').forEach(row => { row.addEventListener('click', (e) => { if (e.target.closest('.matcha-friend-actions')) return; matchaState.dmTarget = { id: row.dataset.friendId, handle: row.dataset.friendHandle }; matchaState.dmMessages = []; matchaState.dmCursor = null; matchaState.dmHasMore = true; renderCurrentView(); }); }); } // ============================================================================= // GLOBAL CHAT // ============================================================================= function renderGlobalChat(body) { body.innerHTML = `
Loading messages...
`; renderTabBar(body); const input = body.querySelector('#matchaGlobalInput'); const sendBtn = body.querySelector('#matchaGlobalSend'); const cancelReply = body.querySelector('#matchaCancelReply'); const messagesEl = body.querySelector('#matchaGlobalMessages'); async function sendMsg() { const text = input.value.trim(); if (!text) return; sendBtn.disabled = true; // Build reply preview for optimistic message let replySnippet = null; let replyFromHandle = null; if (matchaState.replyTo) { const repliedMsg = matchaState.globalMessages.find(m => m.id === matchaState.replyTo); if (repliedMsg) { replySnippet = (repliedMsg.body || '').substring(0, 80); replyFromHandle = repliedMsg.fromHandle || 'Unknown'; } } // Optimistic render const optimisticMsg = { id: 'opt_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8), fromId: matchaState.user?.id, fromHandle: matchaState.user?.handle || 'You', fromAvatarHash: matchaState.user?.avatarHash, toId: 'global', body: text, replyToId: matchaState.replyTo, replyToSnippet: replySnippet, replyToFromHandle: replyFromHandle, createdAt: new Date().toISOString(), _optimistic: true }; matchaState.globalMessages.push(optimisticMsg); appendMessageToList('matchaGlobalMessages', optimisticMsg); const res = await matcha.sendMessage('global', text, matchaState.replyTo); sendBtn.disabled = false; if (res.ok) { input.value = ''; matchaState.replyTo = null; body.querySelector('#matchaReplyBar').style.display = 'none'; } else { showToast(res.error || 'Failed to send'); // Remove optimistic message on failure matchaState.globalMessages = matchaState.globalMessages.filter(m => m.id !== optimisticMsg.id); const optEl = document.querySelector(`[data-msg-id="${optimisticMsg.id}"]`); if (optEl) optEl.remove(); } } sendBtn.addEventListener('click', sendMsg); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !sendBtn.disabled) sendMsg(); }); cancelReply.addEventListener('click', () => { matchaState.replyTo = null; body.querySelector('#matchaReplyBar').style.display = 'none'; }); // Scroll to load more messagesEl.addEventListener('scroll', () => { if (messagesEl.scrollTop === 0 && matchaState.globalHasMore && !matchaState.globalLoading) { loadOlderGlobalMessages(); } }); // Clear unread matchaState.unreadGlobal = 0; matcha.clearUnread('global').catch(() => {}); updateUnreadBadges(); loadGlobalMessages(); setTimeout(() => input.focus(), 100); } async function loadGlobalMessages() { matchaState.globalLoading = true; const res = await matcha.getMessages('global'); matchaState.globalLoading = false; if (!res.ok) { mlog.warn('loadGlobalMessages failed:', res.error); return; } mlog.log('Global messages loaded:', (res.data.messages || []).length); // API returns newest-first, we need oldest-first for display const msgs = res.data.messages || []; matchaState.globalMessages = sortMessagesOldestFirst(msgs); matchaState.globalCursor = res.data.nextCursor || res.data.cursor || null; matchaState.globalHasMore = !!(res.data.nextCursor || res.data.cursor); renderGlobalMessageList(); scrollToBottom('matchaGlobalMessages'); } async function loadOlderGlobalMessages() { if (!matchaState.globalCursor || matchaState.globalLoading) return; matchaState.globalLoading = true; const res = await matcha.getMessages('global', matchaState.globalCursor); matchaState.globalLoading = false; if (!res.ok) return; const older = sortMessagesOldestFirst(res.data.messages || []); matchaState.globalMessages = [...older, ...matchaState.globalMessages]; matchaState.globalCursor = res.data.nextCursor || res.data.cursor || null; matchaState.globalHasMore = !!(res.data.nextCursor || res.data.cursor); // Preserve scroll position when prepending older messages const container = document.getElementById('matchaGlobalMessages'); const prevScrollHeight = container ? container.scrollHeight : 0; renderGlobalMessageList(); if (container) container.scrollTop = container.scrollHeight - prevScrollHeight; } function renderGlobalMessageList() { const container = document.getElementById('matchaGlobalMessages'); if (!container) return; if (matchaState.globalMessages.length === 0) { container.innerHTML = '

No messages yet. Say hello!

'; return; } container.innerHTML = matchaState.globalMessages.map(m => renderMessage(m)).join(''); attachMessageListeners(container, 'global'); } // ============================================================================= // DM CHAT // ============================================================================= function renderDmChat(body, header) { const target = matchaState.dmTarget; // Find friend for presence/avatar info const friend = matchaState.friends.find(f => f.id === target.id); const presenceClass = friend?.presence === 'offline' ? 'offline' : (friend?.presence === 'in_game' ? 'ingame' : 'online'); const presenceText = friend?.presence === 'offline' ? 'Offline' : (friend?.presence === 'in_game' ? 'In Game' : 'Online'); const avUrl = avatarUrl(friend?.id); header.innerHTML = `
${avUrl ? `` : ``}
${escapeHtml(target.handle)} ${presenceText}
`; body.innerHTML = `
Loading messages...
`; header.querySelector('#matchaDmBack').addEventListener('click', () => { matchaState.dmTarget = null; matchaState.replyTo = null; matchaState.appTab = 'friends'; renderCurrentView(); }); const input = body.querySelector('#matchaDmInput'); const sendBtn = body.querySelector('#matchaDmSend'); const cancelReply = body.querySelector('#matchaDmCancelReply'); const messagesEl = body.querySelector('#matchaDmMessages'); async function sendDm() { const text = input.value.trim(); if (!text) return; sendBtn.disabled = true; // Build reply preview for optimistic message let replySnippet = null; let replyFromHandle = null; if (matchaState.replyTo) { const repliedMsg = matchaState.dmMessages.find(m => m.id === matchaState.replyTo); if (repliedMsg) { replySnippet = (repliedMsg.body || '').substring(0, 80); replyFromHandle = repliedMsg.fromHandle || 'Unknown'; } } // Optimistic render const optimisticMsg = { id: 'opt_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8), fromId: matchaState.user?.id, fromHandle: matchaState.user?.handle || 'You', fromAvatarHash: matchaState.user?.avatarHash, toId: target.id, body: text, replyToId: matchaState.replyTo, replyToSnippet: replySnippet, replyToFromHandle: replyFromHandle, createdAt: new Date().toISOString(), _optimistic: true }; matchaState.dmMessages.push(optimisticMsg); appendMessageToList('matchaDmMessages', optimisticMsg); const res = await matcha.sendMessage(target.handle, text, matchaState.replyTo); sendBtn.disabled = false; if (res.ok) { input.value = ''; matchaState.replyTo = null; body.querySelector('#matchaDmReplyBar').style.display = 'none'; } else { showToast(res.error || 'Failed to send'); matchaState.dmMessages = matchaState.dmMessages.filter(m => m.id !== optimisticMsg.id); const optEl = document.querySelector(`[data-msg-id="${optimisticMsg.id}"]`); if (optEl) optEl.remove(); } } sendBtn.addEventListener('click', sendDm); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !sendBtn.disabled) sendDm(); }); cancelReply.addEventListener('click', () => { matchaState.replyTo = null; body.querySelector('#matchaDmReplyBar').style.display = 'none'; }); messagesEl.addEventListener('scroll', () => { if (messagesEl.scrollTop === 0 && matchaState.dmHasMore && !matchaState.dmLoading) { loadOlderDmMessages(); } }); // Clear unread for this conversation delete matchaState.unreadDms[target.id]; matcha.clearUnread(target.id).catch(() => {}); updateUnreadBadges(); loadDmMessages(); setTimeout(() => input.focus(), 100); } async function loadDmMessages() { if (!matchaState.dmTarget) return; matchaState.dmLoading = true; const res = await matcha.getMessages(matchaState.dmTarget.id); matchaState.dmLoading = false; if (!res.ok) return; matchaState.dmMessages = sortMessagesOldestFirst(res.data.messages || []); matchaState.dmCursor = res.data.nextCursor || res.data.cursor || null; matchaState.dmHasMore = !!(res.data.nextCursor || res.data.cursor); renderDmMessageList(); scrollToBottom('matchaDmMessages'); } async function loadOlderDmMessages() { if (!matchaState.dmCursor || matchaState.dmLoading) return; matchaState.dmLoading = true; const res = await matcha.getMessages(matchaState.dmTarget.id, matchaState.dmCursor); matchaState.dmLoading = false; if (!res.ok) return; const older = sortMessagesOldestFirst(res.data.messages || []); matchaState.dmMessages = [...older, ...matchaState.dmMessages]; matchaState.dmCursor = res.data.nextCursor || res.data.cursor || null; matchaState.dmHasMore = !!(res.data.nextCursor || res.data.cursor); const container = document.getElementById('matchaDmMessages'); const prevScrollHeight = container ? container.scrollHeight : 0; renderDmMessageList(); if (container) container.scrollTop = container.scrollHeight - prevScrollHeight; } function renderDmMessageList() { const container = document.getElementById('matchaDmMessages'); if (!container) return; if (matchaState.dmMessages.length === 0) { container.innerHTML = '

No messages yet. Say hello!

'; return; } container.innerHTML = matchaState.dmMessages.map(m => renderMessage(m)).join(''); attachMessageListeners(container, 'dm'); } // ============================================================================= // MESSAGE RENDERING // ============================================================================= function renderMessage(msg) { // Own check: optimistic messages are always own; otherwise compare IDs as strings const isOwn = msg._optimistic || (!!msg.fromId && !!matchaState.user?.id && String(msg.fromId) === String(matchaState.user.id)); const time = formatTime(msg.createdAt); const fullTime = new Date(msg.createdAt).toLocaleString(); // For own messages, fall back to current user ID for avatar (optimistic msgs may lack fromId) const avUrl = avatarUrl(msg.fromId || (isOwn ? matchaState.user?.id : null)); const badgeHtml = msg.fromIsDev ? 'DEV' : ''; const replyHtml = msg.replyToSnippet ? `
${escapeHtml(msg.replyToFromHandle || '')} : ${escapeHtml(msg.replyToSnippet)}
` : ''; const clickable = !isOwn && msg.fromId ? ' matcha-clickable-user' : ''; const avatarHtml = `
${avUrl ? `` : ``}
`; const actionsHtml = !msg.deleted ? `
${isOwn ? `` : ''}
` : ''; if (isOwn) { // Own messages: right-aligned, avatar on right const ownHandle = msg.fromHandle || matchaState.user?.handle || 'You'; return `
${replyHtml}
${actionsHtml}
${time} ${escapeHtml(ownHandle)} ${badgeHtml}
${msg.deleted ? 'Message deleted' : linkify(msg.body || '')}
${avatarHtml}
`; } // Others' messages: left-aligned, avatar on left const authorClickable = msg.fromId ? ` matcha-clickable-user" data-user-id="${msg.fromId}` : ''; return `
${replyHtml}
${avatarHtml}
${escapeHtml(msg.fromHandle || 'Unknown')} ${badgeHtml} ${time}
${msg.deleted ? 'Message deleted' : linkify(msg.body || '')}
${actionsHtml}
`; } function attachMessageListeners(container, chatType) { container.querySelectorAll('.matcha-msg-action').forEach(btn => { btn.addEventListener('click', (e) => { const msgEl = e.target.closest('.matcha-message'); const msgId = msgEl.dataset.msgId; const action = btn.dataset.action; if (action === 'reply') { matchaState.replyTo = msgId; const replyBarId = chatType === 'dm' ? 'matchaDmReplyBar' : 'matchaReplyBar'; const replyTextId = chatType === 'dm' ? 'matchaDmReplyText' : 'matchaReplyText'; const bar = document.getElementById(replyBarId); const text = document.getElementById(replyTextId); if (bar && text) { text.textContent = `Replying to ${msgEl.dataset.fromHandle}`; bar.style.display = 'flex'; } } else if (action === 'delete') { matcha.deleteMessage(msgId).then(res => { if (res.ok) { msgEl.classList.add('deleted'); const bodyEl = msgEl.querySelector('.matcha-msg-body'); if (bodyEl) bodyEl.innerHTML = 'Message deleted'; const actionsEl = msgEl.querySelector('.matcha-msg-actions'); if (actionsEl) actionsEl.remove(); } else { showToast(res.error || 'Failed to delete message'); } }).catch(() => showToast('Failed to delete message')); } }); }); // Clickable usernames and avatars → open user profile popup container.querySelectorAll('.matcha-clickable-user').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); const userId = el.dataset.userId; if (userId) openUserProfile(userId); }); }); // Clickable links → open in external browser container.querySelectorAll('.matcha-link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const url = link.dataset.url; if (url && window.electronAPI?.openExternal) { window.electronAPI.openExternal(url); } }); }); } // ============================================================================= // PROFILE VIEW // ============================================================================= function renderProfile(body, header) { header.innerHTML = ` Profile `; const user = matchaState.user || {}; const avUrl = avatarUrl(user.id); body.innerHTML = `
${avUrl ? `` : `
`}
${escapeHtml(user.handle || 'Loading...')}
`; header.querySelector('#matchaProfileBack').addEventListener('click', () => { matchaState.appTab = 'friends'; renderCurrentView(); }); // Copy body.querySelectorAll('.matcha-copy-btn').forEach(btn => { btn.addEventListener('click', () => { navigator.clipboard.writeText(btn.dataset.copy); const icon = btn.querySelector('i'); icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }); }); // Avatars body.querySelector('#matchaUploadHytaleAvatar')?.addEventListener('click', async function() { this.disabled = true; this.innerHTML = ' Uploading...'; const res = await matcha.uploadAvatar('hytale'); if (res.ok) { if (matchaState.user?.id) avatarDeletedUsers.delete(String(matchaState.user.id)); avatarCacheBust = Date.now(); await refreshProfile(); renderCurrentView(); showToast('Avatar updated!'); } else if (res.error !== 'Cancelled') { this.disabled = false; this.innerHTML = ' Upload Hytale Avatar'; showToast(res.error || 'Upload failed'); } else { this.disabled = false; this.innerHTML = ' Upload Hytale Avatar'; } }); body.querySelector('#matchaUploadCustomAvatar')?.addEventListener('click', async function() { this.disabled = true; this.innerHTML = ' Uploading...'; const res = await matcha.uploadAvatar('custom'); if (res.ok) { if (matchaState.user?.id) avatarDeletedUsers.delete(String(matchaState.user.id)); avatarCacheBust = Date.now(); await refreshProfile(); renderCurrentView(); showToast('Avatar updated!'); } else if (res.error !== 'Cancelled') { this.disabled = false; this.innerHTML = ' Upload Custom Avatar'; showToast(res.error || 'Upload failed'); } else { this.disabled = false; this.innerHTML = ' Upload Custom Avatar'; } }); body.querySelector('#matchaDeleteAvatar')?.addEventListener('click', async function() { this.disabled = true; this.innerHTML = ' Deleting...'; const res = await matcha.deleteAvatar(); if (res.ok) { if (matchaState.user?.id) avatarDeletedUsers.add(String(matchaState.user.id)); avatarCacheBust = Date.now(); updateHeaderAvatar(); renderCurrentView(); showToast('Avatar deleted'); refreshProfile(); } else { this.disabled = false; this.innerHTML = ' Delete Avatar'; showToast(res.error || 'Failed'); } }); // Sign out body.querySelector('#matchaSignOut').addEventListener('click', async function() { this.disabled = true; this.innerHTML = ' Signing out...'; await matcha.logout(); stopFriendsPoll(); setUser(null); matchaState.friends = []; matchaState.incomingRequests = []; matchaState.outgoingRequests = []; matchaState.globalMessages = []; matchaState.globalCursor = null; matchaState.globalHasMore = true; matchaState.dmTarget = null; matchaState.dmMessages = []; matchaState.dmCursor = null; matchaState.dmHasMore = true; matchaState.unreadGlobal = 0; matchaState.unreadDms = {}; matchaState.replyTo = null; matchaState.appTab = 'friends'; avatarDeletedUsers.clear(); updateUnreadBadges(); updateHeaderAvatar(); setView('intro'); }); } // ============================================================================= // USER PROFILE POPUP // ============================================================================= async function openUserProfile(userId) { if (!userId || !matcha) return; // Don't show popup for self — go to own profile view instead if (matchaState.user?.id && String(userId) === String(matchaState.user.id)) { matchaState.appTab = 'profile'; renderCurrentView(); return; } const overlay = document.getElementById('matchaUserProfileOverlay'); const body = document.getElementById('matchaUserProfileBody'); if (!overlay || !body) return; body.innerHTML = '
'; overlay.style.display = 'flex'; // Close on backdrop click overlay.onclick = (e) => { if (e.target === overlay) closeUserProfile(); }; const res = await matcha.getUser(userId); if (!res.ok) { body.innerHTML = `

${escapeHtml(res.error || 'Failed to load profile')}

`; body.querySelector('#matchaProfileCloseErr')?.addEventListener('click', closeUserProfile); return; } const user = res.data?.user || res.data; const avUrl = avatarUrl(user.id); const roleBadge = user.role === 'dev' ? 'DEV' : (user.role === 'mod' ? 'MOD' : ''); const createdDate = user.createdAt ? new Date(user.createdAt).toLocaleString() : 'Unknown'; const msgCount = user.messagesSentTotal != null ? Number(user.messagesSentTotal).toLocaleString() : '0'; // Determine friend status const isFriend = matchaState.friends.some(f => String(f.id) === String(user.id)); const pendingOut = matchaState.outgoingRequests.some(r => String(r.toId || '') === String(user.id)); const pendingIn = matchaState.incomingRequests.some(r => String(r.fromId || '') === String(user.id)); const pending = pendingOut || pendingIn; let actionBtn = ''; if (isFriend) { actionBtn = ``; } else if (pending) { actionBtn = ``; } else { actionBtn = ``; } body.innerHTML = `
USER PROFILE

${escapeHtml(user.handle || 'Unknown')} ${roleBadge}

${avUrl ? `` : `
`}
CREATED ${createdDate}
TOTAL MESSAGES SENT ${msgCount}
${actionBtn}
`; body.querySelector('#matchaProfileClose')?.addEventListener('click', closeUserProfile); body.querySelector('#matchaProfileSendRequest')?.addEventListener('click', async (e) => { const btn = e.target.closest('button'); btn.disabled = true; btn.textContent = 'Sending...'; const reqRes = await matcha.friendRequest(user.handle); if (reqRes.ok) { btn.textContent = 'Request sent!'; loadFriends(); // Refresh to get updated outgoing requests } else { btn.disabled = false; btn.textContent = 'Send friend request'; showToast(reqRes.error || 'Failed to send request'); } }); } function closeUserProfile() { const overlay = document.getElementById('matchaUserProfileOverlay'); if (overlay) overlay.style.display = 'none'; } // ============================================================================= // REFRESH / DATA LOADING // ============================================================================= async function refreshAppData() { await refreshProfile(); loadFriends(); loadUnread(); startFriendsPoll(); } function startFriendsPoll() { stopFriendsPoll(); friendsPollTimer = setInterval(() => { if (matchaState.view === 'app' && matchaState.user) { loadFriends(); loadUnread(); } }, 12000); // 12s like Butter } function stopFriendsPoll() { if (friendsPollTimer) { clearInterval(friendsPollTimer); friendsPollTimer = null; } } let _refreshProfileInFlight = false; async function refreshProfile() { if (_refreshProfileInFlight) return; _refreshProfileInFlight = true; try { const res = await matcha.getMe(); if (res && res.ok) { // res.data is the API response: { ok, user: { id, handle, ... }, system: {...} } const userData = res.data?.user || res.data; mlog.log('refreshProfile user:', userData?.id, userData?.handle); if (userData?.id) setUser(userData); } else { mlog.warn('refreshProfile failed:', res?.error); } } catch (err) { mlog.error('refreshProfile exception:', err); } finally { _refreshProfileInFlight = false; } } async function loadUnread() { try { const res = await matcha.getUnread(); if (!res.ok) { mlog.warn('loadUnread failed:', res.error); return; } const data = res.data; matchaState.unreadGlobal = data.global || 0; matchaState.unreadDms = data.dm || data.dms || {}; mlog.log('Unread loaded: global=' + matchaState.unreadGlobal, 'dms=' + JSON.stringify(matchaState.unreadDms)); updateUnreadBadges(); } catch (err) { mlog.error('loadUnread exception:', err?.message || err); } } // ============================================================================= // UNREAD BADGES // ============================================================================= function updateUnreadBadges() { const msgTotal = matchaState.unreadGlobal + Object.values(matchaState.unreadDms).reduce((a, b) => a + b, 0); const reqCount = matchaState.incomingRequests.length; const total = msgTotal + reqCount; const badge = document.getElementById('matchaUnreadBadge'); if (badge) { if (total > 0) { badge.textContent = total > 99 ? '99+' : total; badge.style.display = 'flex'; // Yellow tint when friend requests pending, purple otherwise badge.className = `matcha-unread-badge${reqCount > 0 ? ' has-requests' : ''}`; } else { badge.style.display = 'none'; } } } // ============================================================================= // HEADER AVATAR // ============================================================================= function updateHeaderAvatar() { const icon = document.getElementById('matchaNavIcon'); const img = document.getElementById('matchaNavAvatar'); const dot = document.getElementById('matchaNavStatus'); if (!icon || !img) return; if (matchaState.user?.id) { const avSrc = avatarUrl(matchaState.user.id); if (avSrc) { img.src = avSrc; img.style.display = 'block'; img.onerror = () => { img.style.display = 'none'; icon.style.display = ''; }; icon.style.display = 'none'; } else { img.style.display = 'none'; icon.style.display = ''; } if (dot) { dot.style.display = ''; dot.className = `matcha-nav-status ${matchaState.wsConnected ? 'online' : 'offline'}`; } } else { img.style.display = 'none'; icon.style.display = ''; if (dot) dot.style.display = 'none'; } } // ============================================================================= // WS EVENT HANDLERS // ============================================================================= function setupWsListeners() { if (!matcha) return; matcha.onWsConnected(async (data) => { mlog.log('WS connected, user:', data?.user?.handle); matchaState.wsConnected = true; if (data?.user) setUser(data.user); updateHeaderAvatar(); const banner = document.getElementById('matchaReconnectBanner'); if (banner) banner.style.display = 'none'; // Also fetch full profile for complete data await refreshProfile(); loadFriends(); loadUnread(); startFriendsPoll(); }); matcha.onWsDisconnected(() => { mlog.log('WS disconnected'); matchaState.wsConnected = false; stopFriendsPoll(); updateHeaderAvatar(); if (matchaState.panelOpen && matchaState.view === 'app') { showReconnectBanner(); } }); matcha.onWsMessage((data) => { // The WS data has type:"message" + message fields at top level const msg = data.message || data; const isOwnMessage = !!msg.fromId && String(msg.fromId) === String(matchaState.user?.id || ''); const isGlobal = msg.toId === 'global' || msg.to === 'global'; mlog.log('WS msg:', msg.fromHandle, '->', msg.toId || msg.to, '| own:', isOwnMessage, '| fromId:', msg.fromId, '| userId:', matchaState.user?.id); if (isGlobal) { // Skip if own message (already rendered optimistically) if (isOwnMessage) { // Replace optimistic message ID with real one in state const optIdx = matchaState.globalMessages.findIndex(m => m._optimistic && m.body === msg.body); if (optIdx !== -1) { const oldId = matchaState.globalMessages[optIdx].id; matchaState.globalMessages[optIdx] = msg; // Update DOM element's data-msg-id so reply/delete work const optEl = document.querySelector(`[data-msg-id="${oldId}"]`) || document.querySelector('.matcha-message.own:last-child'); if (optEl) optEl.setAttribute('data-msg-id', msg.id); } return; } // Check for exact duplicate if (matchaState.globalMessages.some(m => m.id === msg.id)) return; matchaState.globalMessages.push(msg); if (matchaState.panelOpen && matchaState.appTab === 'globalChat') { appendMessageToList('matchaGlobalMessages', msg); matcha.clearUnread('global').catch(() => {}); } else { matchaState.unreadGlobal++; updateUnreadBadges(); } } else { // DM const otherId = isOwnMessage ? msg.toId : msg.fromId; // Skip own messages (already rendered optimistically) if (isOwnMessage) { const optIdx = matchaState.dmMessages.findIndex(m => m._optimistic && m.body === msg.body); if (optIdx !== -1) { const oldId = matchaState.dmMessages[optIdx].id; matchaState.dmMessages[optIdx] = msg; const optEl = document.querySelector(`[data-msg-id="${oldId}"]`); if (optEl) optEl.setAttribute('data-msg-id', msg.id); } return; } // Check for exact duplicate if (matchaState.dmMessages.some(m => m.id === msg.id)) return; if (matchaState.dmTarget && matchaState.dmTarget.id === otherId) { matchaState.dmMessages.push(msg); appendMessageToList('matchaDmMessages', msg); matcha.clearUnread(otherId).catch(() => {}); } else { matchaState.unreadDms[otherId] = (matchaState.unreadDms[otherId] || 0) + 1; updateUnreadBadges(); } } }); matcha.onMessageDeleted((data) => { const msgId = data.messageId || data.id; const el = document.querySelector(`.matcha-message[data-msg-id="${msgId}"]`); if (el) { el.classList.add('deleted'); const bodyEl = el.querySelector('.matcha-msg-body'); if (bodyEl) bodyEl.innerHTML = 'Message deleted'; const actionsEl = el.querySelector('.matcha-msg-actions'); if (actionsEl) actionsEl.remove(); } }); matcha.onAvatarUpdated((data) => { // Clear deleted flag if avatar was updated (by us or someone else) if (data?.userId) avatarDeletedUsers.delete(String(data.userId)); avatarCacheBust = Date.now(); updateHeaderAvatar(); if (matchaState.panelOpen) { if (matchaState.appTab === 'friends') loadFriends(); if (matchaState.appTab === 'profile') renderCurrentView(); } }); matcha.onBanned((data) => { stopFriendsPoll(); setUser(null); matchaState.friends = []; matchaState.incomingRequests = []; matchaState.outgoingRequests = []; matchaState.globalMessages = []; matchaState.globalCursor = null; matchaState.globalHasMore = true; matchaState.dmTarget = null; matchaState.dmMessages = []; matchaState.dmCursor = null; matchaState.dmHasMore = true; matchaState.unreadGlobal = 0; matchaState.unreadDms = {}; matchaState.replyTo = null; matchaState.appTab = 'friends'; avatarDeletedUsers.clear(); matchaState.view = 'intro'; updateUnreadBadges(); updateHeaderAvatar(); const reason = data?.reason || 'Your account has been banned.'; showToast(reason); renderCurrentView(); }); matcha.onAnnouncement((data) => { const msg = data.message || data; if (matchaState.panelOpen && matchaState.appTab === 'globalChat') { const container = document.getElementById('matchaGlobalMessages'); if (container) { const el = document.createElement('div'); el.className = 'matcha-announcement'; el.textContent = typeof msg === 'string' ? msg : (msg.body || 'Announcement'); container.appendChild(el); scrollToBottom('matchaGlobalMessages'); } } }); matcha.onMaxRetries(() => { showReconnectBanner(true); }); } function showReconnectBanner(showRetry) { let banner = document.getElementById('matchaReconnectBanner'); if (!banner) { banner = document.createElement('div'); banner.id = 'matchaReconnectBanner'; banner.className = 'matcha-reconnect-banner'; const panelBody = document.getElementById('matchaPanelBody'); if (panelBody) panelBody.prepend(banner); } banner.style.display = 'flex'; banner.innerHTML = showRetry ? `Connection lost. ` : `Reconnecting...`; if (showRetry) { banner.querySelector('#matchaRetryBtn')?.addEventListener('click', () => { matcha.reconnect(); banner.innerHTML = 'Reconnecting...'; }); } } function appendMessageToList(containerId, msg) { const container = document.getElementById(containerId); if (!container) return; // Remove empty state const empty = container.querySelector('.matcha-empty'); if (empty) empty.remove(); const el = document.createElement('div'); el.innerHTML = renderMessage(msg); const msgEl = el.firstElementChild; container.appendChild(msgEl); // Only attach listeners to the new message element, not the whole container attachMessageListeners(msgEl, containerId.includes('Dm') ? 'dm' : 'global'); // Auto-scroll if near bottom const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; if (isNearBottom) scrollToBottom(containerId); } // ============================================================================= // UTILITIES // ============================================================================= function sortMessagesOldestFirst(messages) { return [...messages].sort((a, b) => { const dateA = new Date(a.createdAt || 0).getTime(); const dateB = new Date(b.createdAt || 0).getTime(); return dateA - dateB; }); } function avatarUrl(userId) { if (!userId) return ''; if (avatarDeletedUsers.has(String(userId))) return ''; return `https://butter.lat/api/matcha/avatar/${userId}?v=${avatarCacheBust}`; } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function linkify(rawText) { // Find URLs in raw text, escape parts separately so & in URLs isn't broken const urlRegex = /https?:\/\/[^\s<]+/gi; let result = ''; let lastIdx = 0; let match; while ((match = urlRegex.exec(rawText)) !== null) { // Escape text before the URL result += escapeHtml(rawText.slice(lastIdx, match.index)); const url = match[0].replace(/[.,;:!?)]+$/, ''); // trim trailing punctuation urlRegex.lastIndex = match.index + url.length; // adjust position after trim result += `${escapeHtml(url)}`; lastIdx = match.index + url.length; } result += escapeHtml(rawText.slice(lastIdx)); return result; } function escapeAttr(str) { return (str || '').replace(/&/g, '&').replace(/"/g, '"') .replace(/'/g, ''').replace(//g, '>'); } function formatTime(dateStr) { if (!dateStr) return ''; const date = new Date(dateStr); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); if (diffMins < 1) return 'now'; if (diffMins < 60) return `${diffMins}m ago`; const diffHours = Math.floor(diffMins / 60); if (diffHours < 24) return `${diffHours}h ago`; return date.toLocaleDateString(); } function scrollToBottom(containerId) { const el = document.getElementById(containerId); if (el) el.scrollTop = el.scrollHeight; } function showToast(message) { let toast = document.getElementById('matchaToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'matchaToast'; toast.className = 'matcha-toast'; document.body.appendChild(toast); } toast.textContent = message; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 3000); } // ============================================================================= // KEYBOARD SHORTCUTS // ============================================================================= document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const profileOverlay = document.getElementById('matchaUserProfileOverlay'); if (profileOverlay && profileOverlay.style.display !== 'none') { closeUserProfile(); return; } if (matchaState.panelOpen) closeMatchaPanel(); } }); // ============================================================================= // INITIALIZATION // ============================================================================= async function initMatcha() { mlog.log('Initializing...'); // Wire up sidebar button first (before any async calls) const navItem = document.getElementById('matchaNavItem'); if (navItem) { navItem.addEventListener('click', (e) => { e.stopPropagation(); toggleMatchaPanel(); }); mlog.log('Nav item listener attached'); } else { mlog.warn('matchaNavItem not found in DOM'); } // Wire up panel close button and backdrop const panelCloseBtn = document.getElementById('matchaPanelCloseBtn'); if (panelCloseBtn) panelCloseBtn.addEventListener('click', closeMatchaPanel); const backdrop = document.getElementById('matchaPanelBackdrop'); if (backdrop) backdrop.addEventListener('click', closeMatchaPanel); if (!matcha) { mlog.warn('API not available, skipping init'); return; } setupWsListeners(); mlog.log('WS listeners set up'); // Check auth state on startup try { const state = await matcha.getAuthState(); mlog.log('Auth state:', state?.authenticated ? 'authenticated' : 'not authenticated', 'wsConnected:', state?.wsConnected); if (state?.authenticated) { matchaState.view = 'app'; matchaState.wsConnected = state.wsConnected; if (state.user) setUser(state.user); refreshProfile(); loadFriends(); loadUnread(); startFriendsPoll(); } } catch (err) { mlog.error('getAuthState failed:', err?.message || err); } mlog.log('Init complete'); } // Export for script.js export { initMatcha };