mirror of
https://git.sanhost.net/sanasol/hytale-f2p
synced 2026-03-02 18:31:51 -03:00
Add full Matcha social panel (butter.lat API) as a right-side slide-out: Backend (matchaService.js): - HTTP client for auth, friends, messages, unread, avatar, heartbeat APIs - WebSocket with auto-reconnect (exponential backoff, no hard cap) - Token management via config, presence heartbeat every 30s - WS error message type handling, game running presence Renderer (matcha.js): - State machine UI: intro → login/register → app (friends/chat/DMs/profile) - Two-phase registration with master key display and verification - Friends list with presence dots, collapsible requests, 12s polling - Global chat + DM with optimistic rendering, cursor pagination - Scroll position preserved on load-more, separate loading flags - Clickable URLs in messages (linkify with proper escaping) - User profile popup with avatar upload/delete - Unread badges (messages + friend requests) on nav icon - Escape key closes panel/overlay, try/catch on auth flows IPC bridge (preload.js + main.js): - 21 IPC invoke methods + 8 WS event listeners - Avatar upload via file picker dialog in main process - Game launch sets in_game heartbeat state CSS (style.css): - ~1500 lines: panel, auth screens, friends, chat, profile, toast - Responsive panel width, improved contrast, no overflow clipping - Loading states, disabled states, pulse animations Credits: Powered by Butter Launcher & Matcha! (butterlauncher.tech) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1920 lines
72 KiB
JavaScript
1920 lines
72 KiB
JavaScript
// =============================================================================
|
|
// 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 = '<span class="matcha-header-title">Social</span>';
|
|
body.innerHTML = `
|
|
<div class="matcha-intro">
|
|
<div class="matcha-intro-icon">
|
|
<i class="fas fa-users"></i>
|
|
</div>
|
|
<h2 class="matcha-intro-title">Matcha!</h2>
|
|
<p class="matcha-intro-desc">Connect with other Hytale players</p>
|
|
<div class="matcha-intro-features">
|
|
<div class="matcha-feature"><i class="fas fa-user-friends"></i><span>Friends & Presence</span></div>
|
|
<div class="matcha-feature"><i class="fas fa-comments"></i><span>Global Chat</span></div>
|
|
<div class="matcha-feature"><i class="fas fa-envelope"></i><span>Direct Messages</span></div>
|
|
</div>
|
|
<div class="matcha-intro-actions">
|
|
<button class="matcha-btn primary" id="matchaSignInBtn">Sign In</button>
|
|
<button class="matcha-btn ghost" id="matchaCreateBtn">Create Account</button>
|
|
</div>
|
|
<p class="matcha-intro-powered">Powered by <a href="#" class="matcha-powered-link" data-url="https://butterlauncher.tech/">Matcha!</a></p>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<button class="matcha-back-btn" id="matchaLoginBack"><i class="fas fa-arrow-left"></i></button>
|
|
<span class="matcha-header-title">Sign In</span>
|
|
`;
|
|
body.innerHTML = `
|
|
<div class="matcha-auth-form">
|
|
<div class="matcha-form-group">
|
|
<label>Handle</label>
|
|
<input type="text" class="matcha-input" id="matchaLoginHandle" placeholder="Name#0000" autocomplete="off" />
|
|
</div>
|
|
<div class="matcha-form-group">
|
|
<label>Password</label>
|
|
<input type="password" class="matcha-input" id="matchaLoginPassword" placeholder="Password" autocomplete="current-password" />
|
|
</div>
|
|
<div class="matcha-error" id="matchaLoginError"></div>
|
|
<button class="matcha-btn primary full" id="matchaLoginSubmit">Sign In</button>
|
|
<p class="matcha-auth-link">No account? <a href="#" id="matchaLoginToRegister">Create one</a></p>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<button class="matcha-back-btn" id="matchaRegBack"><i class="fas fa-arrow-left"></i></button>
|
|
<span class="matcha-header-title">Create Account</span>
|
|
`;
|
|
body.innerHTML = `
|
|
<div class="matcha-auth-form">
|
|
<div class="matcha-form-group">
|
|
<label>Username</label>
|
|
<input type="text" class="matcha-input" id="matchaRegUsername" placeholder="Username" autocomplete="off" />
|
|
</div>
|
|
<div class="matcha-form-group">
|
|
<label>Password</label>
|
|
<input type="password" class="matcha-input" id="matchaRegPassword" placeholder="Password" autocomplete="new-password" />
|
|
</div>
|
|
<div class="matcha-form-group">
|
|
<label>Confirm Password</label>
|
|
<input type="password" class="matcha-input" id="matchaRegConfirm" placeholder="Confirm password" autocomplete="new-password" />
|
|
</div>
|
|
<div class="matcha-error" id="matchaRegError"></div>
|
|
<button class="matcha-btn primary full" id="matchaRegSubmit">Create Account</button>
|
|
<p class="matcha-auth-link">Already have an account? <a href="#" id="matchaRegToLogin">Sign in</a></p>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<span class="matcha-header-title">Save Your Key</span>';
|
|
body.innerHTML = `
|
|
<div class="matcha-key-display">
|
|
<div class="matcha-key-warning">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<span>Save this information — it cannot be recovered!</span>
|
|
</div>
|
|
<div class="matcha-key-field">
|
|
<label>Your Handle</label>
|
|
<div class="matcha-key-value">
|
|
<code>${escapeHtml(matchaState.pendingHandle || '')}</code>
|
|
<button class="matcha-copy-btn" data-copy="${escapeAttr(matchaState.pendingHandle || '')}">
|
|
<i class="fas fa-copy"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="matcha-key-field">
|
|
<label>Master Key</label>
|
|
<div class="matcha-key-value">
|
|
<code class="matcha-master-key">${escapeHtml(matchaState.proofId || '')}</code>
|
|
<button class="matcha-copy-btn" data-copy="${escapeAttr(matchaState.proofId || '')}">
|
|
<i class="fas fa-copy"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="matcha-key-note">This key is your password recovery method. Without it, you cannot reset your password.</p>
|
|
<button class="matcha-btn primary full" id="matchaKeySaved" disabled>I've Saved My Key</button>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<button class="matcha-back-btn" id="matchaVerifyBack"><i class="fas fa-arrow-left"></i></button>
|
|
<span class="matcha-header-title">Verify Your Key</span>
|
|
`;
|
|
body.innerHTML = `
|
|
<div class="matcha-auth-form">
|
|
<p class="matcha-verify-desc">Enter your master key to confirm you saved it.</p>
|
|
<div class="matcha-form-group">
|
|
<label>Master Key</label>
|
|
<input type="text" class="matcha-input" id="matchaVerifyKey" placeholder="BM:..." autocomplete="off" />
|
|
</div>
|
|
<div class="matcha-error" id="matchaVerifyError"></div>
|
|
<button class="matcha-btn primary full" id="matchaVerifySubmit">Verify & Continue</button>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="matcha-user-bar" id="matchaUserBar">
|
|
<div class="matcha-user-avatar-small">
|
|
${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none'" />` : `<i class="fas fa-user"></i>`}
|
|
</div>
|
|
<span class="matcha-user-handle">${escapeHtml(matchaState.user?.handle || 'User')}</span>
|
|
<button class="matcha-icon-btn" id="matchaProfileBtn" title="Profile"><i class="fas fa-cog"></i></button>
|
|
</div>
|
|
`;
|
|
|
|
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
|
|
? `<span class="matcha-tab-badge">${matchaState.unreadGlobal}</span>` : '';
|
|
|
|
const tabBar = document.createElement('div');
|
|
tabBar.className = 'matcha-tab-bar';
|
|
tabBar.innerHTML = `
|
|
<button class="matcha-tab ${matchaState.appTab === 'friends' ? 'active' : ''}" data-tab="friends">Friends</button>
|
|
<button class="matcha-tab ${matchaState.appTab === 'globalChat' ? 'active' : ''}" data-tab="globalChat">Global Chat${globalBadge}</button>
|
|
`;
|
|
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 = `
|
|
<div class="matcha-friends-view">
|
|
<div class="matcha-add-friend">
|
|
<input type="text" class="matcha-input" id="matchaAddFriendInput" placeholder="Add friend (Name#0000)" />
|
|
<button class="matcha-icon-btn primary" id="matchaAddFriendBtn"><i class="fas fa-plus"></i></button>
|
|
</div>
|
|
<div id="matchaFriendRequests"></div>
|
|
<div id="matchaFriendsList">
|
|
<div class="matcha-loading"><i class="fas fa-spinner fa-spin"></i> Loading friends...</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = '<i class="fas fa-spinner fa-spin"></i>';
|
|
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 = `<div class="matcha-requests-section">
|
|
<div class="matcha-requests-toggle${incoming.length > 0 ? ' has-incoming' : ''}" id="matchaRequestsToggle">
|
|
<i class="fas ${chevronIcon}"></i>
|
|
<span>Requests (${totalRequests})</span>
|
|
${incoming.length > 0 ? `<span class="matcha-requests-badge">${incoming.length} new</span>` : ''}
|
|
</div>`;
|
|
|
|
if (!requestsCollapsed) {
|
|
if (incoming.length > 0) {
|
|
html += `<div class="matcha-section-subheader"><i class="fas fa-inbox"></i> Incoming (${incoming.length})</div>`;
|
|
incoming.forEach(req => {
|
|
const avUrl = avatarUrl(req.fromId);
|
|
html += `
|
|
<div class="matcha-friend-row request">
|
|
<div class="matcha-friend-avatar">${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /><div class="matcha-avatar-placeholder" style="display:none"><i class="fas fa-user"></i></div>` : `<div class="matcha-avatar-placeholder"><i class="fas fa-user"></i></div>`}</div>
|
|
<span class="matcha-friend-name">${escapeHtml(req.fromHandle || 'Unknown')}</span>
|
|
<div class="matcha-request-actions">
|
|
<button class="matcha-icon-btn success" data-accept="${req.id}" title="Accept"><i class="fas fa-check"></i></button>
|
|
<button class="matcha-icon-btn danger" data-reject="${req.id}" title="Reject"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
}
|
|
|
|
if (outgoing.length > 0) {
|
|
html += `<div class="matcha-section-subheader"><i class="fas fa-paper-plane"></i> Sent (${outgoing.length})</div>`;
|
|
outgoing.forEach(req => {
|
|
const avUrl = avatarUrl(req.toId);
|
|
html += `
|
|
<div class="matcha-friend-row request">
|
|
<div class="matcha-friend-avatar">${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /><div class="matcha-avatar-placeholder" style="display:none"><i class="fas fa-user"></i></div>` : `<div class="matcha-avatar-placeholder"><i class="fas fa-user"></i></div>`}</div>
|
|
<span class="matcha-friend-name">${escapeHtml(req.toHandle || 'Unknown')}</span>
|
|
<button class="matcha-icon-btn muted" data-cancel="${req.id}" title="Cancel request"><i class="fas fa-times"></i></button>
|
|
</div>`;
|
|
});
|
|
}
|
|
}
|
|
|
|
html += '</div>';
|
|
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 = '<i class="fas fa-spinner fa-spin"></i>';
|
|
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 = '<i class="fas fa-check"></i>';
|
|
}
|
|
loadFriends();
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-reject]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
const res = await matcha.friendReject(btn.dataset.reject);
|
|
if (res.ok) {
|
|
showToast('Request rejected');
|
|
} else {
|
|
showToast(res.error || 'Failed');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-times"></i>';
|
|
}
|
|
loadFriends();
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-cancel]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
const res = await matcha.friendCancel(btn.dataset.cancel);
|
|
if (res.ok) {
|
|
showToast('Request cancelled');
|
|
} else {
|
|
showToast(res.error || 'Failed');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-times"></i>';
|
|
}
|
|
loadFriends();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderFriendsList() {
|
|
const container = document.getElementById('matchaFriendsList');
|
|
if (!container) return;
|
|
|
|
const friends = matchaState.friends;
|
|
if (friends.length === 0) {
|
|
container.innerHTML = '<div class="matcha-empty"><i class="fas fa-user-friends"></i><p>No friends yet. Add someone!</p></div>';
|
|
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 = `<div class="matcha-section-header">Friends \u2014 ${onlineCount} connected (${friends.length} total)</div>`;
|
|
|
|
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 ? `<span class="matcha-unread-dot">${unread}</span>` : '';
|
|
const avUrl = avatarUrl(f.id);
|
|
|
|
html += `
|
|
<div class="matcha-friend-row" data-friend-id="${f.id}" data-friend-handle="${escapeAttr(f.handle)}">
|
|
<div class="matcha-friend-avatar">
|
|
${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none'" />` : `<i class="fas fa-user"></i>`}
|
|
<span class="matcha-presence-dot ${presenceClass}"></span>
|
|
</div>
|
|
<div class="matcha-friend-info">
|
|
<span class="matcha-friend-name">${escapeHtml(f.handle || 'Unknown')}</span>
|
|
<span class="matcha-friend-status">${presenceText}</span>
|
|
</div>
|
|
${unreadBadge}
|
|
<div class="matcha-friend-actions">
|
|
<button class="matcha-icon-btn" data-dm="${f.id}" data-dm-handle="${escapeAttr(f.handle)}" title="Open DM"><i class="fas fa-comment"></i></button>
|
|
<button class="matcha-icon-btn danger" data-remove="${f.id}" data-remove-handle="${escapeAttr(f.handle)}" title="Remove Friend"><i class="fas fa-user-minus"></i></button>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
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 = '<i class="fas fa-spinner fa-spin"></i>';
|
|
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 = '<i class="fas fa-user-minus"></i>';
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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 = `
|
|
<div class="matcha-chat-view">
|
|
<div class="matcha-messages" id="matchaGlobalMessages">
|
|
<div class="matcha-loading"><i class="fas fa-spinner fa-spin"></i> Loading messages...</div>
|
|
</div>
|
|
<div class="matcha-reply-bar" id="matchaReplyBar" style="display:none">
|
|
<span id="matchaReplyText"></span>
|
|
<button class="matcha-icon-btn" id="matchaCancelReply"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="matcha-chat-input">
|
|
<input type="text" class="matcha-input" id="matchaGlobalInput" placeholder="Type a message..." maxlength="500" />
|
|
<button class="matcha-icon-btn primary" id="matchaGlobalSend"><i class="fas fa-paper-plane"></i></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = '<div class="matcha-empty"><p>No messages yet. Say hello!</p></div>';
|
|
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 = `
|
|
<button class="matcha-back-btn" id="matchaDmBack"><i class="fas fa-arrow-left"></i></button>
|
|
<div class="matcha-dm-header-info">
|
|
<div class="matcha-dm-header-avatar">
|
|
${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none'" />` : `<i class="fas fa-user"></i>`}
|
|
<span class="matcha-presence-dot ${presenceClass}"></span>
|
|
</div>
|
|
<div class="matcha-dm-header-text">
|
|
<span class="matcha-header-title">${escapeHtml(target.handle)}</span>
|
|
<span class="matcha-dm-header-status">${presenceText}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
body.innerHTML = `
|
|
<div class="matcha-chat-view">
|
|
<div class="matcha-messages" id="matchaDmMessages">
|
|
<div class="matcha-loading"><i class="fas fa-spinner fa-spin"></i> Loading messages...</div>
|
|
</div>
|
|
<div class="matcha-reply-bar" id="matchaDmReplyBar" style="display:none">
|
|
<span id="matchaDmReplyText"></span>
|
|
<button class="matcha-icon-btn" id="matchaDmCancelReply"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="matcha-chat-input">
|
|
<input type="text" class="matcha-input" id="matchaDmInput" placeholder="Message ${escapeAttr(target.handle)}..." maxlength="500" />
|
|
<button class="matcha-icon-btn primary" id="matchaDmSend"><i class="fas fa-paper-plane"></i></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<div class="matcha-empty"><p>No messages yet. Say hello!</p></div>';
|
|
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 ? '<span class="matcha-dev-badge">DEV</span>' : '';
|
|
const replyHtml = msg.replyToSnippet
|
|
? `<div class="matcha-reply-preview"><i class="fas fa-reply"></i> ${escapeHtml(msg.replyToFromHandle || '')} : ${escapeHtml(msg.replyToSnippet)}</div>`
|
|
: '';
|
|
|
|
const clickable = !isOwn && msg.fromId ? ' matcha-clickable-user' : '';
|
|
const avatarHtml = `<div class="matcha-msg-avatar${clickable}" ${msg.fromId ? `data-user-id="${msg.fromId}"` : ''}>${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none';this.nextElementSibling.style.display='block'" /><i class="fas fa-user" style="display:none"></i>` : `<i class="fas fa-user"></i>`}</div>`;
|
|
|
|
const actionsHtml = !msg.deleted ? `
|
|
<div class="matcha-msg-actions">
|
|
<button class="matcha-msg-action" data-action="reply" title="Reply"><i class="fas fa-reply"></i></button>
|
|
${isOwn ? `<button class="matcha-msg-action danger" data-action="delete" title="Delete"><i class="fas fa-trash"></i></button>` : ''}
|
|
</div>` : '';
|
|
|
|
if (isOwn) {
|
|
// Own messages: right-aligned, avatar on right
|
|
const ownHandle = msg.fromHandle || matchaState.user?.handle || 'You';
|
|
return `
|
|
<div class="matcha-message own ${msg.deleted ? 'deleted' : ''}" data-msg-id="${msg.id}" data-from-handle="${escapeAttr(msg.fromHandle || '')}">
|
|
${replyHtml}
|
|
<div class="matcha-message-row">
|
|
${actionsHtml}
|
|
<div class="matcha-msg-content">
|
|
<div class="matcha-msg-header own-header">
|
|
<span class="matcha-msg-time" title="${fullTime}">${time}</span>
|
|
<span class="matcha-msg-author">${escapeHtml(ownHandle)}</span>
|
|
${badgeHtml}
|
|
</div>
|
|
<div class="matcha-msg-body">${msg.deleted ? '<em>Message deleted</em>' : linkify(msg.body || '')}</div>
|
|
</div>
|
|
${avatarHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Others' messages: left-aligned, avatar on left
|
|
const authorClickable = msg.fromId ? ` matcha-clickable-user" data-user-id="${msg.fromId}` : '';
|
|
return `
|
|
<div class="matcha-message ${msg.deleted ? 'deleted' : ''}" data-msg-id="${msg.id}" data-from-handle="${escapeAttr(msg.fromHandle || '')}">
|
|
${replyHtml}
|
|
<div class="matcha-message-row">
|
|
${avatarHtml}
|
|
<div class="matcha-msg-content">
|
|
<div class="matcha-msg-header">
|
|
<span class="matcha-msg-author${authorClickable}">${escapeHtml(msg.fromHandle || 'Unknown')}</span>
|
|
${badgeHtml}
|
|
<span class="matcha-msg-time" title="${fullTime}">${time}</span>
|
|
</div>
|
|
<div class="matcha-msg-body">${msg.deleted ? '<em>Message deleted</em>' : linkify(msg.body || '')}</div>
|
|
</div>
|
|
${actionsHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 = '<em>Message deleted</em>';
|
|
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 = `
|
|
<button class="matcha-back-btn" id="matchaProfileBack"><i class="fas fa-arrow-left"></i></button>
|
|
<span class="matcha-header-title">Profile</span>
|
|
`;
|
|
|
|
const user = matchaState.user || {};
|
|
const avUrl = avatarUrl(user.id);
|
|
|
|
body.innerHTML = `
|
|
<div class="matcha-profile-view">
|
|
<div class="matcha-profile-avatar">
|
|
${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /><div class="matcha-avatar-placeholder" style="display:none"><i class="fas fa-user"></i></div>` : `<div class="matcha-avatar-placeholder"><i class="fas fa-user"></i></div>`}
|
|
</div>
|
|
<div class="matcha-profile-info">
|
|
<div class="matcha-profile-field">
|
|
<label>Handle</label>
|
|
<div class="matcha-profile-value">
|
|
<span id="matchaProfileHandle">${escapeHtml(user.handle || 'Loading...')}</span>
|
|
<button class="matcha-copy-btn" data-copy="${escapeAttr(user.handle || '')}"><i class="fas fa-copy"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="matcha-profile-actions">
|
|
<button class="matcha-btn ghost full" id="matchaUploadHytaleAvatar"><i class="fas fa-gamepad"></i> Upload Hytale Avatar</button>
|
|
<button class="matcha-btn ghost full" id="matchaUploadCustomAvatar"><i class="fas fa-image"></i> Upload Custom Avatar</button>
|
|
<button class="matcha-btn ghost danger full" id="matchaDeleteAvatar"><i class="fas fa-trash"></i> Delete Avatar</button>
|
|
</div>
|
|
<div class="matcha-profile-divider"></div>
|
|
<button class="matcha-btn ghost danger full" id="matchaSignOut"><i class="fas fa-sign-out-alt"></i> Sign Out</button>
|
|
</div>
|
|
`;
|
|
|
|
|
|
|
|
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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-gamepad"></i> Upload Hytale Avatar';
|
|
showToast(res.error || 'Upload failed');
|
|
} else {
|
|
this.disabled = false;
|
|
this.innerHTML = '<i class="fas fa-gamepad"></i> Upload Hytale Avatar';
|
|
}
|
|
});
|
|
|
|
body.querySelector('#matchaUploadCustomAvatar')?.addEventListener('click', async function() {
|
|
this.disabled = true;
|
|
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-image"></i> Upload Custom Avatar';
|
|
showToast(res.error || 'Upload failed');
|
|
} else {
|
|
this.disabled = false;
|
|
this.innerHTML = '<i class="fas fa-image"></i> Upload Custom Avatar';
|
|
}
|
|
});
|
|
|
|
body.querySelector('#matchaDeleteAvatar')?.addEventListener('click', async function() {
|
|
this.disabled = true;
|
|
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-trash"></i> Delete Avatar';
|
|
showToast(res.error || 'Failed');
|
|
}
|
|
});
|
|
|
|
// Sign out
|
|
body.querySelector('#matchaSignOut').addEventListener('click', async function() {
|
|
this.disabled = true;
|
|
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<div class="matcha-loading"><i class="fas fa-spinner fa-spin"></i></div>';
|
|
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 = `<p class="matcha-error-text">${escapeHtml(res.error || 'Failed to load profile')}</p>
|
|
<button class="matcha-btn ghost" id="matchaProfileCloseErr">Close</button>`;
|
|
body.querySelector('#matchaProfileCloseErr')?.addEventListener('click', closeUserProfile);
|
|
return;
|
|
}
|
|
|
|
const user = res.data?.user || res.data;
|
|
const avUrl = avatarUrl(user.id);
|
|
const roleBadge = user.role === 'dev'
|
|
? '<span class="matcha-dev-badge">DEV</span>'
|
|
: (user.role === 'mod' ? '<span class="matcha-mod-badge">MOD</span>' : '');
|
|
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 = `<button class="matcha-btn ghost full" disabled>Already friends</button>`;
|
|
} else if (pending) {
|
|
actionBtn = `<button class="matcha-btn ghost full" disabled>Request pending</button>`;
|
|
} else {
|
|
actionBtn = `<button class="matcha-btn primary full" id="matchaProfileSendRequest">Send friend request</button>`;
|
|
}
|
|
|
|
body.innerHTML = `
|
|
<div class="matcha-up-header">
|
|
<span class="matcha-up-label">USER PROFILE</span>
|
|
<h3 class="matcha-up-handle">${escapeHtml(user.handle || 'Unknown')} ${roleBadge}</h3>
|
|
</div>
|
|
<div class="matcha-up-main">
|
|
<div class="matcha-up-avatar">
|
|
${avUrl ? `<img src="${avUrl}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /><div class="matcha-avatar-placeholder" style="display:none"><i class="fas fa-user"></i></div>` : `<div class="matcha-avatar-placeholder"><i class="fas fa-user"></i></div>`}
|
|
</div>
|
|
<div class="matcha-up-stats">
|
|
<div class="matcha-up-stat">
|
|
<span class="matcha-up-stat-label">CREATED</span>
|
|
<span class="matcha-up-stat-value">${createdDate}</span>
|
|
</div>
|
|
<div class="matcha-up-stat">
|
|
<span class="matcha-up-stat-label">TOTAL MESSAGES SENT</span>
|
|
<span class="matcha-up-stat-value">${msgCount}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="matcha-up-actions">
|
|
${actionBtn}
|
|
<button class="matcha-btn ghost" id="matchaProfileClose">Close</button>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<em>Message deleted</em>';
|
|
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
|
|
? `<span>Connection lost.</span> <button class="matcha-retry-btn" id="matchaRetryBtn">Retry</button>`
|
|
: `<span>Reconnecting...</span>`;
|
|
|
|
if (showRetry) {
|
|
banner.querySelector('#matchaRetryBtn')?.addEventListener('click', () => {
|
|
matcha.reconnect();
|
|
banner.innerHTML = '<span>Reconnecting...</span>';
|
|
});
|
|
}
|
|
}
|
|
|
|
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 += `<a href="#" class="matcha-link" data-url="${escapeAttr(url)}">${escapeHtml(url)}</a>`;
|
|
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, '<').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 };
|