327 lines
8.8 KiB
JavaScript
327 lines
8.8 KiB
JavaScript
|
|
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
|
||
|
|
export function setMessageHistory(messageList) {
|
||
|
|
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([]);
|
||
|
|
}
|