diff --git "a/client.js" "b/client.js"
--- "a/client.js"
+++ "b/client.js"
@@ -8,10 +8,22 @@ let currentChatFriendName = null;
let currentChatContext = { type: 'none', id: null, name: null };
let pendingMediaDraft = null;
let pendingMediaDrafts = [];
+let activeCallState = null;
+let peerConnection = null;
+let localCallStream = null;
+let currentCallPeerId = null;
+let currentCallPeerName = null;
+let callTimerInterval = null;
+let callStartedAt = null;
+let incomingCallAudioContext = null;
+let incomingCallInterval = null;
+let activeRoomSubscription = null;
let roomMessages = JSON.parse(localStorage.getItem('roomMessages') || '{}');
let pinnedChats = [];
let joinedCommunities = [];
let joinedGroups = [];
+let communityProfiles = JSON.parse(localStorage.getItem('communityProfiles') || '{}');
+let groupProfiles = JSON.parse(localStorage.getItem('groupProfiles') || '{}');
let chatSettings = {
disappearTime: 0,
enterToSend: true,
@@ -43,7 +55,92 @@ let themeSettings = {
}
};
-const API_BASE = '/api';
+// ==================== SERVER CONFIGURATION ====================
+// Always use same-origin backend so it works on Railway, custom domains, LAN IPs, and localhost.
+const SERVER_URL = window.location.origin;
+const API_BASE = SERVER_URL + '/api';
+
+console.log(`๐ Connected to: ${SERVER_URL}`);
+
+async function syncBuildVersionBadge() {
+ try {
+ const badge = document.getElementById('buildVersionBadge');
+ if (!badge) return;
+
+ const response = await fetch(`${API_BASE}/version`, { cache: 'no-store' });
+ if (!response.ok) return;
+
+ const data = await response.json();
+ if (data?.version) {
+ badge.textContent = `v${data.version}`;
+ badge.title = `Running backend version: ${data.version}`;
+ }
+ } catch (error) {
+ // Keep fallback badge text if version endpoint is unavailable.
+ }
+}
+
+function startCallTimer() {
+ stopCallTimer();
+ callStartedAt = Date.now();
+ const timerLabel = document.getElementById('callTimerLabel');
+ if (!timerLabel) return;
+
+ callTimerInterval = setInterval(() => {
+ const elapsed = Math.floor((Date.now() - callStartedAt) / 1000);
+ const hrs = String(Math.floor(elapsed / 3600)).padStart(2, '0');
+ const mins = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0');
+ const secs = String(elapsed % 60).padStart(2, '0');
+ timerLabel.textContent = `${hrs}:${mins}:${secs}`;
+ }, 1000);
+}
+
+function stopCallTimer() {
+ if (callTimerInterval) {
+ clearInterval(callTimerInterval);
+ callTimerInterval = null;
+ }
+ callStartedAt = null;
+}
+
+function startIncomingCallAlert() {
+ stopIncomingCallAlert();
+
+ if (navigator.vibrate) {
+ navigator.vibrate([300, 200, 300, 200, 300]);
+ }
+
+ incomingCallInterval = setInterval(() => {
+ try {
+ if (!incomingCallAudioContext) {
+ incomingCallAudioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ const osc = incomingCallAudioContext.createOscillator();
+ const gain = incomingCallAudioContext.createGain();
+ osc.type = 'sine';
+ osc.frequency.value = 880;
+ gain.gain.value = 0.05;
+ osc.connect(gain);
+ gain.connect(incomingCallAudioContext.destination);
+ osc.start();
+ osc.stop(incomingCallAudioContext.currentTime + 0.15);
+ } catch (error) {
+ // Browser may block sound until user gesture; visual prompt still appears.
+ }
+ }, 900);
+}
+
+function stopIncomingCallAlert() {
+ if (incomingCallInterval) {
+ clearInterval(incomingCallInterval);
+ incomingCallInterval = null;
+ }
+
+ if (navigator.vibrate) {
+ navigator.vibrate(0);
+ }
+}
// ==================== AUTH FUNCTIONS ====================
@@ -241,10 +338,16 @@ function switchAccount() {
// ==================== SOCKET.IO CONNECTION ====================
function connectSocket() {
- socket = io();
+ socket = io(SERVER_URL, {
+ reconnection: true,
+ reconnectionDelay: 1000,
+ reconnectionDelayMax: 5000,
+ reconnectionAttempts: 5,
+ transports: ['websocket', 'polling']
+ });
socket.on('connect', () => {
- console.log('Connected to server');
+ console.log('โ
Connected to server:', SERVER_URL);
socket.emit('join', { token: currentToken });
});
@@ -252,30 +355,118 @@ function connectSocket() {
updateMembersList(users);
});
+ socket.on('room chat history', (payload) => {
+ const roomType = payload?.roomType;
+ const roomName = payload?.roomName;
+ const roomKey = String(roomType) + ':' + String(roomName);
+ roomMessages[roomKey] = Array.isArray(payload?.messages) ? payload.messages : [];
+ saveRoomMessages();
+
+ const isCurrentRoom = currentChatContext?.type === roomType && String(currentChatContext?.name) === String(roomName);
+ if (isCurrentRoom) {
+ renderRoomMessages(roomKey, roomName, roomType);
+ }
+ });
+
+ socket.on('room message', (payload) => {
+ const roomType = payload?.roomType;
+ const roomName = payload?.roomName;
+ if (!roomType || !roomName) return;
+
+ const roomKey = String(roomType) + ':' + String(roomName);
+ if (!roomMessages[roomKey]) {
+ roomMessages[roomKey] = [];
+ }
+ roomMessages[roomKey].push(payload);
+ saveRoomMessages();
+
+ const isCurrentRoom = currentChatContext?.type === roomType && String(currentChatContext?.name) === String(roomName);
+ if (isCurrentRoom) {
+ // Only show if from someone else to avoid duplicates from optimistic updates
+ if (String(payload.from) !== String(currentUser.id)) {
+ addMessageToChat(payload);
+ }
+ } else if (payload.from !== currentUser.id && chatSettings.notifyOnReceived) {
+ // Show notification for room message when not in that room
+ const messagePreview = payload.mediaType === 'text' ? payload.content : `${payload.mediaType} message`;
+ const roomLabel = roomType === 'community' ? 'Community' : 'Group';
+ showToastClickable(
+ `New message in ${roomLabel} "${roomName}" from ${payload.fromUsername}: ${messagePreview}`,
+ 'info',
+ { type: roomType, name: roomName }
+ );
+ playNotificationSound();
+ }
+ });
+
+ socket.on('room chat cleared', (payload) => {
+ const roomType = payload?.roomType;
+ const roomName = payload?.roomName;
+ if (!roomType || !roomName) return;
+
+ const roomKey = String(roomType) + ':' + String(roomName);
+ roomMessages[roomKey] = [];
+ saveRoomMessages();
+
+ const isCurrentRoom = currentChatContext?.type === roomType && String(currentChatContext?.name) === String(roomName);
+ if (isCurrentRoom) {
+ document.getElementById('messages-container').innerHTML = '';
+ refreshChatScrollbar();
+ }
+ });
+
+ socket.on('dm history cleared', (payload) => {
+ const fromUserId = payload?.fromUserId;
+ if (!fromUserId) return;
+
+ // If we're currently chatting with the user who cleared history, clear our UI too
+ if (currentChatContext?.type === 'dm' && String(currentChatContext?.id) === String(fromUserId)) {
+ document.getElementById('messages-container').innerHTML = '';
+ refreshChatScrollbar();
+ showToast('Chat history was cleared', 'info');
+ }
+ });
socket.on('dm message', (data) => {
const fromId = String(data.from);
const toId = String(data.to);
const myId = String(currentUser.id);
const isSender = fromId === myId;
const otherUserId = isSender ? toId : fromId;
- const isCurrentChat = currentChatFriendId && String(currentChatFriendId) === String(otherUserId);
+ const isCurrentChat = (
+ currentChatContext.type === 'dm' &&
+ currentChatContext.id &&
+ String(currentChatContext.id) === String(otherUserId)
+ );
if (isCurrentChat) {
- addMessageToChat(data);
+ // Only add to chat if we're the receiver (to avoid duplicates from optimistic updates)
+ if (!isSender) {
+ addMessageToChat(data);
+ }
}
if (isSender && chatSettings.notifyOnSent) {
- showToast('Message sent', 'success');
+ // Don't show toast for sent messages (already optimistically shown)
}
if (!isSender && chatSettings.notifyOnReceived) {
const messagePreview = data.mediaType === 'text' ? data.content : `${data.mediaType} message`;
- showToast(`New message from ${data.fromUsername}: ${messagePreview}`, 'warning');
+ showToastClickable(
+ `New message from ${data.fromUsername}: ${messagePreview}`,
+ 'info',
+ { type: 'dm', friendId: otherUserId, friendName: data.fromUsername }
+ );
showDesktopNotification(`New message from ${data.fromUsername}`, messagePreview, false);
playNotificationSound();
}
});
+ socket.on('dm delivery status', (data) => {
+ if (data && data.delivered === false) {
+ showToast('Message sent, but recipient is currently offline', 'warning');
+ }
+ });
+
socket.on('dm user typing', (data) => {
// Display typing indicator for DM
showTypingIndicator(data.username);
@@ -286,10 +477,143 @@ function connectSocket() {
hideTypingIndicator(data.username);
});
+ socket.on('community user typing', (data) => {
+ // Display typing indicator for community chats
+ if (currentChatContext.type === 'community' && currentChatContext.name === data.community) {
+ showTypingIndicator(data.username);
+ }
+ });
+
+ socket.on('community user stop typing', (data) => {
+ if (currentChatContext.type === 'community' && currentChatContext.name === data.community) {
+ hideTypingIndicator();
+ }
+ });
+
+ socket.on('room user typing', (data) => {
+ // Display typing indicator for room/group chats
+ if ((currentChatContext.type === 'group' || currentChatContext.type === 'room') && String(currentChatContext.id) === String(data.roomId)) {
+ showTypingIndicator(data.username);
+ }
+ });
+
+ socket.on('room user stop typing', (data) => {
+ if ((currentChatContext.type === 'group' || currentChatContext.type === 'room') && String(currentChatContext.id) === String(data.roomId)) {
+ hideTypingIndicator();
+ }
+ });
+
socket.on('error', (msg) => {
console.error('Socket error:', msg);
showToast(msg || 'Realtime error', 'error');
});
+
+ socket.on('friend request received', (payload) => {
+ const senderName = payload?.fromUsername || 'Someone';
+ showToast(`New friend request from ${senderName}`, 'warning');
+ if (document.getElementById('friendsModal') && !document.getElementById('friendsModal').classList.contains('hidden')) {
+ loadFriends();
+ }
+ });
+
+ socket.on('friend request accepted', (payload) => {
+ if (!currentUser) return;
+ if (String(payload?.fromId) === String(currentUser.id) || String(payload?.toId) === String(currentUser.id)) {
+ loadFriendsForDM();
+ if (document.getElementById('friendsModal') && !document.getElementById('friendsModal').classList.contains('hidden')) {
+ loadFriends();
+ }
+ }
+ });
+
+ socket.on('friend request rejected', (payload) => {
+ if (!currentUser) return;
+ if (String(payload?.fromId) === String(currentUser.id) || String(payload?.toId) === String(currentUser.id)) {
+ if (document.getElementById('friendsModal') && !document.getElementById('friendsModal').classList.contains('hidden')) {
+ loadFriends();
+ }
+ }
+ });
+
+ socket.on('incoming video call', (payload) => {
+ handleIncomingVideoCall(payload);
+ });
+
+ socket.on('video call ringing', (payload) => {
+ const targetName = currentChatContext?.name || 'user';
+ activeCallState = { callId: payload.callId, peerId: payload.toId, direction: 'outgoing' };
+ showToast(`Calling ${targetName}...`, 'success');
+ });
+
+ socket.on('video call accepted', (payload) => {
+ const byName = payload?.byUsername || 'User';
+ stopIncomingCallAlert();
+ showToast(`${byName} accepted the call`, 'success');
+ const peerId = activeCallState?.peerId || payload?.byUserId;
+ if (peerId) {
+ startCallSession(byName, peerId, true);
+ }
+ });
+
+ socket.on('video call rejected', (payload) => {
+ const byName = payload?.byUsername || 'User';
+ stopIncomingCallAlert();
+ showToast(`${byName} declined the call`, 'warning');
+ activeCallState = null;
+ closeCustomDialog();
+ });
+
+ socket.on('video call unavailable', () => {
+ stopIncomingCallAlert();
+ showToast('User is currently unavailable for calls', 'warning');
+ cleanupCallSession(false);
+ });
+
+ socket.on('video call ended', (payload) => {
+ const byName = payload?.byUsername || 'User';
+ stopIncomingCallAlert();
+ showToast(`Call ended by ${byName}`, 'warning');
+ cleanupCallSession(false);
+ });
+
+ socket.on('video signal', async (payload) => {
+ const signal = payload?.signal;
+ const fromId = payload?.fromId;
+ const fromName = payload?.fromUsername || 'User';
+
+ if (!signal || !fromId) return;
+
+ try {
+ if (signal.type === 'offer') {
+ if (!activeCallState) {
+ activeCallState = { callId: payload?.callId || null, peerId: fromId, direction: 'incoming' };
+ }
+ if (!peerConnection) {
+ await startCallSession(fromName, fromId, false);
+ }
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(signal.offer));
+ const answer = await peerConnection.createAnswer();
+ await peerConnection.setLocalDescription(answer);
+
+ socket.emit('video signal', {
+ toId: fromId,
+ callId: activeCallState?.callId,
+ signal: { type: 'answer', answer }
+ });
+ }
+
+ if (signal.type === 'answer' && peerConnection) {
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(signal.answer));
+ }
+
+ if (signal.type === 'ice-candidate' && peerConnection && signal.candidate) {
+ await peerConnection.addIceCandidate(new RTCIceCandidate(signal.candidate));
+ }
+ } catch (error) {
+ console.error('Video signaling error:', error);
+ showToast('Video call signaling failed', 'error');
+ }
+ });
}
function showTypingIndicator(username) {
@@ -313,6 +637,48 @@ function hideTypingIndicator() {
function showMainApp() {
document.getElementById('authContainer').style.display = 'none';
document.getElementById('mainApp').classList.remove('hidden');
+
+ // AGGRESSIVELY close all panels on startup (especially for mobile)
+ try {
+ // Close profile panel - multiple methods to ensure it stays closed
+ const profilePanel = document.getElementById('profilePanel');
+ if (profilePanel) {
+ profilePanel.classList.remove('show');
+ profilePanel.style.display = 'none';
+ profilePanel.style.visibility = 'hidden';
+ profilePanel.style.pointerEvents = 'none';
+ profilePanel.style.opacity = '0';
+ profilePanel.style.transform = 'translateX(500px)';
+ }
+
+ // Close settings panel
+ const settingsPanel = document.getElementById('settingsPanel');
+ if (settingsPanel) {
+ settingsPanel.classList.remove('show');
+ settingsPanel.style.right = '-500px';
+ }
+
+ // Close mobile members panel
+ const mobileMembersPanel = document.getElementById('mobileMembersPanel');
+ if (mobileMembersPanel) {
+ mobileMembersPanel.classList.remove('show');
+ }
+
+ // Close sidebar overlay
+ const sidebarOverlay = document.getElementById('sidebarOverlay');
+ if (sidebarOverlay) {
+ sidebarOverlay.classList.remove('show');
+ }
+
+ // Close channel sidebar on mobile
+ const channelSidebar = document.getElementById('channelSidebar');
+ if (channelSidebar) {
+ channelSidebar.classList.remove('show');
+ }
+ } catch (e) {
+ console.log('Error closing panels:', e);
+ }
+
loadUserProfile();
loadShopItems();
@@ -331,6 +697,9 @@ function showSection(section) {
document.querySelector('.chat-area').style.display = 'flex';
document.getElementById('friendsModal').classList.add('hidden');
document.getElementById('shopModal').classList.add('hidden');
+ if (currentChatContext.type === 'community' || currentChatContext.type === 'group') {
+ subscribeToRoomChat(currentChatContext.type, currentChatContext.name);
+ }
loadFriendsForDM();
} else if (section === 'friends') {
document.querySelector('.chat-area').style.display = 'none';
@@ -344,17 +713,24 @@ function showSection(section) {
}
function openDM(friendId, friendName) {
+ if (activeRoomSubscription && socket && socket.connected) {
+ socket.emit('leave room chat', activeRoomSubscription);
+ }
+ activeRoomSubscription = null;
currentChatFriendId = friendId;
currentChatFriendName = friendName;
currentChatContext = { type: 'dm', id: friendId, name: friendName };
clearPendingMediaDraft();
+ updatePinButtonState();
// Check if chatting with self
- const isSelfChat = friendId === currentUser.id;
+ const isSelfChat = String(friendId) === String(currentUser.id);
const displayName = isSelfChat ? `๐ ${currentUser.username} (Notes)` : `๐ฌ ${friendName}`;
// Update UI
- document.getElementById('chatTitle').textContent = displayName;
+ const chatTitleEl = document.getElementById('chatTitle');
+ chatTitleEl.textContent = displayName;
+ chatTitleEl.onclick = null;
document.getElementById('messages-container').innerHTML = '';
refreshChatScrollbar();
@@ -370,6 +746,11 @@ function openDM(friendId, friendName) {
}
showSection('chat');
+
+ // Close mobile sidebar if open
+ if (window.innerWidth <= 480) {
+ closeMobileSidebar();
+ }
}
async function loadDMMessages(friendId) {
@@ -399,7 +780,13 @@ async function loadDMMessages(friendId) {
}
function toggleChannelSidebar() {
- document.getElementById('channelSidebar').classList.toggle('show');
+ const sidebar = document.getElementById('channelSidebar');
+ const overlay = document.getElementById('sidebarOverlay');
+
+ sidebar?.classList.toggle('show');
+ if (overlay) {
+ overlay.classList.toggle('show');
+ }
}
function openProfile() {
@@ -808,6 +1195,21 @@ async function sendMessage() {
connectSocket();
return;
}
+
+ // Optimistically display message immediately
+ const optimisticMessage = {
+ id: Date.now(),
+ from: currentUser.id,
+ fromUsername: currentUser.username,
+ fromAvatar: currentUser.avatar,
+ to: targetId,
+ content: content,
+ mediaType: 'text',
+ timestamp: new Date().toISOString()
+ };
+ addMessageToChat(optimisticMessage);
+
+ // Send to server without waiting
socket.emit('send dm', {
toUserId: targetId,
content: content,
@@ -926,8 +1328,32 @@ function addMessageToChat(data) {
deleteBtn.innerHTML = '๐๏ธ';
deleteBtn.title = 'Delete';
deleteBtn.onclick = () => deleteMessage(messageDiv.dataset.messageId, messageDiv);
+
+ const replyBtn = document.createElement('button');
+ replyBtn.className = 'message-action-btn';
+ replyBtn.innerHTML = 'โฉ๏ธ';
+ replyBtn.title = 'Reply';
+ replyBtn.onclick = (e) => {
+ e.stopPropagation();
+ const input = document.getElementById('messageInput');
+ if (!input) return;
+ const previewText = (data.content || '').trim().slice(0, 40);
+ input.value = `@${data.fromUsername || 'user'} ${previewText ? `(${previewText}) ` : ''}`;
+ input.focus();
+ };
+
+ const pinChatBtn = document.createElement('button');
+ pinChatBtn.className = 'message-action-btn';
+ pinChatBtn.innerHTML = '๐';
+ pinChatBtn.title = 'Pin this chat';
+ pinChatBtn.onclick = (e) => {
+ e.stopPropagation();
+ togglePinCurrentChat();
+ };
actions.appendChild(reactBtn);
+ actions.appendChild(replyBtn);
+ actions.appendChild(pinChatBtn);
if (String(data.from) === String(currentUser?.id)) {
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
@@ -1089,6 +1515,12 @@ function updateMembersList(users) {
item.appendChild(name);
membersList.appendChild(item);
});
+
+ // Also update mobile members list
+ const mobileMembersList = document.getElementById('mobileMembersList');
+ if (mobileMembersList) {
+ mobileMembersList.innerHTML = membersList.innerHTML;
+ }
}
// ==================== FILE UPLOAD ====================
@@ -1106,26 +1538,41 @@ async function handleFileUpload(event) {
return;
}
+ const targetId = currentChatContext.type === 'dm' ? ensureChatTarget() : null;
+ let sentCount = 0;
+
for (const file of files) {
- await new Promise((resolve) => {
+ // Read and send each media file immediately after selection.
+ // This removes the extra "press send" step and feels instant like WhatsApp.
+ const payload = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target.result;
const mediaType = file.type.startsWith('image') ? 'image' : 'video';
- setPendingMediaDraft({
+ resolve({
+ content: file.name || (mediaType === 'image' ? 'Photo' : 'Video'),
mediaType,
- mediaUrl: data,
- content: file.name || (mediaType === 'image' ? 'Photo' : 'Video')
+ mediaUrl: data
});
- resolve();
};
- reader.onerror = () => resolve();
+ reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
+
+ if (!payload) continue;
+
+ if (currentChatContext.type === 'community' || currentChatContext.type === 'group') {
+ sendRoomMessage(payload);
+ sentCount += 1;
+ } else if (targetId) {
+ await sendStagedDm(targetId, payload);
+ sentCount += 1;
+ }
}
- const targetName = currentChatContext.name || currentChatFriendName || 'this chat';
- showToast(`${files.length} media file(s) selected for ${targetName}. Press Send to post.`, 'success');
+ if (sentCount > 0) {
+ showToast(`Sent ${sentCount} media file(s)`, 'success');
+ }
// Reset the input
event.target.value = '';
@@ -1179,24 +1626,32 @@ async function loadFriendsForDM() {
const channelsList = document.getElementById('channelsList');
channelsList.innerHTML = '';
- if (pinnedChats.length > 0) {
+ // Separate pinned and unpinned friends
+ const pinnedFriends = [];
+ const unpinnedFriends = [];
+
+ friends.forEach(friend => {
+ const chatId = `dm:${friend.id}`;
+ if (pinnedChats.includes(chatId)) {
+ pinnedFriends.push(friend);
+ } else {
+ unpinnedFriends.push(friend);
+ }
+ });
+
+ // Show pinned section if there are pinned chats
+ if (pinnedFriends.length > 0) {
const pinnedTitle = document.createElement('div');
pinnedTitle.className = 'channel-section-title';
- pinnedTitle.textContent = 'PINNED CHATS';
+ pinnedTitle.textContent = '๐ PINNED';
channelsList.appendChild(pinnedTitle);
- pinnedChats.forEach(chat => {
- const pinnedItem = document.createElement('div');
- pinnedItem.className = 'channel-item';
- pinnedItem.innerHTML = `
-
๐
- ${chat.name}
- `;
- pinnedItem.onclick = () => openDM(chat.id, chat.name);
- channelsList.appendChild(pinnedItem);
+ pinnedFriends.forEach(friend => {
+ createFriendChannelItem(friend, channelsList, true);
});
}
+ // Show communities if any
if (joinedCommunities.length > 0) {
const communitiesTitle = document.createElement('div');
communitiesTitle.className = 'channel-section-title';
@@ -1204,11 +1659,20 @@ async function loadFriendsForDM() {
channelsList.appendChild(communitiesTitle);
joinedCommunities.forEach(community => {
+ const profile = getCommunityProfile(community);
+ const communityAvatar = profile.image
+ ? ``
+ : `${profile.icon || '๐'}
`;
const item = document.createElement('div');
+ const chatId = `community:${community}`;
item.className = 'channel-item';
+ if (pinnedChats.includes(chatId)) {
+ item.classList.add('pinned');
+ }
item.innerHTML = `
- ๐
+ ${communityAvatar}
${community}
+ ${pinnedChats.includes(chatId) ? '' : ''}
`;
item.style.cursor = 'pointer';
item.onclick = () => openCommunityChat(community);
@@ -1216,6 +1680,7 @@ async function loadFriendsForDM() {
});
}
+ // Show groups if any
if (joinedGroups.length > 0) {
const groupsTitle = document.createElement('div');
groupsTitle.className = 'channel-section-title';
@@ -1223,11 +1688,20 @@ async function loadFriendsForDM() {
channelsList.appendChild(groupsTitle);
joinedGroups.forEach(group => {
+ const profile = getGroupProfile(group);
+ const groupAvatar = profile.image
+ ? ``
+ : `${profile.icon || '๐ฅ'}
`;
const item = document.createElement('div');
+ const chatId = `group:${group}`;
item.className = 'channel-item';
+ if (pinnedChats.includes(chatId)) {
+ item.classList.add('pinned');
+ }
item.innerHTML = `
- ๐ฅ
+ ${groupAvatar}
${group}
+ ${pinnedChats.includes(chatId) ? '' : ''}
`;
item.style.cursor = 'pointer';
item.onclick = () => openGroupChat(group);
@@ -1262,7 +1736,7 @@ async function loadFriendsForDM() {
selfItem.onclick = () => openDM(currentUser.id, `${currentUser.username} (Notes)`);
channelsList.appendChild(selfItem);
- if (friends.length === 0) {
+ if (unpinnedFriends.length === 0 && pinnedFriends.length === 0) {
const empty = document.createElement('div');
empty.style.padding = '10px';
empty.style.color = 'var(--text-secondary)';
@@ -1270,39 +1744,8 @@ async function loadFriendsForDM() {
empty.textContent = 'No friends yet';
channelsList.appendChild(empty);
} else {
- friends.forEach(friend => {
- const item = document.createElement('div');
- item.className = 'channel-item';
- item.dataset.friendId = friend._id;
-
- // Create avatar
- const avatar = document.createElement('div');
- avatar.className = 'channel-avatar';
- if (friend.avatar) {
- avatar.style.backgroundImage = `url(${friend.avatar})`;
- avatar.style.backgroundSize = 'cover';
- avatar.style.backgroundPosition = 'center';
- } else {
- avatar.textContent = friend.username.charAt(0).toUpperCase();
- }
-
- // Create text with status
- const text = document.createElement('span');
- const status = friend.status === 'online' ? '๐ข' : 'โซ';
- text.textContent = `${status} ${friend.username}`;
-
- item.appendChild(avatar);
- item.appendChild(text);
- item.style.cursor = 'pointer';
- item.onclick = () => openDM(friend._id, friend.username);
-
- // Add right-click context menu for profile
- item.oncontextmenu = (e) => {
- e.preventDefault();
- showFriendContextMenu(e, friend._id, friend.username);
- };
-
- channelsList.appendChild(item);
+ unpinnedFriends.forEach(friend => {
+ createFriendChannelItem(friend, channelsList, false);
});
}
} catch (error) {
@@ -1310,6 +1753,53 @@ async function loadFriendsForDM() {
}
}
+function createFriendChannelItem(friend, container, isPinned) {
+ const item = document.createElement('div');
+ item.className = 'channel-item';
+ item.dataset.friendId = friend.id;
+
+ if (isPinned) {
+ item.classList.add('pinned');
+ }
+
+ // Create avatar
+ const avatar = document.createElement('div');
+ avatar.className = 'channel-avatar';
+ if (friend.avatar) {
+ avatar.style.backgroundImage = `url(${friend.avatar})`;
+ avatar.style.backgroundSize = 'cover';
+ avatar.style.backgroundPosition = 'center';
+ } else {
+ avatar.textContent = friend.username.charAt(0).toUpperCase();
+ }
+
+ // Create text with status
+ const text = document.createElement('span');
+ const status = friend.status === 'online' ? '๐ข' : 'โซ';
+ text.textContent = `${status} ${friend.username}`;
+
+ item.appendChild(avatar);
+ item.appendChild(text);
+
+ if (isPinned) {
+ const pinIndicator = document.createElement('span');
+ pinIndicator.className = 'pin-indicator';
+ pinIndicator.innerHTML = '';
+ item.appendChild(pinIndicator);
+ }
+
+ item.style.cursor = 'pointer';
+ item.onclick = () => openDM(friend.id, friend.username);
+
+ // Add right-click context menu for profile
+ item.oncontextmenu = (e) => {
+ e.preventDefault();
+ showFriendContextMenu(e, friend.id, friend.username);
+ };
+
+ container.appendChild(item);
+}
+
async function loadFriends() {
try {
const [requests, friends] = await Promise.all([
@@ -1323,19 +1813,40 @@ async function loadFriends() {
const requestsDiv = document.getElementById('friendRequests');
requestsDiv.innerHTML = '';
- if (requests.length === 0) {
+ const latestRequestsBySender = new Map();
+ requests.forEach((req) => {
+ const senderId = req?.from?.id ?? `request-${req?.id}`;
+ const prev = latestRequestsBySender.get(senderId);
+ if (!prev || Number(req.id) > Number(prev.id)) {
+ latestRequestsBySender.set(senderId, req);
+ }
+ });
+
+ const normalizedRequests = Array.from(latestRequestsBySender.values())
+ .sort((a, b) => Number(b.id) - Number(a.id));
+
+ if (normalizedRequests.length === 0) {
requestsDiv.innerHTML = 'No pending requests
';
}
- requests.forEach(req => {
+ normalizedRequests.forEach((req) => {
const item = document.createElement('div');
item.className = 'friend-item';
+ item.style.display = 'flex';
+ item.style.justifyContent = 'space-between';
+ item.style.alignItems = 'center';
+ item.style.gap = '10px';
+ item.style.padding = '12px';
+ item.style.borderRadius = '10px';
+ item.style.border = '1px solid var(--border-color)';
+ item.style.background = 'rgba(255,255,255,0.02)';
item.innerHTML = `
-
+
${req.from.username}
-
pending
+
Pending request
-
-
+
+
+
`;
requestsDiv.appendChild(item);
@@ -1352,10 +1863,11 @@ async function loadFriends() {
item.style.cursor = 'pointer';
const status = friend.status === 'online' ? '๐ข Online' : 'โซ Offline';
item.innerHTML = `
-
+
${friend.username}
${status}
+
๐ค Profile
`;
friendsList.appendChild(item);
});
@@ -1375,12 +1887,18 @@ async function acceptFriendRequest(requestId) {
body: JSON.stringify({ requestId })
});
- if (response.ok) {
- showToast('Friend request accepted!');
- loadFriends();
+ const data = await response.json();
+ if (!response.ok) {
+ showToast(data.error || 'Failed to accept friend request', 'error');
+ return;
}
+
+ showToast('Friend request accepted!');
+ loadFriends();
+ loadFriendsForDM();
} catch (error) {
console.error(error);
+ showToast('Could not accept friend request', 'error');
}
}
@@ -1414,7 +1932,15 @@ async function loadShopItems() {
const response = await fetch(`${API_BASE}/shop`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
- const items = await response.json();
+ const apiItems = await response.json();
+ const items = Array.isArray(apiItems) && apiItems.length > 0 ? apiItems : [
+ { id: 'fallback_banner_neon', name: 'Neon Banner Effect', description: 'Animated neon gradient banner for your profile.', price: 450, category: 'Banners' },
+ { id: 'fallback_badge_founder', name: 'Founder Badge', description: 'Exclusive badge shown next to your username.', price: 320, category: 'Badges' },
+ { id: 'fallback_color_pack', name: 'Color Burst Pack', description: 'Unlock 12 vibrant accent color themes.', price: 380, category: 'Themes' },
+ { id: 'fallback_chat_fx', name: 'Message Glow FX', description: 'Subtle glow animation for your sent messages.', price: 260, category: 'Effects' },
+ { id: 'fallback_avatar_ring', name: 'Aura Avatar Ring', description: 'Premium animated ring around your avatar.', price: 520, category: 'Avatar' },
+ { id: 'fallback_nameplate', name: 'Crystal Nameplate', description: 'Polished nameplate style in member list.', price: 410, category: 'Nameplates' }
+ ];
const shopList = document.getElementById('shopList');
shopList.innerHTML = '';
@@ -1426,13 +1952,16 @@ async function loadShopItems() {
const itemDiv = document.createElement('div');
itemDiv.className = 'shop-item';
itemDiv.innerHTML = `
-
-
${item.name}
+
+
+
${item.name}
+
${item.category || 'Premium'}
+
${item.description}
${item.price} ๐ฐ
-
+
`;
shopList.appendChild(itemDiv);
@@ -1489,11 +2018,14 @@ async function loadInventory() {
inventory.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = 'shop-item';
+ itemDiv.style.cursor = 'pointer';
itemDiv.innerHTML = `
${item.itemId.name}
-
Qty: ${item.quantity}
+
${item.itemId.description}
+
Qty: ${item.quantity}
+
`;
inventoryList.appendChild(itemDiv);
});
@@ -1502,6 +2034,91 @@ async function loadInventory() {
}
}
+function applyShopItem(itemId, category) {
+ const effectsMap = {
+ 'Banners': () => {
+ document.documentElement.style.setProperty('--primary-color', '#00d4ff');
+ document.documentElement.style.setProperty('--primary-hover', '#00b8e6');
+ showToast('โจ Banner effect applied! Your profile now has a neon glow.', 'success');
+ },
+ 'Badges': () => {
+ showToast('๐ Badge equipped! It now appears next to your name.', 'success');
+ // Badge would be stored in user profile and shown in messages
+ },
+ 'Themes': () => {
+ const colors = ['#ff6b9d', '#c44569', '#4a69bd', '#6a89cc', '#60a3bc', '#78e08f', '#f6b93b', '#e55039'];
+ const randomColor = colors[Math.floor(Math.random() * colors.length)];
+ document.documentElement.style.setProperty('--primary-color', randomColor);
+ document.documentElement.style.setProperty('--primary-hover', randomColor + 'dd');
+ showToast('๐จ Theme applied! Your accent color has changed.', 'success');
+ },
+ 'Effects': () => {
+ // Message glow effect
+ const style = document.createElement('style');
+ style.id = 'message-glow-effect';
+ style.textContent = `
+ .message[data-sender="${currentUser.id}"] .message-content {
+ animation: messageGlow 2s ease-in-out infinite;
+ }
+ @keyframes messageGlow {
+ 0%, 100% { box-shadow: 0 0 5px rgba(88, 101, 242, 0.3); }
+ 50% { box-shadow: 0 0 20px rgba(88, 101, 242, 0.6); }
+ }
+ `;
+ const existing = document.getElementById('message-glow-effect');
+ if (existing) existing.remove();
+ document.head.appendChild(style);
+ showToast('โจ Message glow effect activated!', 'success');
+ },
+ 'Avatar': () => {
+ const style = document.createElement('style');
+ style.id = 'avatar-aura-effect';
+ style.textContent = `
+ .message-avatar {
+ position: relative;
+ animation: avatarPulse 3s ease-in-out infinite;
+ }
+ @keyframes avatarPulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(88, 101, 242, 0.4); }
+ 50% { box-shadow: 0 0 0 8px rgba(88, 101, 242, 0); }
+ }
+ `;
+ const existing = document.getElementById('avatar-aura-effect');
+ if (existing) existing.remove();
+ document.head.appendChild(style);
+ showToast('๐ Avatar aura effect activated!', 'success');
+ },
+ 'Nameplates': () => {
+ const style = document.createElement('style');
+ style.id = 'nameplate-effect';
+ style.textContent = `
+ .message-username {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ font-weight: 700;
+ }
+ `;
+ const existing = document.getElementById('nameplate-effect');
+ if (existing) existing.remove();
+ document.head.appendChild(style);
+ showToast('๐ Crystal nameplate effect activated!', 'success');
+ },
+ 'Emotes': () => {
+ showToast('๐ Special emotes unlocked! Use them in your messages.', 'success');
+ }
+ };
+
+ const applyEffect = effectsMap[category];
+ if (applyEffect) {
+ applyEffect();
+ } else {
+ showToast('Effect applied!', 'success');
+ }
+}
+
+
// ==================== TOAST NOTIFICATIONS ====================
function showToast(message, type = 'success') {
@@ -1614,11 +2231,17 @@ function loadTheme() {
// ==================== CLOSE CHAT ====================
function closeCurrentChat() {
+ if (activeRoomSubscription && socket && socket.connected) {
+ socket.emit('leave room chat', activeRoomSubscription);
+ }
+ activeRoomSubscription = null;
currentChatFriendId = null;
currentChatFriendName = null;
currentChatContext = { type: 'none', id: null, name: null };
clearPendingMediaDraft();
- document.getElementById('chatTitle').textContent = '๐ฌ Select a friend to chat';
+ const chatTitleEl = document.getElementById('chatTitle');
+ chatTitleEl.textContent = '๐ฌ Select a friend to chat';
+ chatTitleEl.onclick = null;
document.getElementById('messages-container').innerHTML = '';
document.getElementById('closeChatBtn').style.display = 'none';
refreshChatScrollbar();
@@ -1689,21 +2312,32 @@ function sendRoomMessage(payload) {
return;
}
- console.log('๐ค Sending room message:', payload);
+ if (!socket || !socket.connected) {
+ showToast('Connection lost. Reconnecting...', 'warning');
+ connectSocket();
+ return;
+ }
- const roomMessage = {
+ // Optimistically display message immediately
+ const optimisticMessage = {
+ id: Date.now(),
from: currentUser.id,
fromUsername: currentUser.username,
- fromAvatar: currentUser.avatar || null,
+ fromAvatar: currentUser.avatar,
content: payload.content,
mediaType: payload.mediaType,
mediaUrl: payload.mediaUrl || null,
timestamp: new Date().toISOString()
};
+ addMessageToChat(optimisticMessage);
- console.log('๐จ Room message to add:', roomMessage);
- addRoomMessage(roomKey, roomMessage);
- addMessageToChat(roomMessage);
+ socket.emit('send room message', {
+ roomType: currentChatContext.type,
+ roomName: currentChatContext.name,
+ content: payload.content,
+ mediaType: payload.mediaType,
+ mediaUrl: payload.mediaUrl || null
+ });
}
function ensureChatTarget() {
@@ -1836,18 +2470,22 @@ function openCommunitiesModal() {
list.innerHTML = communities.map(c => {
const isJoined = joinedCommunities.includes(c.name);
+ const profile = getCommunityProfile(c.name);
return `
-
${c.emoji} ${c.name}
+
${profile.icon || c.emoji} ${c.name}
${c.description}
-
+
+ ${isJoined ? `` : ''}
+
+
`;
@@ -1875,12 +2513,19 @@ function openGroupsModal() {
list.innerHTML = groups.map(g => {
const isJoined = joinedGroups.includes(g.name);
+ const profile = getGroupProfile(g.name);
+ const avatarHtml = profile.image
+ ? `
`
+ : `
${profile.icon || g.emoji}
`;
return `
-
-
${g.emoji} ${g.name}
+
+ ${avatarHtml}
+
+
${g.name}
${g.description}
+