// =============================================================================
// 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.state !== 'offline' ? 1 : 0;
const bOnline = b.state !== 'offline' ? 1 : 0;
if (aOnline !== bOnline) return bOnline - aOnline;
return (a.handle || '').localeCompare(b.handle || '');
});
const onlineCount = sorted.filter(f => f.state !== 'offline').length;
let html = ``;
sorted.forEach(f => {
const presenceClass = f.state === 'offline' ? 'offline' : (f.state === 'in_game' ? 'ingame' : 'online');
const presenceText = f.state === 'offline' ? 'Offline' : (f.state === '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?.state === 'offline' ? 'offline' : (friend?.state === 'in_game' ? 'ingame' : 'online');
const presenceText = friend?.state === 'offline' ? 'Offline' : (friend?.state === '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) => {
// WS data has: type:"message", convo:"global"|, message:{...}
const msg = data.message || data;
const isOwnMessage = !!msg.fromId && String(msg.fromId) === String(matchaState.user?.id || '');
// Use data.convo (Butter's WS format) as primary routing, fallback to msg fields
const isGlobal = data.convo === 'global' || msg.toId === 'global' || msg.to === 'global';
mlog.log('WS msg:', msg.fromHandle, '-> convo:', data.convo, 'toId:', msg.toId, '| 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 — use data.convo as conversation identifier, fallback to msg fields
const otherId = data.convo || (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 };