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 = '';
+ 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 = `
+
+
+ `;
+ 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 = `
+
+
+ `;
+ 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 = '';
+ 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 = `
+
+
+ `;
+ body.innerHTML = `
+
+ `;
+
+ 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 = `
+
+ `;
+ 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.forEach(req => {
+ const avUrl = avatarUrl(req.fromId);
+ html += `
+
+
${avUrl ? `

` : `
`}
+
${escapeHtml(req.fromHandle || 'Unknown')}
+
+
+
+
+
`;
+ });
+ }
+
+ if (outgoing.length > 0) {
+ html += ``;
+ 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 = ``;
+
+ 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 = `
+
+ `;
+ 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 = `
+
+
+ `;
+
+ body.innerHTML = `
+
+ `;
+
+ 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}
+
+
+
${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}
+
+
+
${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 = `
+
+
+ `;
+
+ 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 = `
+
+
+
+ ${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())
}
});