Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
326
frontend/src/lib/chat/chatStore.js
Normal file
326
frontend/src/lib/chat/chatStore.js
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
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([]);
|
||||
}
|
||||
425
frontend/src/lib/chat/chatWebSocket.js
Normal file
425
frontend/src/lib/chat/chatWebSocket.js
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import {
|
||||
connectionStatus,
|
||||
chatUserInfo,
|
||||
addMessage,
|
||||
removeMessage,
|
||||
setMessageHistory,
|
||||
currentRealmId,
|
||||
participants
|
||||
} from './chatStore';
|
||||
import { get } from 'svelte/store';
|
||||
import { getGuestFingerprint } from '$lib/fingerprint';
|
||||
|
||||
class ChatWebSocket {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.realmId = null;
|
||||
this.token = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000;
|
||||
this.maxReconnectDelay = 30000; // Cap delay at 30 seconds
|
||||
this.messageHandlers = [];
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
|
||||
async connect(realmId, token = null) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
// Already connected, just switch realm
|
||||
if (this.realmId !== realmId) {
|
||||
this.joinRealm(realmId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.realmId = realmId;
|
||||
this.token = token;
|
||||
|
||||
// SECURITY FIX #9: Don't include token in URL query params
|
||||
// Token will be sent as first message after connection to avoid logging/exposure
|
||||
let wsUrl = `ws://${window.location.host}/chat/ws?realmId=${encodeURIComponent(realmId)}`;
|
||||
|
||||
// Guest connection - generate fingerprint for ban enforcement
|
||||
// Only guests are fingerprinted, registered users are NOT fingerprinted for privacy
|
||||
if (!token) {
|
||||
try {
|
||||
const fingerprint = await getGuestFingerprint();
|
||||
if (fingerprint) {
|
||||
wsUrl += `&fp=${encodeURIComponent(fingerprint)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate fingerprint:', e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ChatWebSocket] Connecting to:', wsUrl, 'Token present:', !!token);
|
||||
|
||||
connectionStatus.set('connecting');
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('Chat WebSocket connected');
|
||||
connectionStatus.set('connected');
|
||||
this.reconnectAttempts = 0;
|
||||
currentRealmId.set(realmId);
|
||||
|
||||
// SECURITY FIX #9: Send auth token as first message (not in URL)
|
||||
if (this.token) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.token }));
|
||||
}
|
||||
|
||||
// Send join message to subscribe to the realm
|
||||
this.joinRealm(realmId);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('Chat WebSocket error:', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('Chat WebSocket closed');
|
||||
connectionStatus.set('disconnected');
|
||||
this.attemptReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
connectionStatus.set('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'welcome':
|
||||
// If we have a token, we're about to authenticate - don't set guest info
|
||||
// The auth_success message will set the correct user info
|
||||
if (!this.token) {
|
||||
chatUserInfo.set({
|
||||
username: data.username,
|
||||
userId: data.userId,
|
||||
isGuest: data.isGuest,
|
||||
isModerator: data.isModerator,
|
||||
isSiteModerator: data.isSiteModerator || false,
|
||||
isStreamer: data.isStreamer || false,
|
||||
isAdmin: data.isAdmin || false,
|
||||
avatarUrl: data.avatarUrl || ''
|
||||
});
|
||||
|
||||
// If guest and has saved name in localStorage, rename immediately
|
||||
if (data.isGuest && typeof localStorage !== 'undefined') {
|
||||
const savedGuestName = localStorage.getItem('guestName');
|
||||
if (savedGuestName && savedGuestName !== data.username) {
|
||||
console.log('Restoring saved guest name:', savedGuestName);
|
||||
// Delay slightly to ensure connection is fully established
|
||||
setTimeout(() => {
|
||||
this.sendRename(savedGuestName);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'history':
|
||||
setMessageHistory(data.messages || []);
|
||||
break;
|
||||
|
||||
case 'new_message':
|
||||
addMessage(data);
|
||||
break;
|
||||
|
||||
case 'message_deleted':
|
||||
removeMessage(data.messageId);
|
||||
break;
|
||||
|
||||
case 'message_sent':
|
||||
// Confirmation that message was sent
|
||||
break;
|
||||
|
||||
case 'rename_success':
|
||||
chatUserInfo.update((info) => ({
|
||||
...info,
|
||||
username: data.newName
|
||||
}));
|
||||
console.log('Rename successful:', data.newName);
|
||||
break;
|
||||
|
||||
case 'auth_success':
|
||||
// Update user info after successful authentication
|
||||
chatUserInfo.set({
|
||||
username: data.username,
|
||||
userId: data.userId,
|
||||
isGuest: false,
|
||||
isModerator: data.isModerator || false,
|
||||
isSiteModerator: data.isSiteModerator || false,
|
||||
isStreamer: data.isStreamer || false,
|
||||
isAdmin: data.isAdmin || false,
|
||||
avatarUrl: data.avatarUrl || '',
|
||||
userColor: data.userColor || '#FFFFFF'
|
||||
});
|
||||
console.log('Authentication successful:', data.username);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('Chat error:', data.error);
|
||||
// Optionally show error to user
|
||||
break;
|
||||
|
||||
case 'mod_action_success':
|
||||
console.log('Moderation action successful:', data.action);
|
||||
break;
|
||||
|
||||
case 'participants_list':
|
||||
participants.set(data.participants || []);
|
||||
console.log(`Participants in realm: ${data.count}`);
|
||||
break;
|
||||
|
||||
case 'participant_joined':
|
||||
// Add new participant to the list
|
||||
participants.update((list) => {
|
||||
// Check if already in list (avoid duplicates)
|
||||
const exists = list.some((p) => p.userId === data.participant.userId);
|
||||
if (!exists) {
|
||||
return [...list, data.participant];
|
||||
}
|
||||
return list;
|
||||
});
|
||||
console.log(`Participant joined: ${data.participant.username} (${data.participantCount} total)`);
|
||||
break;
|
||||
|
||||
case 'participant_left':
|
||||
// Remove participant from the list
|
||||
participants.update((list) => list.filter((p) => p.userId !== data.userId));
|
||||
console.log(`Participant left: ${data.username} (${data.participantCount} total)`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', data.type);
|
||||
}
|
||||
|
||||
// Call custom handlers
|
||||
this.messageHandlers.forEach((handler) => handler(data));
|
||||
}
|
||||
|
||||
sendMessage(content, userColor = '#FFFFFF', selfDestructSeconds = 0) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'message',
|
||||
content,
|
||||
userColor
|
||||
};
|
||||
|
||||
// Only include selfDestructSeconds if set (> 0)
|
||||
if (selfDestructSeconds > 0) {
|
||||
message.selfDestructSeconds = selfDestructSeconds;
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
joinRealm(realmId) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'join',
|
||||
realmId
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
this.realmId = realmId;
|
||||
currentRealmId.set(realmId);
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteMessage(messageId) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'mod_action',
|
||||
action: 'delete',
|
||||
messageId
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Site-wide fingerprint ban (admins + site moderators only)
|
||||
uberbanUser(targetUserId, fingerprint = '', reason = '') {
|
||||
return this.sendModAction('uberban', targetUserId, reason, 0, fingerprint);
|
||||
}
|
||||
|
||||
// Remove site-wide fingerprint ban
|
||||
unUberbanUser(fingerprint, reason = '') {
|
||||
return this.sendModAction('ununberban', '', reason, 0, fingerprint);
|
||||
}
|
||||
|
||||
// Per-realm ban
|
||||
banUser(targetUserId, reason = '') {
|
||||
return this.sendModAction('ban', targetUserId, reason);
|
||||
}
|
||||
|
||||
// Remove per-realm ban
|
||||
unbanUser(targetUserId, fingerprint = '') {
|
||||
return this.sendModAction('unban', targetUserId, '', 0, fingerprint);
|
||||
}
|
||||
|
||||
// Kick (disconnect + 1 min rejoin block)
|
||||
kickUser(targetUserId, duration = 60, reason = '') {
|
||||
return this.sendModAction('kick', targetUserId, reason, duration);
|
||||
}
|
||||
|
||||
// duration: 0 = permanent (default), >0 = temporary with auto-expire
|
||||
muteUser(targetUserId, duration = 0, reason = '') {
|
||||
return this.sendModAction('mute', targetUserId, reason, duration);
|
||||
}
|
||||
|
||||
sendModAction(action, targetUserId, reason = '', duration = 0, fingerprint = '') {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'mod_action',
|
||||
action,
|
||||
targetUserId,
|
||||
reason,
|
||||
duration
|
||||
};
|
||||
|
||||
// Include fingerprint if provided (for uberban/unban)
|
||||
if (fingerprint) {
|
||||
message.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
getParticipants() {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'get_participants'
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
sendRename(newName) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.error('WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'rename',
|
||||
newName
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
onMessage(handler) {
|
||||
this.messageHandlers.push(handler);
|
||||
return () => {
|
||||
this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnect attempts reached. Use manualReconnect() to try again.');
|
||||
connectionStatus.set('failed');
|
||||
|
||||
// Schedule automatic reset of reconnect attempts after 60 seconds
|
||||
// This allows the system to try again later without user intervention
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
}
|
||||
this.reconnectResetTimer = setTimeout(() => {
|
||||
console.log('Resetting reconnect attempts after timeout');
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.realmId) {
|
||||
this.connect(this.realmId, this.token);
|
||||
}
|
||||
}, 60000); // Try again after 1 minute
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
// Cap the delay at maxReconnectDelay
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.realmId) {
|
||||
this.connect(this.realmId, this.token);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger reconnection - resets attempt counter and tries immediately
|
||||
*/
|
||||
manualReconnect() {
|
||||
console.log('Manual reconnect triggered');
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.realmId) {
|
||||
this.connect(this.realmId, this.token);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clear any pending reconnect timers
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
this.reconnectResetTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.realmId = null;
|
||||
this.token = null;
|
||||
this.reconnectAttempts = 0;
|
||||
connectionStatus.set('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const chatWebSocket = new ChatWebSocket();
|
||||
66
frontend/src/lib/chat/stickerFavorites.js
Normal file
66
frontend/src/lib/chat/stickerFavorites.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
// Load favorites from localStorage
|
||||
function loadFavorites() {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const stored = localStorage.getItem('stickerFavorites');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load sticker favorites:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save favorites to localStorage
|
||||
function saveFavorites(favorites) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem('stickerFavorites', JSON.stringify(favorites));
|
||||
} catch (e) {
|
||||
console.error('Failed to save sticker favorites:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the store
|
||||
function createStickerFavoritesStore() {
|
||||
const { subscribe, set, update } = writable(loadFavorites());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
add: (stickerName) => {
|
||||
update(favorites => {
|
||||
if (!favorites.includes(stickerName)) {
|
||||
const updated = [...favorites, stickerName];
|
||||
saveFavorites(updated);
|
||||
return updated;
|
||||
}
|
||||
return favorites;
|
||||
});
|
||||
},
|
||||
remove: (stickerName) => {
|
||||
update(favorites => {
|
||||
const updated = favorites.filter(f => f !== stickerName);
|
||||
saveFavorites(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
toggle: (stickerName) => {
|
||||
update(favorites => {
|
||||
let updated;
|
||||
if (favorites.includes(stickerName)) {
|
||||
updated = favorites.filter(f => f !== stickerName);
|
||||
} else {
|
||||
updated = [...favorites, stickerName];
|
||||
}
|
||||
saveFavorites(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
isFavorite: (stickerName, favorites) => {
|
||||
return favorites.includes(stickerName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const stickerFavorites = createStickerFavoritesStore();
|
||||
220
frontend/src/lib/chat/ttsStore.js
Normal file
220
frontend/src/lib/chat/ttsStore.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// TTS enabled state (default: false)
|
||||
export const ttsEnabled = writable(false);
|
||||
|
||||
// TTS settings
|
||||
export const ttsSettings = writable({
|
||||
voice: null, // SpeechSynthesisVoice object
|
||||
voiceName: '', // Voice name for persistence
|
||||
rate: 1, // 0.5 - 2
|
||||
pitch: 1, // 0.5 - 2
|
||||
volume: 1 // 0 - 1
|
||||
});
|
||||
|
||||
// User filter: 'all' | 'guests' | 'registered'
|
||||
export const ttsUserFilter = writable('all');
|
||||
|
||||
// Set of muted user IDs
|
||||
export const ttsMutedUsers = writable(new Set());
|
||||
|
||||
// Available voices from browser
|
||||
export const availableVoices = writable([]);
|
||||
|
||||
// Current realm ID for per-realm settings
|
||||
let currentRealmId = null;
|
||||
|
||||
// Initialize voices from browser
|
||||
export function initVoices() {
|
||||
if (!browser || !window.speechSynthesis) return;
|
||||
|
||||
const loadVoices = () => {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
availableVoices.set(voices);
|
||||
|
||||
// Restore saved voice if available
|
||||
const settings = get(ttsSettings);
|
||||
if (settings.voiceName && voices.length > 0) {
|
||||
const savedVoice = voices.find(v => v.name === settings.voiceName);
|
||||
if (savedVoice) {
|
||||
ttsSettings.update(s => ({ ...s, voice: savedVoice }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chrome needs this event, Firefox loads immediately
|
||||
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
||||
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||||
}
|
||||
loadVoices();
|
||||
}
|
||||
|
||||
// Set voice by name
|
||||
export function setVoice(voiceName) {
|
||||
const voices = get(availableVoices);
|
||||
const voice = voices.find(v => v.name === voiceName);
|
||||
if (voice) {
|
||||
ttsSettings.update(s => ({ ...s, voice, voiceName }));
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if message should be spoken
|
||||
function shouldSpeak(message) {
|
||||
if (!get(ttsEnabled)) return false;
|
||||
|
||||
// Check if user is muted
|
||||
const mutedUsers = get(ttsMutedUsers);
|
||||
if (mutedUsers.has(message.userId)) return false;
|
||||
|
||||
// Check user filter
|
||||
const filter = get(ttsUserFilter);
|
||||
if (filter === 'guests' && !message.isGuest) return false;
|
||||
if (filter === 'registered' && message.isGuest) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strip HTML/markdown and clean text for TTS
|
||||
function cleanTextForTTS(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove HTML tags
|
||||
let clean = text.replace(/<[^>]*>/g, '');
|
||||
|
||||
// Remove markdown formatting
|
||||
clean = clean.replace(/\*\*(.+?)\*\*/g, '$1'); // bold
|
||||
clean = clean.replace(/\*(.+?)\*/g, '$1'); // italic
|
||||
clean = clean.replace(/__(.+?)__/g, '$1'); // underline
|
||||
clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough
|
||||
clean = clean.replace(/`(.+?)`/g, '$1'); // code
|
||||
|
||||
// Remove URLs
|
||||
clean = clean.replace(/https?:\/\/\S+/g, 'link');
|
||||
|
||||
// Limit length to prevent very long TTS
|
||||
if (clean.length > 200) {
|
||||
clean = clean.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
return clean.trim();
|
||||
}
|
||||
|
||||
// Speak a message
|
||||
export function speakMessage(message) {
|
||||
if (!browser || !window.speechSynthesis) return;
|
||||
if (!shouldSpeak(message)) return;
|
||||
|
||||
const settings = get(ttsSettings);
|
||||
const text = cleanTextForTTS(message.content);
|
||||
|
||||
if (!text) return;
|
||||
|
||||
// Add username prefix
|
||||
const fullText = `${message.username} says: ${text}`;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(fullText);
|
||||
|
||||
if (settings.voice) {
|
||||
utterance.voice = settings.voice;
|
||||
}
|
||||
utterance.rate = settings.rate;
|
||||
utterance.pitch = settings.pitch;
|
||||
utterance.volume = settings.volume;
|
||||
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
// Toggle mute for a user
|
||||
export function toggleUserMute(userId, username) {
|
||||
ttsMutedUsers.update(muted => {
|
||||
const newMuted = new Set(muted);
|
||||
if (newMuted.has(userId)) {
|
||||
newMuted.delete(userId);
|
||||
} else {
|
||||
newMuted.add(userId);
|
||||
}
|
||||
return newMuted;
|
||||
});
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Check if user is muted
|
||||
export function isUserMuted(userId) {
|
||||
return get(ttsMutedUsers).has(userId);
|
||||
}
|
||||
|
||||
// Clear all muted users
|
||||
export function clearMutedUsers() {
|
||||
ttsMutedUsers.set(new Set());
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Save settings to localStorage
|
||||
function saveSettings() {
|
||||
if (!browser || !currentRealmId) return;
|
||||
|
||||
const settings = get(ttsSettings);
|
||||
const data = {
|
||||
enabled: get(ttsEnabled),
|
||||
voiceName: settings.voiceName,
|
||||
rate: settings.rate,
|
||||
pitch: settings.pitch,
|
||||
volume: settings.volume,
|
||||
userFilter: get(ttsUserFilter),
|
||||
mutedUsers: Array.from(get(ttsMutedUsers))
|
||||
};
|
||||
|
||||
localStorage.setItem(`tts_settings_${currentRealmId}`, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Load settings from localStorage
|
||||
export function loadSettings(realmId) {
|
||||
currentRealmId = realmId;
|
||||
|
||||
if (!browser || !realmId) return;
|
||||
|
||||
const saved = localStorage.getItem(`tts_settings_${realmId}`);
|
||||
if (!saved) {
|
||||
// Reset to defaults for new realm
|
||||
ttsEnabled.set(false);
|
||||
ttsUserFilter.set('all');
|
||||
ttsMutedUsers.set(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
|
||||
ttsEnabled.set(data.enabled || false);
|
||||
ttsUserFilter.set(data.userFilter || 'all');
|
||||
ttsMutedUsers.set(new Set(data.mutedUsers || []));
|
||||
|
||||
ttsSettings.update(s => ({
|
||||
...s,
|
||||
voiceName: data.voiceName || '',
|
||||
rate: data.rate ?? 1,
|
||||
pitch: data.pitch ?? 1,
|
||||
volume: data.volume ?? 1
|
||||
}));
|
||||
|
||||
// Restore voice object if voices are loaded
|
||||
if (data.voiceName) {
|
||||
const voices = get(availableVoices);
|
||||
const voice = voices.find(v => v.name === data.voiceName);
|
||||
if (voice) {
|
||||
ttsSettings.update(s => ({ ...s, voice }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load TTS settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to store changes to auto-save
|
||||
if (browser) {
|
||||
ttsEnabled.subscribe(() => saveSettings());
|
||||
ttsSettings.subscribe(() => saveSettings());
|
||||
ttsUserFilter.subscribe(() => saveSettings());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue