import { writable, derived, get } from 'svelte/store'; import { browser } from '$app/environment'; import { speakMessage } from './ttsStore.js'; // Chat messages store export const messages = writable([]); // Current realm ID export const currentRealmId = writable(null); // WebSocket connection status export const connectionStatus = writable('disconnected'); // 'connected', 'connecting', 'disconnected' // User info export const chatUserInfo = writable({ username: '', userId: '', isGuest: true, isModerator: false, isSiteModerator: false, // Site-wide moderator (from is_moderator JWT claim) isStreamer: false, isAdmin: false, avatarUrl: '' }); // Active channels filter export const activeChannels = writable(['global']); // Participants in current realm export const participants = writable([]); // Message visibility filter: 'all' | 'registered' | 'guests' export const messageVisibilityFilter = writable('all'); // Set of hidden user IDs (individual user hiding) export const hiddenUsers = writable(new Set()); // Global chat: selected realms filter (empty Set = all realms / global view) export const selectedRealms = writable(new Set()); // Available realms with participant counts [{realmId, realmName, participantCount}] export const availableRealms = writable([]); // Filtered messages based on active channels, realm filter, and visibility settings // Also computes showHeader for each message to avoid render-time index volatility export const filteredMessages = derived( [messages, activeChannels, messageVisibilityFilter, hiddenUsers, selectedRealms], ([$messages, $activeChannels, $visibilityFilter, $hiddenUsers, $selectedRealms]) => { let filtered = $messages; // Filter by realm (global chat filtering) // Empty selectedRealms means show all (global view) if ($selectedRealms.size > 0) { filtered = filtered.filter((msg) => $selectedRealms.has(msg.realmId)); } // Filter by channel (legacy - may be deprecated) if (!$activeChannels.includes('global')) { filtered = filtered.filter((msg) => $activeChannels.some((channel) => msg.channel === channel) ); } // Filter by visibility (guest/registered) if ($visibilityFilter === 'registered') { filtered = filtered.filter((msg) => !msg.isGuest); } else if ($visibilityFilter === 'guests') { filtered = filtered.filter((msg) => msg.isGuest); } // Filter out individually hidden users if ($hiddenUsers.size > 0) { filtered = filtered.filter((msg) => !$hiddenUsers.has(msg.userId)); } // Compute showHeader for each message based on filtered results // This is computed here (not at render time) to avoid index volatility during rapid updates // Use String() coercion for defensive comparison (handles number vs string IDs) return filtered.map((msg, i) => { if (i === 0) { return { ...msg, showHeader: true }; } const prev = filtered[i - 1]; const sameUser = String(prev.userId) === String(msg.userId); const sameRealm = String(prev.realmId) === String(msg.realmId); const showHeader = !sameUser || !sameRealm || msg.usedRoll || msg.usedRtd; return { ...msg, showHeader }; }); } ); // Add message to store export function addMessage(message) { messages.update((msgs) => { // Prevent duplicate messages (can happen with rapid sends or race conditions) if (msgs.some((m) => m.messageId === message.messageId)) { return msgs; } // Insert message in correct position by timestamp to handle out-of-order arrivals const newMsgs = [...msgs, message]; // Only sort if needed (message timestamp is older than the last message) if (msgs.length > 0 && message.timestamp < msgs[msgs.length - 1].timestamp) { newMsgs.sort((a, b) => a.timestamp - b.timestamp); } return newMsgs; }); // Trigger TTS for new messages speakMessage(message); } // Remove message by ID export function removeMessage(messageId) { messages.update((msgs) => msgs.filter((m) => m.messageId !== messageId)); } // Clear all messages export function clearMessages() { messages.set([]); } // Set message history // If merge=true, adds new messages to existing ones (for realm switching in global chat) // If merge=false (default), replaces all messages (for initial connection) export function setMessageHistory(messageList, merge = false) { if (merge) { messages.update((existing) => { // Create a Map of existing messages by messageId for O(1) lookup const existingMap = new Map(existing.map((m) => [m.messageId, m])); // Add new messages that don't already exist for (const msg of messageList) { if (!existingMap.has(msg.messageId)) { existingMap.set(msg.messageId, msg); } } // Convert back to array and sort by timestamp return Array.from(existingMap.values()).sort( (a, b) => new Date(a.timestamp) - new Date(b.timestamp) ); }); } else { messages.set(messageList); } } // Toggle channel export function toggleChannel(channel) { activeChannels.update((channels) => { if (channels.includes(channel)) { // Don't remove if it's the last channel if (channels.length === 1) return channels; return channels.filter((c) => c !== channel); } else { return [...channels, channel]; } }); } // Add channel export function addChannel(channel) { activeChannels.update((channels) => { if (!channels.includes(channel)) { return [...channels, channel]; } return channels; }); } // === Global Chat Realm Filtering === // Join a realm (add to filter - like checking a checkbox) export function joinRealmFilter(realmId) { selectedRealms.update((realms) => { const newRealms = new Set(realms); newRealms.add(realmId); return newRealms; }); saveRealmFilter(); } // Leave a realm (remove from filter - like unchecking a checkbox) export function leaveRealmFilter(realmId) { selectedRealms.update((realms) => { const newRealms = new Set(realms); newRealms.delete(realmId); return newRealms; }); saveRealmFilter(); } // Toggle a realm in the filter export function toggleRealmFilter(realmId) { selectedRealms.update((realms) => { const newRealms = new Set(realms); if (newRealms.has(realmId)) { newRealms.delete(realmId); } else { newRealms.add(realmId); } return newRealms; }); saveRealmFilter(); } // Reset to global view (show all realms) export function resetToGlobal() { selectedRealms.set(new Set()); saveRealmFilter(); } // Check if currently in global mode (no filter) export function isGlobalMode() { return get(selectedRealms).size === 0; } // Save realm filter to localStorage function saveRealmFilter() { if (!browser) return; const realms = Array.from(get(selectedRealms)); localStorage.setItem('chat_realm_filter', JSON.stringify(realms)); } // Load realm filter from localStorage export function loadRealmFilter() { if (!browser) return; const saved = localStorage.getItem('chat_realm_filter'); if (saved) { try { const realms = JSON.parse(saved); selectedRealms.set(new Set(realms)); } catch (e) { console.error('Failed to load realm filter:', e); selectedRealms.set(new Set()); } } } // Fetch available realms with participant counts export async function fetchRealmStats() { try { const res = await fetch('/api/chat/realms/stats'); if (res.ok) { const stats = await res.json(); availableRealms.set(stats); return stats; } } catch (e) { console.error('Failed to fetch realm stats:', e); } return []; } // Initialize realm filter on load if (browser) { loadRealmFilter(); } // Toggle hiding a specific user's messages export function toggleHideUser(userId) { hiddenUsers.update((hidden) => { const newHidden = new Set(hidden); if (newHidden.has(userId)) { newHidden.delete(userId); } else { newHidden.add(userId); } return newHidden; }); saveVisibilitySettings(); } // Check if a user is hidden export function isUserHidden(userId) { return get(hiddenUsers).has(userId); } // Clear all hidden users export function clearHiddenUsers() { hiddenUsers.set(new Set()); saveVisibilitySettings(); } // Save visibility settings to localStorage let currentVisibilityRealmId = null; function saveVisibilitySettings() { if (!browser || !currentVisibilityRealmId) return; const data = { filter: get(messageVisibilityFilter), hiddenUsers: Array.from(get(hiddenUsers)) }; localStorage.setItem(`chat_visibility_${currentVisibilityRealmId}`, JSON.stringify(data)); } // Load visibility settings from localStorage export function loadVisibilitySettings(realmId) { currentVisibilityRealmId = realmId; if (!browser || !realmId) return; const saved = localStorage.getItem(`chat_visibility_${realmId}`); if (!saved) { // Reset to defaults for new realm messageVisibilityFilter.set('all'); hiddenUsers.set(new Set()); return; } try { const data = JSON.parse(saved); messageVisibilityFilter.set(data.filter || 'all'); hiddenUsers.set(new Set(data.hiddenUsers || [])); } catch (e) { console.error('Failed to load visibility settings:', e); } } // Subscribe to store changes to auto-save if (browser) { messageVisibilityFilter.subscribe(() => saveVisibilitySettings()); } // Reset store export function resetChatStore() { messages.set([]); currentRealmId.set(null); connectionStatus.set('disconnected'); chatUserInfo.set({ username: '', userId: '', isGuest: true, isModerator: false, isSiteModerator: false, isStreamer: false, isAdmin: false }); activeChannels.set(['global']); participants.set([]); messageVisibilityFilter.set('all'); hiddenUsers.set(new Set()); // Note: selectedRealms is NOT reset here to preserve user's global chat filter preference // Use resetToGlobal() explicitly if you want to reset the realm filter availableRealms.set([]); }