diff --git "a/client.js" "b/client.js" --- "a/client.js" +++ "b/client.js" @@ -20,15 +20,31 @@ let callTimerInterval = null; let callStartedAt = null; let incomingCallAudioContext = null; let incomingCallInterval = null; +let remoteCallPlaybackRetryBound = false; +let remoteCallPlaybackStarted = false; +let isStartingCallSession = false; +let pendingSignalOffer = null; +let hasUserInteractedWithPage = false; let activeRoomSubscription = null; let roomMessages = JSON.parse(localStorage.getItem('roomMessages') || '{}'); let pinnedChats = []; let joinedCommunities = []; let joinedGroups = []; +let groupMemberRoles = JSON.parse(localStorage.getItem('groupMemberRoles') || '{}'); let cachedShopItemsById = {}; let equippedShopItems = JSON.parse(localStorage.getItem('equippedShopItems') || '{}'); let communityProfiles = JSON.parse(localStorage.getItem('communityProfiles') || '{}'); let groupProfiles = JSON.parse(localStorage.getItem('groupProfiles') || '{}'); +let selectedOrbEffect = localStorage.getItem('selectedOrbEffect') || 'none'; +let isCallCameraOff = false; +let notifList = JSON.parse(localStorage.getItem('notifList') || '[]'); +let notifUnreadCount = 0; +let globalOnlineUsers = []; +let currentRoomMembers = []; +let roomRoleCache = JSON.parse(localStorage.getItem('roomRoleCache') || '{}'); +let roomProfileCache = JSON.parse(localStorage.getItem('roomProfileCache') || '{}'); +const ROOM_CACHE_MAX_PER_ROOM = 40; +const ROOM_CACHE_MEDIA_URL_MAX_CHARS = 1400; let chatSettings = { disappearTime: 0, enterToSend: true, @@ -64,6 +80,84 @@ let themeSettings = { const SERVER_URL = window.location.origin; const API_BASE = SERVER_URL + '/api'; +function normalizeRoomKeyClient(roomType, roomName) { + return `${String(roomType || '').trim().toLowerCase()}:${String(roomName || '').trim().toLowerCase()}`; +} + +function getRoomCacheKey(roomType, roomName) { + return normalizeRoomKeyClient(roomType, roomName); +} + +function getMyRoomRole(roomType, roomName) { + const key = getRoomCacheKey(roomType, roomName); + const roleMap = roomRoleCache[key] || {}; + const myId = String(currentUser?.id || ''); + const cachedRole = roleMap[myId]; + if (cachedRole) return cachedRole; + + if (roomType === 'group') { + const localRole = groupMemberRoles?.[roomName]?.[myId]; + if (localRole) return localRole; + if (joinedGroups.includes(roomName)) return 'Owner'; + } + + return 'Member'; +} + +function canManageRoomAppearance(roomType, roomName) { + if (roomType === 'group' && joinedGroups.includes(roomName)) { + return true; + } + + const role = getMyRoomRole(roomType, roomName); + if (roomType === 'group') { + const localRole = groupMemberRoles?.[roomName]?.[String(currentUser?.id || '')] || 'Member'; + if (localRole === 'Owner' || localRole === 'Admin') { + return true; + } + } + if (roomType === 'community') { + const profile = communityProfiles[roomName]; + const localRole = Array.isArray(profile?.members) + ? (profile.members.find((m) => String(m.id) === String(currentUser?.id))?.role || 'member') + : 'member'; + if (localRole === 'leader' || localRole === 'vice_leader') { + return true; + } + } + return role === 'Owner' || role === 'Admin'; +} + +function cacheRoomProfile(roomType, roomName, profilePatch = {}) { + const key = getRoomCacheKey(roomType, roomName); + const prev = roomProfileCache[key] || {}; + roomProfileCache[key] = { + ...prev, + ...profilePatch, + roomType, + roomName + }; + localStorage.setItem('roomProfileCache', JSON.stringify(roomProfileCache)); +} + +function emitRoomProfileUpdate(roomType, roomName, profilePatch) { + if (!socket || !socket.connected) { + showToast('Not connected to sync room update', 'warning'); + return; + } + socket.emit('update room profile', { + roomType, + roomName, + profilePatch + }); +} + +['pointerdown', 'touchstart', 'keydown'].forEach((eventName) => { + window.addEventListener(eventName, () => { + hasUserInteractedWithPage = true; + }, { passive: true }); +}); + console.log(`๐ Connected to: ${SERVER_URL}`); async function syncBuildVersionBadge() { @@ -110,7 +204,7 @@ function stopCallTimer() { function startIncomingCallAlert() { stopIncomingCallAlert(); - if (navigator.vibrate) { + if (navigator.vibrate && hasUserInteractedWithPage) { navigator.vibrate([300, 200, 300, 200, 300]); } @@ -141,11 +235,67 @@ function stopIncomingCallAlert() { incomingCallInterval = null; } - if (navigator.vibrate) { - navigator.vibrate(0); + if (navigator.vibrate && hasUserInteractedWithPage) { + try { + navigator.vibrate(0); + } catch (error) { + // Ignore browser intervention when user has not interacted yet. + } } } +function formatMessageTimestamp(timestamp) { + const date = timestamp ? new Date(timestamp) : new Date(); + if (Number.isNaN(date.getTime())) return 'Unknown time'; + return date.toLocaleString([], { + month: 'short', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +function ensureRemoteMediaPlayback(videoEl) { + if (!videoEl) return; + + const playPromise = videoEl.play(); + if (!playPromise || typeof playPromise.then !== 'function') return; + + playPromise.then(() => { + if (!remoteCallPlaybackStarted) { + remoteCallPlaybackStarted = true; + showToast('๐ Connected! Audio and video are live.', 'success'); + } + }).catch((error) => { + if (error?.name === 'AbortError') { + setTimeout(() => { + if (videoEl.srcObject) { + videoEl.play().catch(() => {}); + } + }, 120); + return; + } + + console.warn('Remote media autoplay blocked, waiting for user gesture:', error); + showToast('Tap once to enable call audio', 'warning'); + + if (!remoteCallPlaybackRetryBound) { + remoteCallPlaybackRetryBound = true; + const retryPlayback = () => { + videoEl.play().catch((retryErr) => { + console.warn('Retry playback failed:', retryErr); + }); + document.removeEventListener('click', retryPlayback); + document.removeEventListener('touchstart', retryPlayback); + remoteCallPlaybackRetryBound = false; + }; + document.addEventListener('click', retryPlayback, { once: true }); + document.addEventListener('touchstart', retryPlayback, { once: true }); + } + }); +} + function toggleAuthMode() { @@ -172,6 +322,7 @@ function toggleAuthMode() { const resetPanel = document.getElementById('resetPasswordPanel'); if (forgotWrap) forgotWrap.style.display = isRegistering ? 'none' : 'block'; if (resetPanel) resetPanel.classList.remove('show'); + setAuthFeedback(''); } function toggleForgotPasswordPanel() { @@ -180,21 +331,37 @@ function toggleForgotPasswordPanel() { panel.classList.toggle('show'); } +function setAuthFeedback(message, type = 'info') { + const statusEl = document.getElementById('authStatus'); + if (!statusEl) return; + const colors = { + info: 'var(--text-secondary)', + success: 'var(--success)', + warning: '#f6c945', + error: 'var(--danger)' + }; + statusEl.textContent = message || ''; + statusEl.style.color = colors[type] || colors.info; +} + async function resetForgottenPassword() { const email = document.getElementById('resetEmail')?.value.trim(); const newPassword = document.getElementById('resetNewPassword')?.value.trim(); if (!email || !newPassword) { showToast('Enter your email and new password', 'warning'); + setAuthFeedback('Enter your email and new password', 'warning'); return; } if (newPassword.length < 6) { showToast('New password must be at least 6 characters', 'warning'); + setAuthFeedback('New password must be at least 6 characters', 'warning'); return; } try { + setAuthFeedback('Resetting password...', 'info'); const response = await fetch(`${API_BASE}/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -204,17 +371,29 @@ async function resetForgottenPassword() { const data = await response.json(); if (!response.ok) { showToast(data.error || 'Failed to reset password', 'error'); + setAuthFeedback(data.error || 'Failed to reset password', 'error'); return; } showToast('Password reset successful. You can now login.', 'success'); document.getElementById('resetPasswordPanel')?.classList.remove('show'); + document.getElementById('resetEmail').value = email; + document.getElementById('resetNewPassword').value = ''; + if (isRegistering) { + toggleAuthMode(); + } + if (document.getElementById('email')) { + document.getElementById('email').value = email; + } if (document.getElementById('password')) { - document.getElementById('password').value = ''; + document.getElementById('password').value = newPassword; + document.getElementById('password').focus(); } + setAuthFeedback('Password reset successful. Use the pre-filled email/password and tap Login.', 'success'); } catch (error) { console.error(error); showToast('Error resetting password', 'error'); + setAuthFeedback('Error resetting password. Please try again.', 'error'); } } @@ -223,17 +402,29 @@ async function handleAuth(event) { const email = document.getElementById('email').value.trim(); const password = document.getElementById('password').value.trim(); const username = document.getElementById('username').value.trim(); + const authButton = document.getElementById('authButton'); if (!email || !password) { showToast('Please fill in all fields', 'warning'); + setAuthFeedback('Please fill in all fields', 'warning'); return; } try { + if (authButton) { + authButton.disabled = true; + authButton.style.opacity = '0.7'; + } + setAuthFeedback(isRegistering ? 'Creating account...' : 'Logging in...', 'info'); let response; if (isRegistering) { if (!username) { showToast('Username is required', 'warning'); + setAuthFeedback('Username is required', 'warning'); + if (authButton) { + authButton.disabled = false; + authButton.style.opacity = '1'; + } return; } response = await fetch(`${API_BASE}/register`, { @@ -249,9 +440,15 @@ async function handleAuth(event) { }); } - const data = await response.json(); + let data = {}; + try { + data = await response.json(); + } catch (parseError) { + data = { error: 'Server returned invalid response' }; + } if (!response.ok) { showToast(data.error || 'Authentication failed', 'error'); + setAuthFeedback(data.error || 'Authentication failed', 'error'); return; } @@ -260,12 +457,21 @@ async function handleAuth(event) { localStorage.setItem('token', currentToken); localStorage.setItem('user', JSON.stringify(currentUser)); saveAccountSession(); + const authSuccessMsg = isRegistering ? 'Registration successful. You are now logged in.' : 'Login successful. Welcome back.'; + setAuthFeedback(authSuccessMsg, 'success'); + showToast(authSuccessMsg, 'success'); showMainApp(); connectSocket(); } catch (error) { console.error(error); showToast('Error: ' + error.message, 'error'); + setAuthFeedback(`Error: ${error.message}`, 'error'); + } finally { + if (authButton) { + authButton.disabled = false; + authButton.style.opacity = '1'; + } } } @@ -356,17 +562,154 @@ function connectSocket() { }); socket.on('users update', (users) => { - updateMembersList(users); + globalOnlineUsers = Array.isArray(users) ? users : []; + if (currentChatContext?.type === 'group' || currentChatContext?.type === 'community') { + const byId = new Map(); + (currentRoomMembers || []).forEach((m) => { + const uid = String(m?.id || m?.userId || ''); + if (!uid) return; + byId.set(uid, { ...m }); + }); + + (globalOnlineUsers || []).forEach((u) => { + const uid = String(u?.userId || u?.id || u?._id || ''); + if (!uid || byId.has(uid)) return; + byId.set(uid, { + id: uid, + username: u?.username || 'User', + avatar: u?.avatar || null, + role: 'Online' + }); + }); + + renderMembersSidebarFromRoom(Array.from(byId.values())); + } else { + updateMembersList(globalOnlineUsers); + } + }); + + socket.on('room members update', (payload) => { + const roomType = payload?.roomType; + const roomName = payload?.roomName; + const members = Array.isArray(payload?.members) ? payload.members : []; + const roomKey = getRoomCacheKey(roomType, roomName); + const nextRoles = {}; + members.forEach((m) => { + const uid = String(m.id || m.userId || ''); + if (uid) nextRoles[uid] = m.role || 'Member'; + }); + roomRoleCache[roomKey] = nextRoles; + localStorage.setItem('roomRoleCache', JSON.stringify(roomRoleCache)); + + if (roomType === 'group' && roomName) { + groupMemberRoles[roomName] = nextRoles; + localStorage.setItem('groupMemberRoles', JSON.stringify(groupMemberRoles)); + } + + const expectedKey = normalizeRoomKeyClient(currentChatContext?.type, currentChatContext?.name); + const incomingKey = normalizeRoomKeyClient(roomType, roomName); + if (expectedKey !== incomingKey) return; + + currentRoomMembers = members; + renderMembersSidebarFromRoom(currentRoomMembers); + }); + + socket.on('room profile updated', (payload) => { + const roomType = payload?.roomType; + const roomName = payload?.roomName; + const previousRoomName = payload?.previousRoomName || roomName; + const profile = payload?.profile || {}; + if (!roomType || !roomName) return; + + cacheRoomProfile(roomType, roomName, profile); + + if (roomType === 'group') { + const previousProfile = groupProfiles[previousRoomName] || groupProfiles[roomName] || {}; + const merged = { + ...previousProfile, + ...profile, + name: profile?.name || roomName + }; + if (previousRoomName !== roomName) { + delete groupProfiles[previousRoomName]; + delete groupMemberRoles[previousRoomName]; + } + groupProfiles[roomName] = merged; + localStorage.setItem('groupProfiles', JSON.stringify(groupProfiles)); + + const index = joinedGroups.findIndex((g) => String(g) === String(previousRoomName)); + if (index >= 0) { + joinedGroups[index] = roomName; + localStorage.setItem('joinedGroups', JSON.stringify(joinedGroups)); + } + } + + if (roomType === 'community') { + const previousProfile = communityProfiles[previousRoomName] || communityProfiles[roomName] || {}; + const merged = { + ...previousProfile, + ...profile, + name: profile?.name || roomName + }; + if (previousRoomName !== roomName) { + delete communityProfiles[previousRoomName]; + } + communityProfiles[roomName] = merged; + localStorage.setItem('communityProfiles', JSON.stringify(communityProfiles)); + + const index = joinedCommunities.findIndex((g) => String(g) === String(previousRoomName)); + if (index >= 0) { + joinedCommunities[index] = roomName; + localStorage.setItem('joinedCommunities', JSON.stringify(joinedCommunities)); + } + } + + if (currentChatContext?.type === roomType && String(currentChatContext?.name) === String(previousRoomName)) { + const isRenameEvent = String(previousRoomName) !== String(roomName); + currentChatContext.name = roomName; + currentChatContext.id = roomName; + + if (isRenameEvent) { + const oldKey = normalizeRoomKeyClient(roomType, previousRoomName); + const nextKey = normalizeRoomKeyClient(roomType, roomName); + if (roomMessages[oldKey] && !roomMessages[nextKey]) { + roomMessages[nextKey] = roomMessages[oldKey]; + delete roomMessages[oldKey]; + saveRoomMessages(); + } + + if (roomType === 'group') openGroupChat(roomName); + if (roomType === 'community') openCommunityChat(roomName); + } else { + const titleEl = document.getElementById('chatTitle'); + if (titleEl) { + const profile = roomType === 'group' ? getGroupProfile(roomName) : getCommunityProfile(roomName); + titleEl.textContent = roomType === 'group' + ? `${profile.icon || '๐ฅ'} Group: ${roomName}` + : `${profile.icon || '๐'} Community: ${roomName}`; + } + } + } + + if (roomType === 'group' || roomType === 'community') { + const expectedKey = normalizeRoomKeyClient(currentChatContext?.type, currentChatContext?.name); + const incomingKey = normalizeRoomKeyClient(roomType, roomName); + if (expectedKey === incomingKey) { + renderMembersSidebarFromRoom(currentRoomMembers || []); + } + } + + loadFriendsForDM(); }); socket.on('room chat history', (payload) => { const roomType = payload?.roomType; const roomName = payload?.roomName; - const roomKey = String(roomType) + ':' + String(roomName); + const roomKey = normalizeRoomKeyClient(roomType, roomName); roomMessages[roomKey] = Array.isArray(payload?.messages) ? payload.messages : []; saveRoomMessages(); - const isCurrentRoom = currentChatContext?.type === roomType && String(currentChatContext?.name) === String(roomName); + const isCurrentRoom = normalizeRoomKeyClient(currentChatContext?.type, currentChatContext?.name) === normalizeRoomKeyClient(roomType, roomName); if (isCurrentRoom) { renderRoomMessages(roomKey, roomName, roomType); } @@ -377,17 +720,23 @@ function connectSocket() { const roomName = payload?.roomName; if (!roomType || !roomName) return; - const roomKey = String(roomType) + ':' + String(roomName); + const roomKey = normalizeRoomKeyClient(roomType, roomName); if (!roomMessages[roomKey]) { roomMessages[roomKey] = []; } - roomMessages[roomKey].push(payload); + const msgId = payload?.messageId || payload?.id; + const exists = msgId ? roomMessages[roomKey].some((m) => String(m?.messageId || m?.id) === String(msgId)) : false; + if (!exists) { + roomMessages[roomKey].push(payload); + } saveRoomMessages(); - const isCurrentRoom = currentChatContext?.type === roomType && String(currentChatContext?.name) === String(roomName); + const isCurrentRoom = normalizeRoomKeyClient(currentChatContext?.type, currentChatContext?.name) === normalizeRoomKeyClient(roomType, roomName); if (isCurrentRoom) { - - if (String(payload.from) !== String(currentUser.id)) { + const alreadyRendered = payload?.messageId + ? !!document.querySelector(`.message[data-message-id="${String(payload.messageId)}"]`) + : false; + if (!alreadyRendered) { addMessageToChat(payload); } } else if (payload.from !== currentUser.id && chatSettings.notifyOnReceived) { @@ -400,6 +749,7 @@ function connectSocket() { { type: roomType, name: roomName } ); playNotificationSound(); + pushNotification(`${roomLabel === 'Community' ? '๐' : '๐ฅ'} ${roomName}`, `${payload.fromUsername}: ${messagePreview}`, { type: roomType, name: roomName }); } }); @@ -408,11 +758,11 @@ function connectSocket() { const roomName = payload?.roomName; if (!roomType || !roomName) return; - const roomKey = String(roomType) + ':' + String(roomName); + const roomKey = normalizeRoomKeyClient(roomType, roomName); roomMessages[roomKey] = []; saveRoomMessages(); - const isCurrentRoom = currentChatContext?.type === roomType && String(currentChatContext?.name) === String(roomName); + const isCurrentRoom = normalizeRoomKeyClient(currentChatContext?.type, currentChatContext?.name) === normalizeRoomKeyClient(roomType, roomName); if (isCurrentRoom) { document.getElementById('messages-container').innerHTML = ''; refreshChatScrollbar(); @@ -462,6 +812,7 @@ function connectSocket() { ); showDesktopNotification(`New message from ${data.fromUsername}`, messagePreview, false); playNotificationSound(); + pushNotification(`๐ฌ ${data.fromUsername}`, messagePreview, { type: 'dm', friendId: otherUserId, friendName: data.fromUsername }); } }); @@ -555,7 +906,12 @@ function connectSocket() { showToast(`${byName} accepted the call`, 'success'); const peerId = activeCallState?.peerId || payload?.byUserId; if (peerId) { - startCallSession(byName, peerId, true); + closeCustomDialog(); + startCallSession(byName, peerId, true).catch((error) => { + console.error('Failed to start outgoing call session:', error); + showToast('Unable to start camera/microphone for this call', 'error'); + cleanupCallSession(false); + }); } }); @@ -565,6 +921,8 @@ function connectSocket() { showToast(`${byName} declined the call`, 'warning'); activeCallState = null; closeCustomDialog(); + // Close overlay if open (waiting screen) + document.getElementById('callOverlay')?.classList.remove('active'); }); socket.on('video call unavailable', () => { @@ -592,9 +950,15 @@ function connectSocket() { if (!activeCallState) { activeCallState = { callId: payload?.callId || null, peerId: fromId, direction: 'incoming' }; } + if (isStartingCallSession) { + // Session setup in progress; save offer to process after setup completes + pendingSignalOffer = { fromId, offer: signal.offer }; + return; + } if (!peerConnection) { await startCallSession(fromName, fromId, false); } + if (!peerConnection) return; await peerConnection.setRemoteDescription(new RTCSessionDescription(signal.offer)); await flushQueuedIceCandidates(); const answer = await peerConnection.createAnswer(); @@ -608,8 +972,13 @@ function connectSocket() { } if (signal.type === 'answer' && peerConnection) { - await peerConnection.setRemoteDescription(new RTCSessionDescription(signal.answer)); - await flushQueuedIceCandidates(); + // Only apply answer when we actually sent an offer - prevents 'wrong state: stable' crash + if (peerConnection.signalingState !== 'have-local-offer') { + console.warn('Ignoring stale answer, signaling state:', peerConnection.signalingState); + } else { + await peerConnection.setRemoteDescription(new RTCSessionDescription(signal.answer)); + await flushQueuedIceCandidates(); + } } if (signal.type === 'ice-candidate' && peerConnection && signal.candidate) { @@ -744,6 +1113,11 @@ function openDM(friendId, friendName) { if (socket && socket.connected) { socket.emit('join dm', friendId); } + + currentRoomMembers = []; + if (globalOnlineUsers.length > 0) { + updateMembersList(globalOnlineUsers); + } showSection('chat'); @@ -797,7 +1171,15 @@ function openProfile() { panel.style.removeProperty('pointer-events'); panel.style.removeProperty('opacity'); panel.style.removeProperty('transform'); + panel.style.overflowY = 'auto'; + panel.style.webkitOverflowScrolling = 'touch'; + panel.style.height = '100dvh'; + panel.style.maxHeight = '100dvh'; + panel.style.touchAction = 'pan-y'; panel.classList.add('show'); + panel.scrollTop = 0; + const backdrop = document.getElementById('profileBackdrop'); + if (backdrop) backdrop.style.display = 'block'; loadUserProfile(); } @@ -817,10 +1199,96 @@ function shareProfileLink() { }); } +// ==================== PROFILE BANNER ==================== +const BANNER_PRESETS = [ + 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800&q=80', // night sky + 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&q=80', // abstract geo + 'https://images.unsplash.com/photo-1528459801416-a9e53bbf4e17?w=800&q=80', // purple wave + 'https://images.unsplash.com/photo-1550684376-efcbd6e3f031?w=800&q=80', // aurora + '#gradient:135,#667eea,#764ba2', + '#gradient:135,#f093fb,#f5576c', + '#gradient:135,#4facfe,#00f2fe', + '#gradient:135,#43e97b,#38f9d7', +]; + +function openBannerPicker() { + const presetsHtml = BANNER_PRESETS.map((p, i) => { + const isGradient = p.startsWith('#gradient:'); + const style = isGradient + ? (() => { const parts = p.replace('#gradient:','').split(','); return `background: linear-gradient(${parts[0]}deg, ${parts[1]}, ${parts[2]})`; })() + : `background-image: url('${p}'); background-size: cover; background-position: center;`; + return `
`; + }).join(''); + + const html = ` +Choose a preset or upload your own image.
+No shop items available
'; } else { items.forEach(item => { const itemDiv = document.createElement('div'); + const canAfford = userCoins >= Number(item.price || 0); itemDiv.className = 'shop-item'; itemDiv.innerHTML = `Click on a chat to open it, or unarchive to move it back to your main chat list.
-๐ก Video Call Ready!
Waiting for the other user to accept...