diff --git a/GUI/index.html b/GUI/index.html index 9d54385..e4cd378 100644 --- a/GUI/index.html +++ b/GUI/index.html @@ -24,7 +24,7 @@ height="100%25" filter="url(%23noiseFilter)" opacity="0.1" /%3E%3C/svg%3E')] opacity-20"> -
+
+ +
+
+
+
+ Matcha! +
+ +
+
+
+ + + +
@@ -969,6 +995,12 @@ @xSamiVS + | + Social powered by Butter Launcher & + Matcha! +
diff --git a/GUI/js/matcha.js b/GUI/js/matcha.js new file mode 100644 index 0000000..0d72b9b --- /dev/null +++ b/GUI/js/matcha.js @@ -0,0 +1,1919 @@ +// ============================================================================= +// 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 }; diff --git a/GUI/js/script.js b/GUI/js/script.js index 7a846b6..06eece2 100644 --- a/GUI/js/script.js +++ b/GUI/js/script.js @@ -5,6 +5,7 @@ import { initModsManager } from './mods.js'; import './players.js'; import './settings.js'; import './logs.js'; +import { initMatcha } from './matcha.js'; let i18nInitialized = false; (async () => { @@ -15,10 +16,12 @@ let i18nInitialized = false; if (document.readyState === 'complete' || document.readyState === 'interactive') { updateLanguageSelector(); initModsManager(); + initMatcha(); } else { document.addEventListener('DOMContentLoaded', () => { updateLanguageSelector(); initModsManager(); + initMatcha(); }); } })(); diff --git a/GUI/style.css b/GUI/style.css index c75a220..34c89a4 100644 --- a/GUI/style.css +++ b/GUI/style.css @@ -1,3 +1,9 @@ +html, body { + overflow: hidden; + height: 100%; + margin: 0; +} + body { font-family: 'Space Grotesk', sans-serif; } @@ -7060,4 +7066,1530 @@ input[type="text"].uuid-input, .loading-versions { padding: 3rem; text-align: center; +} + +/* ============================================================================= + MATCHA SOCIAL PANEL + ============================================================================= */ + +/* Panel backdrop */ +.matcha-panel-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 9997; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + -webkit-backface-visibility: hidden; +} + +.matcha-panel-backdrop.active { + opacity: 1; + pointer-events: auto; +} + +/* Panel container */ +.matcha-panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + width: min(380px, 100vw); + background: rgb(10, 10, 15); + border-left: 1px solid rgba(147, 51, 234, 0.2); + z-index: 9998; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + will-change: transform; + -webkit-backface-visibility: hidden; + isolation: isolate; +} + +.matcha-panel.open { + transform: translateX(0); +} + +@media (prefers-reduced-motion: reduce) { + .matcha-panel { + transition: none; + } + .matcha-panel-backdrop { + transition: none; + } +} + +/* Panel header */ +.matcha-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + min-height: 56px; + -webkit-app-region: no-drag; +} + +.matcha-panel-header-content { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.matcha-header-title { + font-size: 1rem; + font-weight: 600; + color: white; + white-space: nowrap; +} + +.matcha-panel-close { + background: none; + border: none; + color: #9ca3af; + font-size: 1.1rem; + cursor: pointer; + padding: 0.25rem; + border-radius: 6px; + transition: all 0.2s; + flex-shrink: 0; +} + +.matcha-panel-close:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +/* Panel body */ +.matcha-panel-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; +} + +/* Back button */ +.matcha-back-btn { + background: none; + border: none; + color: #9ca3af; + font-size: 1rem; + cursor: pointer; + padding: 0.25rem; + border-radius: 6px; + transition: all 0.2s; +} + +.matcha-back-btn:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +/* ============================================================================= + INTRO SCREEN + ============================================================================= */ + +.matcha-intro { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 2rem; + text-align: center; + flex: 1; +} + +.matcha-intro-icon { + width: 64px; + height: 64px; + border-radius: 16px; + background: linear-gradient(135deg, #9333ea, #3b82f6); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + box-shadow: 0 0 30px rgba(147, 51, 234, 0.4); +} + +.matcha-intro-icon i { + font-size: 1.75rem; + color: white; +} + +.matcha-intro-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.matcha-intro-desc { + color: #9ca3af; + font-size: 0.875rem; + margin-bottom: 2rem; +} + +.matcha-intro-features { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 2.5rem; + width: 100%; + max-width: 250px; +} + +.matcha-feature { + display: flex; + align-items: center; + gap: 0.75rem; + color: #d1d5db; + font-size: 0.875rem; +} + +.matcha-feature i { + color: #9333ea; + width: 20px; + text-align: center; +} + +.matcha-intro-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 250px; +} + +.matcha-intro-powered { + color: #6b7280; + font-size: 0.7rem; + margin-top: 2rem; +} + +.matcha-powered-link { + color: #818cf8; + text-decoration: none; + cursor: pointer; +} + +.matcha-powered-link:hover { + color: #a78bfa; + text-decoration: underline; +} + +/* ============================================================================= + BUTTONS + ============================================================================= */ + +.matcha-btn { + padding: 0.625rem 1.25rem; + border-radius: 8px; + border: none; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; +} + +.matcha-btn.primary { + background: linear-gradient(135deg, #9333ea, #3b82f6); + color: white; + box-shadow: 0 2px 8px rgba(147, 51, 234, 0.3); +} + +.matcha-btn.primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(147, 51, 234, 0.4); +} + +.matcha-btn.primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.matcha-btn.ghost { + background: rgba(255, 255, 255, 0.06); + color: #d1d5db; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.matcha-btn.ghost:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.matcha-btn.ghost.danger { + color: #f87171; + border-color: rgba(248, 113, 113, 0.2); +} + +.matcha-btn.ghost.danger:hover { + background: rgba(248, 113, 113, 0.1); +} + +.matcha-btn.full { + width: 100%; +} + +.matcha-icon-btn { + background: none; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 0.375rem; + border-radius: 6px; + transition: all 0.2s; + font-size: 0.875rem; +} + +.matcha-icon-btn:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +.matcha-icon-btn.primary { + color: white; + background: linear-gradient(135deg, #9333ea, #3b82f6); +} + +.matcha-icon-btn.primary:hover { + box-shadow: 0 2px 8px rgba(147, 51, 234, 0.4); +} + +.matcha-icon-btn.primary:disabled { + opacity: 0.5; +} + +.matcha-icon-btn.success { + color: #22c55e; +} + +.matcha-icon-btn.success:hover { + background: rgba(34, 197, 94, 0.15); +} + +.matcha-icon-btn.danger { + color: #f87171; +} + +.matcha-icon-btn.danger:hover { + background: rgba(248, 113, 113, 0.15); +} + +.matcha-icon-btn.muted:hover { + background: rgba(255, 255, 255, 0.08); +} + +.matcha-icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.matcha-btn.ghost:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* ============================================================================= + FORM ELEMENTS + ============================================================================= */ + +.matcha-auth-form { + padding: 2rem 1.5rem; +} + +.matcha-form-group { + margin-bottom: 1rem; +} + +.matcha-form-group label { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: #9ca3af; + margin-bottom: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.matcha-input { + width: 100%; + padding: 0.625rem 0.875rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: white; + font-size: 0.875rem; + font-family: inherit; + outline: none; + transition: all 0.2s; + box-sizing: border-box; +} + +.matcha-input:focus { + border-color: #9333ea; + box-shadow: 0 0 0 2px rgba(147, 51, 234, 0.2); +} + +.matcha-input::placeholder { + color: #4b5563; +} + +.matcha-error { + color: #f87171; + font-size: 0.8rem; + min-height: 1.25rem; + margin-bottom: 0.75rem; +} + +.matcha-auth-link { + text-align: center; + color: #9ca3af; + font-size: 0.8rem; + margin-top: 1rem; +} + +.matcha-auth-link a { + color: #9333ea; + text-decoration: none; +} + +.matcha-auth-link a:hover { + text-decoration: underline; +} + +/* ============================================================================= + KEY DISPLAY / VERIFY + ============================================================================= */ + +.matcha-key-display { + padding: 2rem 1.5rem; +} + +.matcha-key-warning { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.25); + border-radius: 8px; + color: #fbbf24; + font-size: 0.8rem; + margin-bottom: 1.5rem; +} + +.matcha-key-warning i { + font-size: 1rem; + flex-shrink: 0; +} + +.matcha-key-field { + margin-bottom: 1.25rem; +} + +.matcha-key-field label { + display: block; + font-size: 0.7rem; + font-weight: 500; + color: #9ca3af; + margin-bottom: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.matcha-key-value { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; +} + +.matcha-key-value code { + flex: 1; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + color: #d1d5db; + word-break: break-all; +} + +.matcha-master-key { + font-size: 0.7rem !important; +} + +.matcha-copy-btn { + background: none; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + transition: all 0.2s; + flex-shrink: 0; +} + +.matcha-copy-btn:hover { + color: #9333ea; +} + +.matcha-key-note { + color: #6b7280; + font-size: 0.75rem; + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.matcha-verify-desc { + color: #9ca3af; + font-size: 0.85rem; + margin-bottom: 1.5rem; +} + +/* ============================================================================= + USER BAR + ============================================================================= */ + +.matcha-user-bar { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + flex: 1; + min-width: 0; +} + +.matcha-user-avatar-small { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.matcha-user-avatar-small img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.matcha-user-avatar-small i { + font-size: 0.75rem; + color: #9ca3af; +} + +.matcha-user-handle { + font-size: 0.85rem; + font-weight: 500; + color: #d1d5db; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ============================================================================= + TAB BAR + ============================================================================= */ + +.matcha-tab-bar { + display: flex; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 0 1rem; + flex-shrink: 0; +} + +.matcha-tab { + flex: 1; + padding: 0.75rem 0.5rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: #9ca3af; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + text-align: center; + position: relative; +} + +.matcha-tab:hover { + color: #d1d5db; +} + +.matcha-tab.active { + color: white; + border-bottom-color: #9333ea; +} + +.matcha-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: #ef4444; + color: white; + border-radius: 8px; + font-size: 0.6rem; + font-weight: 700; + margin-left: 4px; + vertical-align: super; +} + +/* ============================================================================= + FRIENDS VIEW + ============================================================================= */ + +.matcha-friends-view { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.matcha-add-friend { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.matcha-add-friend .matcha-input { + flex: 1; +} + +.matcha-requests-section { + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding-bottom: 0.5rem; + margin-bottom: 0.25rem; +} + +.matcha-requests-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + color: rgba(255, 255, 255, 0.7); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + user-select: none; + transition: color 0.15s, background 0.15s; + border-radius: 0.5rem; + margin: 0 0.25rem; +} + +.matcha-requests-toggle.has-incoming { + color: #fbbf24; + background: rgba(251, 191, 36, 0.08); + border: 1px solid rgba(251, 191, 36, 0.2); + animation: matcha-pulse 2s ease-in-out infinite; +} + +@keyframes matcha-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.matcha-requests-toggle:hover { + color: white; +} + +.matcha-requests-toggle i { + font-size: 0.65rem; + width: 12px; + text-align: center; + transition: transform 0.2s; +} + +.matcha-requests-badge { + margin-left: auto; + background: #9333ea; + color: white; + font-size: 0.65rem; + padding: 0.1rem 0.4rem; + border-radius: 99px; + font-weight: 700; +} + +.matcha-section-subheader { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.4); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.matcha-section-header { + font-size: 0.65rem; + font-weight: 600; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0.75rem 1rem 0.375rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.matcha-section-header i { + font-size: 0.7rem; + color: #6b7280; +} + +.matcha-request-actions { + display: flex; + gap: 0.375rem; + margin-left: auto; +} + +.matcha-friend-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + transition: background 0.15s; + cursor: pointer; +} + +.matcha-friend-row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.matcha-friend-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; +} + +.matcha-friend-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.matcha-friend-avatar i { + font-size: 0.8rem; + color: #6b7280; +} + +.matcha-presence-dot { + position: absolute; + bottom: -1px; + right: -1px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid rgba(10, 10, 15, 0.97); +} + +.matcha-presence-dot.online { + background: #22c55e; +} + +.matcha-presence-dot.ingame { + background: #3b82f6; +} + +.matcha-presence-dot.offline { + background: #4b5563; +} + +.matcha-friend-info { + flex: 1; + min-width: 0; +} + +.matcha-friend-name { + font-size: 0.85rem; + color: #d1d5db; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.matcha-friend-status { + font-size: 0.7rem; + color: #6b7280; +} + +.matcha-friend-actions { + display: flex; + gap: 0.125rem; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; +} + +.matcha-friend-row:hover .matcha-friend-actions { + opacity: 1; +} + +.matcha-unread-dot { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: #ef4444; + color: white; + border-radius: 9px; + font-size: 0.65rem; + font-weight: 700; + flex-shrink: 0; +} + +/* ============================================================================= + CHAT VIEW + ============================================================================= */ + +.matcha-chat-view { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.matcha-messages { + flex: 1; + overflow-y: auto; + padding: 0.75rem 0; + contain: layout style; +} + +.matcha-chat-input { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.matcha-chat-input .matcha-input { + flex: 1; +} + +/* Reply bar */ +.matcha-reply-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(147, 51, 234, 0.08); + border-top: 1px solid rgba(147, 51, 234, 0.15); + font-size: 0.75rem; + color: #9ca3af; + flex-shrink: 0; +} + +.matcha-reply-bar span { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Messages */ +.matcha-message { + padding: 0.25rem 1rem; + transition: background 0.15s; +} + +.matcha-message:hover { + background: rgba(255, 255, 255, 0.02); +} + +.matcha-message.own { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.matcha-message.own .matcha-message-row { + flex-direction: row; + justify-content: flex-end; +} + +.matcha-message.own .matcha-msg-content { + background: rgba(59, 130, 246, 0.2); + border: 1px solid rgba(59, 130, 246, 0.15); + border-radius: 12px 12px 4px 12px; + padding: 0.375rem 0.75rem; +} + +.matcha-message.own .matcha-reply-preview { + margin-left: 0; + margin-right: 2.375rem; + text-align: right; +} + +.matcha-message:not(.own) .matcha-msg-content { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px 12px 12px 4px; + padding: 0.375rem 0.75rem; +} + +.own-header { + justify-content: flex-end; +} + +.matcha-message.deleted .matcha-msg-body { + color: #4b5563; + font-style: italic; +} + +.matcha-message-row { + display: flex; + gap: 0.625rem; + align-items: flex-start; +} + +.matcha-msg-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + margin-top: 2px; +} + +.matcha-msg-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.matcha-msg-avatar i { + font-size: 0.7rem; + color: #6b7280; +} + +.matcha-msg-content { + flex: 1; + min-width: 0; +} + +.matcha-msg-header { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.125rem; +} + +.matcha-msg-author { + font-size: 0.8rem; + font-weight: 600; + color: #d1d5db; +} + +.matcha-msg-time { + font-size: 0.65rem; + color: #6b7280; +} + +.matcha-msg-body { + font-size: 0.85rem; + color: #e5e7eb; + line-height: 1.4; + word-break: break-word; +} + +.matcha-link { + color: #818cf8; + text-decoration: none; + cursor: pointer; +} + +.matcha-link:hover { + color: #a78bfa; + text-decoration: underline; +} + +.matcha-msg-actions { + display: flex; + gap: 0.125rem; + opacity: 0; + transition: opacity 0.15s; + flex-shrink: 0; +} + +.matcha-message:hover .matcha-msg-actions { + opacity: 1; +} + +.matcha-msg-action { + background: none; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + font-size: 0.7rem; + transition: all 0.15s; +} + +.matcha-msg-action:hover { + color: #d1d5db; + background: rgba(255, 255, 255, 0.08); +} + +.matcha-msg-action.danger:hover { + color: #f87171; + background: rgba(248, 113, 113, 0.1); +} + +.matcha-reply-preview { + font-size: 0.7rem; + color: #6b7280; + padding: 0.25rem 0.5rem; + margin-bottom: 0.25rem; + margin-left: 2.375rem; + border-left: 2px solid rgba(147, 51, 234, 0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.matcha-reply-preview i { + margin-right: 0.25rem; +} + +.matcha-dev-badge { + font-size: 0.55rem; + font-weight: 700; + color: #9333ea; + background: rgba(147, 51, 234, 0.15); + padding: 0.1rem 0.3rem; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.matcha-announcement { + padding: 0.625rem 1rem; + margin: 0.25rem 1rem; + background: rgba(147, 51, 234, 0.1); + border: 1px solid rgba(147, 51, 234, 0.2); + border-radius: 8px; + color: #d1d5db; + font-size: 0.8rem; + text-align: center; +} + +/* ============================================================================= + PROFILE VIEW + ============================================================================= */ + +.matcha-profile-view { + padding: 2rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; +} + +.matcha-profile-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + margin-bottom: 1.5rem; + border: 2px solid rgba(147, 51, 234, 0.3); +} + +.matcha-profile-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.matcha-avatar-placeholder { + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; +} + +.matcha-avatar-placeholder i { + font-size: 2rem; + color: #4b5563; +} + +.matcha-profile-info { + width: 100%; + margin-bottom: 1.5rem; +} + +.matcha-profile-field { + margin-bottom: 0.75rem; +} + +.matcha-profile-field label { + display: block; + font-size: 0.65rem; + font-weight: 500; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.25rem; +} + +.matcha-profile-value { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: #d1d5db; +} + +.matcha-profile-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + margin-bottom: 1rem; +} + +.matcha-profile-divider { + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 1rem 0; +} + +/* ============================================================================= + DM HEADER + ============================================================================= */ + +.matcha-dm-header-info { + display: flex; + align-items: center; + gap: 0.625rem; + flex: 1; + min-width: 0; +} + +.matcha-dm-header-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; +} + +.matcha-dm-header-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.matcha-dm-header-avatar i { + font-size: 0.75rem; + color: #6b7280; +} + +.matcha-dm-header-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.matcha-dm-header-status { + font-size: 0.65rem; + color: #6b7280; +} + +/* ============================================================================= + LOADING / EMPTY / RECONNECT / TOAST + ============================================================================= */ + +.matcha-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + color: #6b7280; + font-size: 0.85rem; +} + +.matcha-error-text { + color: #f87171; + font-size: 0.85rem; + text-align: center; + padding: 2rem 1rem; +} + +.matcha-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + color: #6b7280; + text-align: center; + gap: 0.75rem; +} + +.matcha-empty i { + font-size: 2rem; + color: #374151; +} + +.matcha-empty p { + font-size: 0.85rem; +} + +.matcha-reconnect-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: rgba(245, 158, 11, 0.1); + border-bottom: 1px solid rgba(245, 158, 11, 0.2); + color: #fbbf24; + font-size: 0.8rem; + flex-shrink: 0; +} + +.matcha-retry-btn { + background: rgba(245, 158, 11, 0.2); + border: 1px solid rgba(245, 158, 11, 0.3); + color: #fbbf24; + padding: 0.25rem 0.75rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 500; + transition: all 0.2s; +} + +.matcha-retry-btn:hover { + background: rgba(245, 158, 11, 0.3); +} + +.matcha-toast { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: rgba(10, 10, 15, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.625rem 1.25rem; + border-radius: 8px; + color: #d1d5db; + font-size: 0.8rem; + z-index: 99999; + opacity: 0; + transition: all 0.3s ease; + pointer-events: none; + backdrop-filter: blur(20px); +} + +.matcha-toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* ============================================================================= + UNREAD BADGE (sidebar) + ============================================================================= */ + +.matcha-unread-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: #ef4444; + color: white; + border-radius: 8px; + font-size: 0.6rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); +} + +.matcha-unread-badge.has-requests { + background: #f59e0b; + box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); + animation: matcha-pulse 2s ease-in-out infinite; +} + +/* ============================================================================= + SIDEBAR NAV AVATAR (replaces icon when logged in) + ============================================================================= */ + +.matcha-nav-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(147, 51, 234, 0.4); + transition: border-color 0.2s; +} + +#matchaNavItem:hover .matcha-nav-avatar { + border-color: rgba(147, 51, 234, 0.8); +} + +.matcha-nav-status { + width: 10px; + height: 10px; + border-radius: 50%; + position: absolute; + bottom: 8px; + right: 14px; + border: 2px solid rgba(0, 0, 0, 0.5); +} + +.matcha-nav-status.online { + background: #22c55e; +} + +.matcha-nav-status.offline { + background: #f59e0b; +} + +/* ============================================================================= + CLICKABLE USERS IN CHAT + ============================================================================= */ + +.matcha-clickable-user { + cursor: pointer; +} + +.matcha-msg-avatar.matcha-clickable-user:hover img { + filter: brightness(1.2); +} + +span.matcha-msg-author.matcha-clickable-user { + text-decoration: underline; + text-decoration-color: rgba(255, 255, 255, 0.3); + text-underline-offset: 2px; +} + +span.matcha-msg-author.matcha-clickable-user:hover { + text-decoration-color: rgba(147, 51, 234, 0.8); + color: #c084fc; +} + +/* ============================================================================= + USER PROFILE POPUP + ============================================================================= */ + +.matcha-user-profile-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.matcha-user-profile-card { + width: 100%; + max-width: 420px; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(15, 15, 20, 0.95); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); + animation: matchaPopIn 0.2s ease-out; +} + +@keyframes matchaPopIn { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.matcha-user-profile-body { + padding: 1.5rem; +} + +.matcha-up-header { + margin-bottom: 1.25rem; +} + +.matcha-up-label { + display: block; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.1em; + color: #9ca3af; + margin-bottom: 0.25rem; +} + +.matcha-up-handle { + font-size: 1.4rem; + font-weight: 700; + color: white; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.matcha-up-main { + display: flex; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.matcha-up-avatar { + width: 92px; + height: 92px; + border-radius: 12px; + overflow: hidden; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); +} + +.matcha-up-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.matcha-up-avatar .matcha-avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #6b7280; + font-size: 2rem; +} + +.matcha-up-stats { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.matcha-up-stat { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 0.5rem 0.75rem; +} + +.matcha-up-stat-label { + display: block; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.08em; + color: #6b7280; + margin-bottom: 0.15rem; +} + +.matcha-up-stat-value { + display: block; + font-size: 0.95rem; + font-weight: 600; + color: white; +} + +.matcha-up-actions { + display: flex; + gap: 0.75rem; + padding-top: 0.25rem; +} + +.matcha-up-actions .matcha-btn { + flex: 1; +} + +.matcha-up-actions .matcha-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.matcha-mod-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.65rem; + font-weight: 700; + background: #0ea5e9; + color: white; + letter-spacing: 0.05em; +} + +/* ============================================================================= + SCROLLBAR (panel) + ============================================================================= */ + +.matcha-panel-body::-webkit-scrollbar, +.matcha-messages::-webkit-scrollbar, +.matcha-friends-view::-webkit-scrollbar { + width: 4px; +} + +.matcha-panel-body::-webkit-scrollbar-track, +.matcha-messages::-webkit-scrollbar-track, +.matcha-friends-view::-webkit-scrollbar-track { + background: transparent; +} + +.matcha-panel-body::-webkit-scrollbar-thumb, +.matcha-messages::-webkit-scrollbar-thumb, +.matcha-friends-view::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; +} + +.matcha-panel-body::-webkit-scrollbar-thumb:hover, +.matcha-messages::-webkit-scrollbar-thumb:hover, +.matcha-friends-view::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); } \ No newline at end of file diff --git a/backend/core/config.js b/backend/core/config.js index 45ecc33..9b522f4 100644 --- a/backend/core/config.js +++ b/backend/core/config.js @@ -1087,6 +1087,48 @@ function _generateWindowsWrapper(stripFlags, alwaysArgs, serverArgs) { return lines.join('\r\n'); } +// ============================================================================= +// MATCHA SOCIAL AUTH +// ============================================================================= + +function saveMatchaToken(token) { + saveConfig({ matchaToken: token || null }); +} + +function loadMatchaToken() { + const config = loadConfig(); + return config.matchaToken || null; +} + +function saveMatchaHandle(handle) { + saveConfig({ matchaHandle: handle || null }); +} + +function loadMatchaHandle() { + const config = loadConfig(); + return config.matchaHandle || null; +} + +function saveMatchaUserId(id) { + saveConfig({ matchaUserId: id || null }); +} + +function loadMatchaUserId() { + const config = loadConfig(); + return config.matchaUserId || null; +} + +function clearMatchaAuth() { + const config = loadConfig(); + delete config.matchaToken; + delete config.matchaUserId; + delete config.matchaHandle; + const data = JSON.stringify(config, null, 2); + fs.writeFileSync(CONFIG_TEMP, data, 'utf8'); + if (fs.existsSync(CONFIG_FILE)) fs.copyFileSync(CONFIG_FILE, CONFIG_BACKUP); + fs.renameSync(CONFIG_TEMP, CONFIG_FILE); +} + // ============================================================================= // EXPORTS // ============================================================================= @@ -1162,6 +1204,15 @@ module.exports = { resetWrapperConfig, generateWrapperScript, + // Matcha Social + saveMatchaToken, + loadMatchaToken, + saveMatchaHandle, + loadMatchaHandle, + saveMatchaUserId, + loadMatchaUserId, + clearMatchaAuth, + // Constants CONFIG_FILE, UUID_STORE_FILE diff --git a/backend/services/matchaService.js b/backend/services/matchaService.js new file mode 100644 index 0000000..8553ad8 --- /dev/null +++ b/backend/services/matchaService.js @@ -0,0 +1,529 @@ +const axios = require('axios'); +const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); +const { loadConfig, saveConfig } = require('../core/config'); + +const MATCHA_BASE = 'https://butter.lat'; +const MATCHA_API = `${MATCHA_BASE}/api/matcha`; +const MATCHA_WS = 'wss://butter.lat/api/matcha/ws'; + +class MatchaService { + constructor() { + this.token = null; + this.user = null; + this.ws = null; + this.wsConnected = false; + this.mainWindow = null; + this.heartbeatInterval = null; + this.reconnectTimeout = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.lastMessageSentAt = 0; + this.gameRunning = false; + } + + // ========================================================================= + // LIFECYCLE + // ========================================================================= + + init(mainWindow) { + this.mainWindow = mainWindow; + this.token = this._loadToken(); + if (this.token) { + this._connectWs(); + this._startHeartbeat(); + // Load cached user info + const config = loadConfig(); + if (config.matchaUserId && config.matchaHandle) { + this.user = { id: config.matchaUserId, handle: config.matchaHandle }; + } + } + } + + destroy() { + // Best-effort offline heartbeat + if (this.token) { + this.sendHeartbeat('offline').catch(() => {}); + } + this._stopHeartbeat(); + this._disconnectWs(); + this.mainWindow = null; + } + + // ========================================================================= + // TOKEN MANAGEMENT + // ========================================================================= + + _loadToken() { + const config = loadConfig(); + return config.matchaToken || null; + } + + _saveToken(token) { + this.token = token; + saveConfig({ matchaToken: token }); + } + + _saveUser(user) { + this.user = user; + if (user) { + saveConfig({ matchaUserId: user.id, matchaHandle: user.handle }); + } + } + + _clearAuth() { + this.token = null; + this.user = null; + this._stopHeartbeat(); + this._disconnectWs(); + // Set to null — saveConfig merges, and JSON.stringify preserves null values, + // but this effectively marks them as cleared for _loadToken() checks + saveConfig({ matchaToken: null, matchaUserId: null, matchaHandle: null }); + } + + _authHeaders() { + return this.token ? { Authorization: `Bearer ${this.token}` } : {}; + } + + // ========================================================================= + // AUTH + // ========================================================================= + + async register(username, password, password2) { + try { + const res = await axios.post(`${MATCHA_API}/register`, { + username, password, password2, deferCreate: true + }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async confirmRegistration(pendingId, proofId) { + try { + const res = await axios.post(`${MATCHA_API}/register/confirm`, { + pendingId, proofId + }); + if (res.data.token) { + this._saveToken(res.data.token); + if (res.data.user) this._saveUser(res.data.user); + this._connectWs(); + this._startHeartbeat(); + } + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async login(handle, password) { + try { + const res = await axios.post(`${MATCHA_API}/login`, { handle, password }); + if (res.data.token) { + this._saveToken(res.data.token); + if (res.data.user) this._saveUser(res.data.user); + this._connectWs(); + this._startHeartbeat(); + } + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async logout() { + await this.sendHeartbeat('offline').catch(() => {}); + this._clearAuth(); + return { ok: true }; + } + + getAuthState() { + return { + authenticated: !!this.token, + user: this.user, + wsConnected: this.wsConnected + }; + } + + // ========================================================================= + // PROFILE + // ========================================================================= + + async getMe() { + try { + const res = await axios.get(`${MATCHA_API}/me`, { headers: this._authHeaders() }); + if (res.data?.user) this._saveUser({ id: res.data.user.id, handle: res.data.user.handle }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async getUser(userId) { + try { + const res = await axios.get(`${MATCHA_API}/users/${encodeURIComponent(userId)}`, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + // ========================================================================= + // FRIENDS + // ========================================================================= + + async getFriends() { + try { + const res = await axios.get(`${MATCHA_API}/friends`, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async sendFriendRequest(handle) { + try { + const res = await axios.post(`${MATCHA_API}/friends/request`, { toHandle: handle }, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async acceptFriend(requestId) { + try { + const res = await axios.post(`${MATCHA_API}/friends/request/accept`, { id: requestId }, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async rejectFriend(requestId) { + try { + const res = await axios.post(`${MATCHA_API}/friends/request/reject`, { id: requestId }, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async cancelFriendRequest(requestId) { + try { + const res = await axios.post(`${MATCHA_API}/friends/request/cancel`, { id: requestId }, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async removeFriend(friendId) { + try { + const res = await axios.post(`${MATCHA_API}/friends/remove`, { friendId }, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + // ========================================================================= + // MESSAGES + // ========================================================================= + + async getMessages(withTarget, cursor, after) { + try { + const params = { with: withTarget }; + if (cursor) params.cursor = cursor; + if (after) params.after = after; + const res = await axios.get(`${MATCHA_API}/messages`, { headers: this._authHeaders(), params }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async sendMessage(to, body, replyTo) { + // Enforce 800ms throttle + const now = Date.now(); + const elapsed = now - this.lastMessageSentAt; + if (elapsed < 800) { + return { ok: false, error: 'Please wait before sending another message' }; + } + this.lastMessageSentAt = now; + + // Try WebSocket first + if (this.wsConnected && this.ws && this.ws.readyState === WebSocket.OPEN) { + const msg = { type: 'send', to, body }; + if (replyTo) msg.replyTo = replyTo; + this.ws.send(JSON.stringify(msg)); + return { ok: true, data: { sent: true, via: 'ws' } }; + } + + // Fallback to HTTP + try { + const payload = { to, body }; + if (replyTo) payload.replyTo = replyTo; + const res = await axios.post(`${MATCHA_API}/messages/send`, payload, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async deleteMessage(messageId) { + try { + const res = await axios.post(`${MATCHA_API}/messages/${messageId}/delete`, {}, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + // ========================================================================= + // UNREAD + // ========================================================================= + + async getUnread() { + try { + const res = await axios.get(`${MATCHA_API}/unread`, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async clearUnread(withTarget) { + try { + const res = await axios.post(`${MATCHA_API}/unread/clear`, { with: withTarget }, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + // ========================================================================= + // HEARTBEAT + // ========================================================================= + + async sendHeartbeat(state) { + if (!this.token) return; + try { + await axios.post(`${MATCHA_API}/heartbeat`, { state: state || 'online' }, { headers: this._authHeaders() }); + } catch (err) { + console.log('[Matcha] Heartbeat failed:', err.message); + } + } + + _startHeartbeat() { + this._stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + const state = this.gameRunning ? 'in_game' : 'online'; + this.sendHeartbeat(state); + }, 30000); + // Send initial heartbeat + this.sendHeartbeat('online'); + } + + _stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + setGameRunning(running) { + this.gameRunning = running; + } + + // ========================================================================= + // AVATAR + // ========================================================================= + + async uploadAvatar(filePath, mode) { + try { + const crypto = require('crypto'); + const fileBuffer = fs.readFileSync(filePath); + if (fileBuffer.length > 1024 * 1024) { + return { ok: false, error: 'Avatar too large (max 1MB)' }; + } + const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + const endpoint = mode === 'custom' ? `${MATCHA_API}/avatar/custom` : `${MATCHA_API}/avatar`; + const res = await axios.post(endpoint, fileBuffer, { + headers: { + ...this._authHeaders(), + 'Content-Type': 'image/png', + 'x-avatar-hash': hash, + 'x-avatar-enable': '1', + 'x-avatar-force': '1', + 'Cache-Control': 'no-store' + }, + maxContentLength: 1024 * 1024 + }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + async deleteAvatar() { + try { + const res = await axios.delete(`${MATCHA_API}/avatar`, { headers: this._authHeaders() }); + return { ok: true, data: res.data }; + } catch (err) { + return this._handleError(err); + } + } + + // ========================================================================= + // WEBSOCKET + // ========================================================================= + + _connectWs() { + if (this.ws) this._disconnectWs(); + + try { + this.ws = new WebSocket(MATCHA_WS); + + this.ws.on('open', () => { + console.log('[Matcha] WebSocket connected'); + this.reconnectAttempts = 0; + // Authenticate + if (this.token) { + this.ws.send(JSON.stringify({ type: 'auth', token: this.token })); + } + }); + + this.ws.on('message', (raw) => { + try { + const data = JSON.parse(raw.toString()); + this._handleWsMessage(data); + } catch (err) { + console.error('[Matcha] WS parse error:', err.message); + } + }); + + this.ws.on('close', (code) => { + console.log('[Matcha] WebSocket closed:', code); + this.wsConnected = false; + this._sendToRenderer('matcha:ws:disconnected'); + + // Handle ban + if (code === 4003) { + this._sendToRenderer('matcha:ws:banned', { reason: 'Account banned' }); + return; + } + + // Auto-reconnect if we have a token + if (this.token && code !== 4003) { + this._scheduleReconnect(); + } + }); + + this.ws.on('error', (err) => { + console.error('[Matcha] WS error:', err.message); + }); + } catch (err) { + console.error('[Matcha] WS connect failed:', err.message); + this._scheduleReconnect(); + } + } + + _disconnectWs() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + if (this.ws) { + try { + this.ws.close(); + } catch (e) {} + this.ws = null; + } + this.wsConnected = false; + } + + _scheduleReconnect() { + if (this.reconnectTimeout) return; + // Exponential backoff capped at 30s, no hard limit (matches Butter's infinite reconnect) + const delay = Math.min(2000 * Math.pow(2, this.reconnectAttempts), 30000); + this.reconnectAttempts++; + // Notify renderer after several failures so it can show a banner + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this._sendToRenderer('matcha:ws:max-retries'); + } + console.log(`[Matcha] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + if (this.token) this._connectWs(); + }, delay); + } + + _handleWsMessage(data) { + switch (data.type) { + case 'authed': + this.wsConnected = true; + if (data.user) this._saveUser(data.user); + this._sendToRenderer('matcha:ws:connected', { user: this.user }); + break; + case 'message': + console.log('[Matcha] WS message received:', JSON.stringify(data).substring(0, 200)); + this._sendToRenderer('matcha:ws:message', data); + break; + case 'message_deleted': + this._sendToRenderer('matcha:ws:message-deleted', data); + break; + case 'avatar_updated': + this._sendToRenderer('matcha:ws:avatar-updated', data); + break; + case 'banned': + this._sendToRenderer('matcha:ws:banned', data); + break; + case 'announcement': + this._sendToRenderer('matcha:ws:announcement', data); + break; + case 'error': + console.log('[Matcha] WS error:', data.message || data.error || JSON.stringify(data)); + // If auth error, treat as ban/disconnect + if (data.message === 'Not authed' || data.error === 'Not authed') { + this._clearAuth(); + this._sendToRenderer('matcha:ws:disconnected'); + } + break; + default: + console.log('[Matcha] Unknown WS message type:', data.type); + } + } + + manualReconnect() { + this.reconnectAttempts = 0; + if (this.token) this._connectWs(); + } + + // ========================================================================= + // HELPERS + // ========================================================================= + + _sendToRenderer(channel, data) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(channel, data); + } + } + + _handleError(err) { + if (err.response) { + const status = err.response.status; + const msg = err.response.data?.error || err.response.data?.message || err.message; + if (status === 401) { + // Token expired or invalid + this._clearAuth(); + this._sendToRenderer('matcha:ws:disconnected'); + } + return { ok: false, error: msg, status }; + } + return { ok: false, error: err.message }; + } +} + +module.exports = new MatchaService(); diff --git a/main.js b/main.js index f4352e9..d50174e 100644 --- a/main.js +++ b/main.js @@ -6,6 +6,7 @@ const fs = require('fs'); const { launchGame, launchGameWithVersionCheck, installGame, saveUsername, loadUsername, saveJavaPath, loadJavaPath, saveInstallPath, loadInstallPath, saveDiscordRPC, loadDiscordRPC, saveLanguage, loadLanguage, saveCloseLauncherOnStart, loadCloseLauncherOnStart, saveLauncherHardwareAcceleration, loadLauncherHardwareAcceleration, saveAllowMultiInstance, loadAllowMultiInstance, isGameInstalled, uninstallGame, repairGame, getHytaleNews, handleFirstLaunchCheck, proposeGameUpdate, markAsLaunched, loadConfig, saveConfig, checkLaunchReady } = require('./backend/launcher'); const { retryPWRDownload } = require('./backend/managers/gameManager'); const { migrateUserDataToCentralized } = require('./backend/utils/userDataMigration'); +const matchaService = require('./backend/services/matchaService'); // Handle Hardware Acceleration try { @@ -213,6 +214,9 @@ function createWindow() { // Initialize Discord Rich Presence initDiscordRPC(); + // Initialize Matcha Social service + matchaService.init(mainWindow); + // Configure and initialize electron-updater // Enable auto-download so updates start immediately when available autoUpdater.autoDownload = true; @@ -509,6 +513,7 @@ async function cleanupDiscordRPC() { app.on('before-quit', () => { console.log('=== LAUNCHER BEFORE QUIT ==='); cleanupDiscordRPC(); + matchaService.destroy(); }); app.on('window-all-closed', () => { @@ -558,6 +563,9 @@ ipcMain.handle('launch-game', async (event, playerName, javaPath, installPath, g // Save last played timestamp try { saveConfig({ last_played: Date.now() }); } catch (e) { /* ignore */ } + // Notify Matcha that game is running (heartbeat will send 'in_game') + matchaService.setGameRunning(true); + const closeOnStart = loadCloseLauncherOnStart(); if (closeOnStart) { console.log('Close Launcher on start enabled, quitting application...'); @@ -615,6 +623,9 @@ ipcMain.handle('launch-game-with-password', async (event, playerName, javaPath, if (result.success && result.launched) { try { saveConfig({ last_played: Date.now() }); } catch (e) { /* ignore */ } + + matchaService.setGameRunning(true); + const closeOnStart = loadCloseLauncherOnStart(); if (closeOnStart) { setTimeout(() => { app.quit(); }, 1000); @@ -1808,6 +1819,108 @@ ipcMain.handle('preview-wrapper-script', (event, config, platform) => { return generateWrapperScript(config || require('./backend/launcher').loadWrapperConfig(), platform || process.platform, '/path/to/java'); }); +// ============================================================================= +// MATCHA SOCIAL IPC HANDLERS +// ============================================================================= + +ipcMain.handle('matcha:log', (event, level, ...args) => { + const prefix = '[Matcha/Renderer]'; + if (level === 'error') console.error(prefix, ...args); + else if (level === 'warn') console.warn(prefix, ...args); + else console.log(prefix, ...args); +}); + +ipcMain.handle('matcha:register', async (event, username, password, password2) => { + return matchaService.register(username, password, password2); +}); + +ipcMain.handle('matcha:confirm-register', async (event, pendingId, proofId) => { + return matchaService.confirmRegistration(pendingId, proofId); +}); + +ipcMain.handle('matcha:login', async (event, handle, password) => { + return matchaService.login(handle, password); +}); + +ipcMain.handle('matcha:logout', async () => { + return matchaService.logout(); +}); + +ipcMain.handle('matcha:get-auth-state', () => { + return matchaService.getAuthState(); +}); + +ipcMain.handle('matcha:get-me', async () => { + return matchaService.getMe(); +}); + +ipcMain.handle('matcha:get-user', async (event, userId) => { + return matchaService.getUser(userId); +}); + +ipcMain.handle('matcha:get-friends', async () => { + return matchaService.getFriends(); +}); + +ipcMain.handle('matcha:friend-request', async (event, handle) => { + return matchaService.sendFriendRequest(handle); +}); + +ipcMain.handle('matcha:friend-accept', async (event, requestId) => { + return matchaService.acceptFriend(requestId); +}); + +ipcMain.handle('matcha:friend-reject', async (event, requestId) => { + return matchaService.rejectFriend(requestId); +}); + +ipcMain.handle('matcha:friend-cancel', async (event, requestId) => { + return matchaService.cancelFriendRequest(requestId); +}); + +ipcMain.handle('matcha:friend-remove', async (event, friendId) => { + return matchaService.removeFriend(friendId); +}); + +ipcMain.handle('matcha:get-messages', async (event, withTarget, cursor, after) => { + return matchaService.getMessages(withTarget, cursor, after); +}); + +ipcMain.handle('matcha:send-message', async (event, to, body, replyTo) => { + return matchaService.sendMessage(to, body, replyTo); +}); + +ipcMain.handle('matcha:delete-message', async (event, messageId) => { + return matchaService.deleteMessage(messageId); +}); + +ipcMain.handle('matcha:get-unread', async () => { + return matchaService.getUnread(); +}); + +ipcMain.handle('matcha:clear-unread', async (event, withTarget) => { + return matchaService.clearUnread(withTarget); +}); + +ipcMain.handle('matcha:upload-avatar', async (event, mode) => { + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Select Avatar Image', + filters: [{ name: 'PNG Images', extensions: ['png'] }], + properties: ['openFile'] + }); + if (result.canceled || !result.filePaths[0]) return { ok: false, error: 'Cancelled' }; + return matchaService.uploadAvatar(result.filePaths[0], mode); +}); + +ipcMain.handle('matcha:delete-avatar', async () => { + return matchaService.deleteAvatar(); +}); + +ipcMain.handle('matcha:reconnect', () => { + matchaService.manualReconnect(); + return { ok: true }; +}); + ipcMain.handle('get-current-platform', () => { return process.platform; }); diff --git a/package.json b/package.json index f8c4230..4cbf9da 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "electron-updater": "^6.7.3", "fs-extra": "^11.3.3", "tar": "^7.5.7", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "ws": "^8.16.0" }, "build": { "appId": "com.hytalef2p.launcher", diff --git a/preload.js b/preload.js index 2c1eede..b3e14a2 100644 --- a/preload.js +++ b/preload.js @@ -153,5 +153,39 @@ contextBridge.exposeInMainWorld('electronAPI', { }, onUpdateError: (callback) => { ipcRenderer.on('update-error', (event, data) => callback(data)); + }, + + // Matcha Social API + matcha: { + log: (level, ...args) => ipcRenderer.invoke('matcha:log', level, ...args), + register: (username, password, password2) => ipcRenderer.invoke('matcha:register', username, password, password2), + confirmRegister: (pendingId, proofId) => ipcRenderer.invoke('matcha:confirm-register', pendingId, proofId), + login: (handle, password) => ipcRenderer.invoke('matcha:login', handle, password), + logout: () => ipcRenderer.invoke('matcha:logout'), + getAuthState: () => ipcRenderer.invoke('matcha:get-auth-state'), + getMe: () => ipcRenderer.invoke('matcha:get-me'), + getUser: (userId) => ipcRenderer.invoke('matcha:get-user', userId), + getFriends: () => ipcRenderer.invoke('matcha:get-friends'), + friendRequest: (handle) => ipcRenderer.invoke('matcha:friend-request', handle), + friendAccept: (requestId) => ipcRenderer.invoke('matcha:friend-accept', requestId), + friendReject: (requestId) => ipcRenderer.invoke('matcha:friend-reject', requestId), + friendCancel: (requestId) => ipcRenderer.invoke('matcha:friend-cancel', requestId), + friendRemove: (friendId) => ipcRenderer.invoke('matcha:friend-remove', friendId), + getMessages: (withTarget, cursor, after) => ipcRenderer.invoke('matcha:get-messages', withTarget, cursor, after), + sendMessage: (to, body, replyTo) => ipcRenderer.invoke('matcha:send-message', to, body, replyTo), + deleteMessage: (messageId) => ipcRenderer.invoke('matcha:delete-message', messageId), + getUnread: () => ipcRenderer.invoke('matcha:get-unread'), + clearUnread: (withTarget) => ipcRenderer.invoke('matcha:clear-unread', withTarget), + uploadAvatar: (mode) => ipcRenderer.invoke('matcha:upload-avatar', mode), + deleteAvatar: () => ipcRenderer.invoke('matcha:delete-avatar'), + reconnect: () => ipcRenderer.invoke('matcha:reconnect'), + onWsMessage: (callback) => ipcRenderer.on('matcha:ws:message', (event, data) => callback(data)), + onWsConnected: (callback) => ipcRenderer.on('matcha:ws:connected', (event, data) => callback(data)), + onWsDisconnected: (callback) => ipcRenderer.on('matcha:ws:disconnected', () => callback()), + onMessageDeleted: (callback) => ipcRenderer.on('matcha:ws:message-deleted', (event, data) => callback(data)), + onAvatarUpdated: (callback) => ipcRenderer.on('matcha:ws:avatar-updated', (event, data) => callback(data)), + onBanned: (callback) => ipcRenderer.on('matcha:ws:banned', (event, data) => callback(data)), + onAnnouncement: (callback) => ipcRenderer.on('matcha:ws:announcement', (event, data) => callback(data)), + onMaxRetries: (callback) => ipcRenderer.on('matcha:ws:max-retries', () => callback()) } });