Files
yourpart3/frontend/src/dialogues/chat/MultiChatDialog.vue
Torsten Schulz (local) d475e8b2f7 Änderung: Verbesserung der Verbindungsverwaltung und Benutzeroberfläche in mehreren Komponenten
Änderungen:
- Hinzufügung eines Verbindungsstatus-Indicators in der AppHeader.vue, der den aktuellen Verbindungsstatus anzeigt.
- Erweiterung der MultiChatDialog.vue um verbesserte Netzwerkereignisbehandlungen und eine Herzschlag-Logik zur Aufrechterhaltung der WebSocket-Verbindung.
- Anpassungen im Store zur Verwaltung des Verbindungsstatus und zur Implementierung von Wiederverbindungslogik mit exponentiellem Backoff.
- Diese Anpassungen verbessern die Benutzererfahrung durch klare Statusanzeigen und erhöhen die Stabilität der WebSocket-Verbindungen.
2025-09-15 08:45:11 +02:00

1781 lines
80 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<DialogWidget ref="dialog" :title="$t('chat.multichat.title')" :modal="false" :show-close="true"
@close="onDialogClose" width="75vw" height="75vh" name="MultiChatDialog" icon="multichat24.png">
<div class="dialog-widget-content">
<div class="multi-chat-top">
<select v-model="selectedRoom" class="room-select">
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
</select>
<div class="right-controls">
<div class="status" :class="statusType">
<span class="dot"></span>
<span class="text">{{ statusText }}</span>
<button v-if="statusType === 'error' || statusType === 'disconnected'" class="reconnect-btn"
@click="connectChatSocket" type="button"></button>
</div>
<div class="options-dropdown-wrapper" ref="optionsWrap">
<button class="options-btn" @click="onOptionsToggle" type="button">
{{ $t('chat.multichat.options') }}
</button>
<div v-if="showOptions" class="options-dropdown">
<label>
<input type="checkbox" v-model="autoscroll" />
{{ $t('chat.multichat.autoscroll') }}
</label>
<div class="opts-divider" v-if="isAdmin"></div>
<div class="opts-row" v-if="isAdmin">
<button class="opts-btn" type="button" @click="reloadRoomsAdmin">Räume neu laden</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="!showColorPicker" class="multi-chat-body">
<div class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true"
@mouseleave="mouseOverOutput = false">
<div v-for="msg in messages" :key="msg.id" class="chat-message">
<template v-if="msg.type === 'scream'">
<span class="scream-line" :style="msg.color ? { color: msg.color } : null">
<span class="user">{{ msg.user }}</span>
<span class="scream-label"> schreit:</span>
<span class="text"> {{ (msg.text || '').toUpperCase() }}</span>
</span>
</template>
<template v-else-if="msg.type === 'action'">
<span class="user" :style="msg.color ? { color: msg.color } : null">{{ msg.user }}</span>
<span class="action-text">{{ msg.action }}</span>
<span class="action-target" :style="msg.toColor ? { color: msg.toColor } : null">{{ msg.to
}}</span>
</template>
<template v-else>
<span class="user" :style="msg.color ? { color: msg.color } : null">{{ msg.user }}:</span>
<span class="text">{{ msg.text }}</span>
</template>
</div>
</div>
<div class="user-list">
<div class="user-list-header">Teilnehmer ({{ usersInRoom.length }})</div>
<div class="user-list-items">
<div class="user-list-item" v-for="u in usersInRoom" :key="u.name"
:class="{ selected: selectedTargetUser === u.name }" @click="selectTargetUser(u.name)"
title="Klicken zum Auswählen">
<span class="user-dot"
:style="{ backgroundColor: (u.color || userColors[u.name] || '#ccc') }"></span>
<span class="user-name"
:style="(u.color || userColors[u.name]) ? { color: (u.color || userColors[u.name]) } : null">{{
u.name
}}</span>
</div>
</div>
</div>
</div>
<div v-if="!showColorPicker" class="multi-chat-input">
<input v-model="input" @keyup.enter="sendMessage" class="chat-input"
:placeholder="$t('chat.multichat.placeholder')" />
<button @click="sendMessage" class="send-btn">{{ $t('chat.multichat.send') }}</button>
<img @click="shout" src="/images/icons/scream.png" class="icon-btn" alt="Schreien" title="Schreien" />
<img @click="action" src="/images/icons/activity.png" class="icon-btn"
:class="{ disabled: !selectedTargetUser }" :alt="$t('chat.multichat.action')"
:title="selectedTargetUser ? $t('chat.multichat.action_to', { to: selectedTargetUser }) : $t('chat.multichat.action_select_user')" />
<img @click="roll" src="/images/icons/dice24.png" class="icon-btn" alt="Würfeln" title="Würfeln" />
<img @click="openColorPicker" src="/images/icons/colorpicker.png" class="icon-btn"
:alt="$t('chat.multichat.colorpicker')" :title="$t('chat.multichat.colorpicker')" />
</div>
<!-- Inline Color Picker Panel -->
<div v-else class="color-picker-panel">
<div class="picker-grid">
<div class="picker-column">
<div class="palette-wrap" @mousedown="onPaletteDown" @mousemove="onPaletteMove"
@mouseup="onPaletteUp" @mouseleave="onPaletteUp">
<canvas ref="paletteCanvas" :width="paletteWidth" :height="paletteHeight"></canvas>
<div class="palette-marker" v-if="pickX !== null"
:style="{ left: (pickX - 6) + 'px', top: (pickY - 6) + 'px' }"></div>
</div>
</div>
<div class="picker-column">
<div class="picker-row">
<label class="picker-label">{{ $t('chat.multichat.hex') }}</label>
<input class="hex-input" v-model="hexInput" @input="onHexInput" placeholder="#AABBCC" />
</div>
<div v-if="hexInvalid" class="picker-error">{{ $t('chat.multichat.invalid_hex') }}</div>
<div class="picker-preview" :style="{ color: selectedColor }">
{{ $t('chat.multichat.colorpicker_preview') }}
</div>
<div class="picker-sample" :style="{ background: selectedColor }"></div>
</div>
</div>
<div class="picker-actions">
<button class="btn" @click="onColorOk">{{ $t('chat.multichat.ok') }}</button>
<button class="btn secondary" @click="onColorCancel">{{ $t('chat.multichat.cancel') }}</button>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import { fetchPublicRooms } from '@/api/chatApi.js';
import { mapGetters } from 'vuex';
import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js';
export default {
name: 'MultiChat',
components: { DialogWidget },
computed: {
...mapGetters(['user', 'menu']),
isAdmin() {
// Infer admin via presence of administration section in menu (server filters by rights)
try {
return !!(this.menu && this.menu.administration);
} catch (_) { return false; }
}
},
mounted() {
// Reconnect when network returns
try { window.addEventListener('online', this.onOnline); } catch (_) { }
try { document.addEventListener('click', this.onGlobalClick); } catch (_) { }
},
beforeUnmount() {
// Safety: ensure connection is shut down on page/navigation leave
this.opened = false;
this.disconnectChatSocket();
try { window.removeEventListener('online', this.onOnline); } catch (_) { }
try { document.removeEventListener('click', this.onGlobalClick); } catch (_) { }
},
data() {
return {
rooms: [],
selectedRoom: null,
autoscroll: true,
mouseOverOutput: false,
messages: [],
usersInRoom: [],
selectedTargetUser: null,
userColors: {},
input: '',
showOptions: false,
debug: true,
chatSocket: null,
transportConnected: false,
chatConnected: false,
reconnectTimer: null,
opened: false,
statusType: 'idle',
statusText: '',
urlOverride: '',
token: null,
announcedRoomEnter: false,
showColorPicker: false,
selectedColor: '#000000',
lastColor: '#000000',
hexInput: '#000000',
hexInvalid: false,
// Palette state
paletteWidth: 420,
paletteHeight: 220,
pickX: null,
pickY: null,
isPicking: false,
maxLightness: 92, // exclude extremely light colors (>% L)
// Reconnect control
reconnectIntervalMs: 3000,
reconnectAttempts: 0,
maxReconnectAttempts: 20,
heartbeatInterval: null,
heartbeatIntervalMs: 30000, // 30 seconds
connectAttemptTimeout: null,
joinFallbackTimer: null,
// Faster handshake watchdog separate from reconnect interval
handshakeWatchdogMs: 2500,
// Rotate to next candidate if a connection never reaches 'open'
connectOpenTimer: null,
candidateOpenTimeoutMs: 900,
// Happy eyeballs state
connectRacing: false,
connectWinnerChosen: false,
pendingWs: [],
raceFailures: 0,
raceTotal: 0,
happyDelayMs: 40,
// Join fallback delay if token is slow to arrive
joinFallbackDelayMs: 120,
// Limit how many parallel WS candidates to race (prevents server socket buildup)
raceLimit: 3
};
},
// Hinweis: Öffnen erfolgt über methods.open(), damit Parent per Ref aufrufen kann
watch: {
messages() {
this.$nextTick(this.handleAutoscroll);
},
autoscroll(val) {
if (val) this.handleAutoscroll();
},
selectedRoom(newVal, oldVal) {
if (newVal && this.transportConnected) {
const room = this.getSelectedRoomName();
if (room) this.sendWithToken({ type: 'join', room, name: this.user?.username || '' });
this.messages = [];
this.usersInRoom = [];
this.selectedTargetUser = null;
}
}
},
methods: {
selectTargetUser(name) {
if (this.selectedTargetUser === name) {
this.selectedTargetUser = null; // toggle off
} else {
this.selectedTargetUser = name;
}
},
explainCloseCode(code) {
const map = {
1000: 'normal closure',
1001: 'going away (server down or browser nav)',
1002: 'protocol error (handshake or frames)',
1003: 'unsupported data',
1005: 'no status received (browser internal)',
1006: 'abnormal closure (network/TCP reset/firewall/CORS/TLS)',
1007: 'invalid payload data',
1008: 'policy violation (auth/permissions)',
1009: 'message too big',
1010: 'mandatory extension missing',
1011: 'internal server error',
1015: 'TLS handshake failure'
};
return map[code] || 'unknown reason';
},
// Wird extern aufgerufen um den Dialog zu öffnen
open(rooms) {
// Falls externe Räume übergeben wurden, nutzen; sonst vom Server laden
if (Array.isArray(rooms) && rooms.length) {
this.initializeRooms(rooms);
} else {
this.loadRooms();
}
},
onDialogClose() {
// Mark as closed first so any async close events won't schedule reconnect
this.opened = false;
console.log('[Chat WS] dialog close — closing websocket');
this.disconnectChatSocket();
// Remove network event listeners
window.removeEventListener('online', this.onOnline);
window.removeEventListener('offline', this.onOffline);
},
onOptionsToggle(e) {
e?.stopPropagation?.();
this.showOptions = !this.showOptions;
},
onGlobalClick(e) {
if (!this.showOptions) return;
const wrap = this.$refs.optionsWrap;
if (wrap && wrap.contains(e.target)) return;
this.showOptions = false;
},
onOnline() {
console.log('[Chat WS] Network online detected');
if (this.opened && !this.chatConnected && !this.connectRacing && (!this.chatWs || this.chatWs.readyState !== WebSocket.OPEN)) {
console.log('[Chat WS] online — attempting reconnect');
this.reconnectAttempts = 0; // Reset attempts on network recovery
this.reconnectIntervalMs = 3000; // Reset to base interval
this.connectChatSocket();
}
},
onOffline() {
console.log('[Chat WS] Network offline detected');
this.setStatus('disconnected');
},
async loadRooms() {
try {
const data = await fetchPublicRooms();
this.initializeRooms(Array.isArray(data) ? data : []);
} catch (e) {
console.error('Fehler beim Laden der Räume', e);
this.initializeRooms([]);
}
},
async reloadRoomsAdmin() {
if (!this.isAdmin) return;
try {
const current = this.selectedRoom;
const data = await fetchPublicRooms();
const rooms = Array.isArray(data) ? data : [];
this.rooms = rooms;
if (!rooms.find(r => r.id === current)) {
this.selectedRoom = rooms.length ? rooms[0].id : null;
}
this.messages.push({ id: Date.now(), user: 'System', text: 'Raumliste aktualisiert.' });
} catch (e) {
console.error('Fehler beim Neuladen der Räume', e);
this.messages.push({ id: Date.now(), user: 'System', text: 'Fehler beim Neuladen der Raumliste.' });
}
},
initializeRooms(rooms) {
this.rooms = rooms;
this.selectedRoom = rooms.length ? rooms[0].id : null;
this.autoscroll = true;
try { this.urlOverride = localStorage.getItem('chatWsOverride') || ''; } catch (_) { this.urlOverride = ''; }
this.messages = [];
this.usersInRoom = [];
this.selectedTargetUser = null;
this.input = '';
this.showOptions = false;
this.announcedRoomEnter = false;
this.$refs.dialog.open();
// Stelle die WS-Verbindung her, wenn der Dialog geöffnet wird
this.opened = true;
this.connectChatSocket();
// Add network event listeners
window.addEventListener('online', this.onOnline);
window.addEventListener('offline', this.onOffline);
},
connectChatSocket() {
if (this.connectRacing) return; // avoid overlapping races
try { this.chatWs?.close(1000, 'reconnect'); } catch (_) { }
this.chatWs = null;
this.wsUrl = getChatWsUrl();
// bump attempt counter for cache-busting
this._wsAttempt = (this._wsAttempt || 0) + 1;
const pageProto = (typeof window !== 'undefined' ? window.location.protocol : '');
const candidates = getChatWsCandidates();
console.log('[Chat WS] connect attempts', candidates, '| page protocol:', pageProto);
if (pageProto === 'https:' && this.wsUrl.startsWith('ws://')) {
console.warn('[Chat WS] insecure WS on HTTPS page — use wss:// or set VITE_CHAT_WS_URL');
}
this.setStatus('connecting');
// Race candidates in parallel (happy eyeballs) so the first to open wins
this.startHappyEyeballs(candidates);
this.token = null;
this.pending = [];
this.transportConnected = false;
this.chatConnected = false;
this.announcedRoomEnter = false;
this.wsStartAt = Date.now();
},
// Append a cache-busting query parameter per attempt to avoid stale routing/caches
bustUrl(url) {
try {
const u = new URL(url, (typeof window !== 'undefined') ? window.location.href : 'http://localhost');
u.searchParams.set('cb', `${Date.now()}-${this._wsAttempt || 0}`);
return u.toString();
} catch (_) {
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}cb=${Date.now()}-${this._wsAttempt || 0}`;
}
},
// Start parallel connection attempts; the first to open wins
startHappyEyeballs(candidates) {
if (!this.opened) return;
this.cleanupPendingSockets();
this.connectRacing = true;
this.connectWinnerChosen = false;
this.raceFailures = 0;
const limited = Array.isArray(candidates) ? candidates.slice(0, this.getRaceLimit()) : [];
this.raceTotal = limited.length;
if (!Array.isArray(candidates) || !candidates.length) {
console.warn('[Chat WS] no candidates to try');
this.scheduleReconnect();
return;
}
const protocols = getChatWsProtocols();
this.pendingWs = [];
const delay = this.happyDelayMs || 120;
limited.forEach((url, i) => {
const t = setTimeout(() => {
// spawn a candidate
this.spawnCandidate(this.bustUrl(url), protocols);
}, i * delay);
// track starter timers to be safe (optional)
this.pendingWs.push({ starterTimer: t, url, ws: null, openTimer: null });
});
},
getRaceLimit() {
try {
const ls = localStorage.getItem('chatWsRaceMax');
const n = parseInt(ls, 10);
if (!isNaN(n) && n > 0) return Math.min(n, 6);
} catch (_) { }
return this.raceLimit || 3;
},
spawnCandidate(url, protocols) {
if (!this.opened) return;
let rec = this.pendingWs.find(r => r.url === url && r.ws === null);
try {
const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
if (!rec) { rec = { url, starterTimer: null, ws: null, openTimer: null }; this.pendingWs.push(rec); }
rec.ws = ws;
const startAt = Date.now();
console.log('[Chat WS] racing', url, '| protocols:', protocols || '(none)');
// If this socket never opens fast, let another win
rec.openTimer = setTimeout(() => {
if (!this.connectWinnerChosen && ws.readyState === WebSocket.CONNECTING) {
console.warn('[Chat WS] candidate handshake timeout — closing to let others win:', url);
try { ws.close(1000, 'race timeout'); } catch (_) { }
}
}, this.candidateOpenTimeoutMs);
ws.addEventListener('open', () => {
const dt = Date.now() - startAt;
if (this.connectWinnerChosen) {
// Someone already won; close this one quietly
try { ws.close(); } catch (_) { }
return;
}
// Winner selection
this.connectWinnerChosen = true;
this.connectRacing = false;
this.wsUrl = url;
this.chatWs = ws;
this.transportConnected = true;
this.reconnectAttempts = 0; // Reset reconnect counter on successful connection
this.reconnectIntervalMs = 3000; // Reset to base interval
this.startHeartbeat(); // Start heartbeat to keep connection alive
console.log('[Chat WS] open in', dt, 'ms', '| protocol:', ws.protocol || '(none)', '| url:', url);
// Close all other candidates
this.pendingWs.forEach(r => {
if (r.ws && r.ws !== ws) {
try { r.ws.close(1000, 'race loser'); } catch (_) { }
r.ws = null;
}
if (r.openTimer) { clearTimeout(r.openTimer); r.openTimer = null; }
if (r.starterTimer) { clearTimeout(r.starterTimer); r.starterTimer = null; }
});
// Drop references to losers so GC can collect
this.pendingWs = [];
// Prepare handshake like before
const init = { type: 'init', name: this.user?.username || '', room: this.getSelectedRoomName() || '' };
if (this.debug) console.log('[Chat WS >>]', init);
this.wsSend(init);
if (this.connectAttemptTimeout) clearTimeout(this.connectAttemptTimeout);
this.connectAttemptTimeout = setTimeout(() => {
this.connectAttemptTimeout = null;
if (this.opened && !this.chatConnected) {
console.warn('[Chat WS] handshake watchdog fired — no room_entered within', this.handshakeWatchdogMs, 'ms');
this.scheduleReconnect();
}
}, this.handshakeWatchdogMs);
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
// Attach message/error/close for the winner
ws.addEventListener('message', (ev) => {
const data = ev.data;
if (this.debug) console.log('[Chat WS << RAW]', data);
this.wsProcessChunk(typeof data === 'string' ? data : '');
});
ws.addEventListener('error', (e) => {
console.warn('[Chat WS] error on winner | readyState:', ws.readyState, '| url:', url, e);
this.setStatus('error');
});
ws.addEventListener('close', (e) => {
const hint = this.explainCloseCode(e.code);
console.warn('[Chat WS] close | code:', e.code, hint, '| reason:', e.reason, '| wasClean:', e.wasClean, '| url:', url);
if (this.connectAttemptTimeout) { clearTimeout(this.connectAttemptTimeout); this.connectAttemptTimeout = null; }
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
this.transportConnected = false;
this.chatConnected = false;
this.setStatus('disconnected');
if (this.opened) this.scheduleReconnect();
});
});
// For non-winners: count failures so we can retry if none succeed
const onFail = (label) => {
if (!this.connectRacing || this.connectWinnerChosen) return;
this.raceFailures = (this.raceFailures || 0) + 1;
const dt = Date.now() - startAt;
console.warn('[Chat WS] candidate', label, 'after', dt, 'ms | url:', url);
if (rec && rec.openTimer) { clearTimeout(rec.openTimer); rec.openTimer = null; }
if (rec && rec.ws) { try { rec.ws.close(1000, 'race fail'); } catch (_) {} rec.ws = null; }
if (this.raceFailures >= this.raceTotal) {
// All failed
this.connectRacing = false;
if (this.opened && !this.chatConnected) this.scheduleReconnect();
}
};
ws.addEventListener('error', () => onFail('error'));
ws.addEventListener('close', () => onFail('close'));
} catch (e) {
console.warn('[Chat WS] spawn failed for', url, e);
}
},
cleanupPendingSockets() {
if (!this.pendingWs) this.pendingWs = [];
this.connectRacing = false;
this.connectWinnerChosen = false;
try {
this.pendingWs.forEach(r => {
if (r.starterTimer) { clearTimeout(r.starterTimer); r.starterTimer = null; }
if (r.openTimer) { clearTimeout(r.openTimer); r.openTimer = null; }
if (r.ws) {
try { r.ws.close(1000, 'cleanup'); } catch (_) { }
r.ws = null;
}
});
} catch (_) { }
this.pendingWs = [];
},
tryNextCandidate(candidates, index) {
if (!this.opened) return;
if (index >= candidates.length) {
console.warn('[Chat WS] all candidates failed');
if (this.opened && !this.chatConnected) this.scheduleReconnect();
return;
}
const url = this.bustUrl(candidates[index]);
const protocols = getChatWsProtocols();
this.wsUrl = url;
console.log('[Chat WS] trying', url, `(candidate ${index + 1}/${candidates.length})`, '| protocols:', protocols || '(none)');
try { this.chatWs?.close(1000, 'switch candidate'); } catch (_) { }
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
this.chatWs = ws;
this.wsStartAt = Date.now();
// If the socket never opens within a short time, assume this candidate is bad and try next
this.connectOpenTimer = setTimeout(() => {
this.connectOpenTimer = null;
if (ws.readyState === WebSocket.CONNECTING && !this.transportConnected) {
console.warn('[Chat WS] candidate handshake timeout — closing to try next:', url);
try { ws.close(1000, 'candidate timeout'); } catch (_) { }
}
}, this.candidateOpenTimeoutMs);
ws.addEventListener('open', () => {
this.transportConnected = true;
const dt = Date.now() - (this.wsStartAt || Date.now());
console.log('[Chat WS] open in', dt, 'ms', '| protocol:', ws.protocol || '(none)', '| url:', url);
const init = { type: 'init', name: this.user?.username || '', room: this.getSelectedRoomName() || '' };
if (this.debug) console.log('[Chat WS >>]', init);
this.wsSend(init);
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
if (this.connectAttemptTimeout) clearTimeout(this.connectAttemptTimeout);
this.connectAttemptTimeout = setTimeout(() => {
this.connectAttemptTimeout = null;
if (this.opened && !this.chatConnected) {
console.warn('[Chat WS] handshake watchdog fired — no room_entered within', this.handshakeWatchdogMs, 'ms');
this.scheduleReconnect();
}
}, this.handshakeWatchdogMs);
// No extra join here; init already included the room
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
});
ws.addEventListener('message', (ev) => {
const data = ev.data;
if (this.debug) console.log('[Chat WS << RAW]', data);
this.wsProcessChunk(typeof data === 'string' ? data : '');
});
ws.addEventListener('error', (e) => {
const dt = Date.now() - (this.wsStartAt || Date.now());
console.warn('[Chat WS] error after', dt, 'ms', '| readyState:', ws.readyState, '| url:', url, e);
this.setStatus('error');
});
ws.addEventListener('close', (e) => {
const dt = Date.now() - (this.wsStartAt || Date.now());
const hint = this.explainCloseCode(e.code);
console.warn('[Chat WS] close after', dt, 'ms', '| code:', e.code, hint, '| reason:', e.reason, '| wasClean:', e.wasClean, '| url:', url);
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
if (this.connectAttemptTimeout) { clearTimeout(this.connectAttemptTimeout); this.connectAttemptTimeout = null; }
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
if (!this.transportConnected) {
// This candidate never opened; try the next one immediately
this.tryNextCandidate(candidates, index + 1);
return;
}
this.transportConnected = false;
this.chatConnected = false;
this.setStatus('disconnected');
if (this.opened) this.scheduleReconnect();
});
// Handshake watchdog is started upon successful open (after init sent)
},
scheduleReconnect() {
if (this.reconnectTimer) return;
// Check if we've exceeded max attempts
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[Chat WS] Max reconnect attempts reached, waiting longer before retry');
this.reconnectAttempts = 0; // Reset counter
this.reconnectIntervalMs = 30000; // Wait 30 seconds
} else {
this.reconnectAttempts++;
// Use exponential backoff with jitter
const baseDelay = Math.min(3000 * Math.pow(1.5, this.reconnectAttempts - 1), 15000);
const jitter = Math.random() * 1000; // Add up to 1 second jitter
this.reconnectIntervalMs = baseDelay + jitter;
}
console.log('[Chat WS] scheduleReconnect in', Math.round(this.reconnectIntervalMs), 'ms', '| opened:', this.opened, '| attempt:', this.reconnectAttempts);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (this.opened && !this.chatConnected) {
this.connectChatSocket();
}
}, this.reconnectIntervalMs);
},
applyUrlOverride() {
try {
const val = (this.urlOverride || '').trim();
if (val) localStorage.setItem('chatWsOverride', val); else localStorage.removeItem('chatWsOverride');
} catch (_) { }
console.log('[Chat WS] url override applied', this.urlOverride || '(cleared)');
if (this.opened) this.connectChatSocket();
},
clearUrlOverride() {
this.urlOverride = '';
this.applyUrlOverride();
},
disconnectChatSocket() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat(); // Stop heartbeat when disconnecting
this.cleanupPendingSockets();
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
if (this.connectAttemptTimeout) { clearTimeout(this.connectAttemptTimeout); this.connectAttemptTimeout = null; }
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
try {
if (this.chatWs) {
if (this.chatWs.readyState === WebSocket.OPEN) {
this.chatWs.close(1000, 'dialog closed');
} else if (this.chatWs.readyState === WebSocket.CONNECTING) {
this.chatWs.close(1000, 'dialog closed');
}
}
} catch (_) { }
this.chatWs = null;
this.chatConnected = false;
this.announcedRoomEnter = false;
this.usersInRoom = [];
this.selectedTargetUser = null;
},
startHeartbeat() {
this.stopHeartbeat(); // Clear any existing heartbeat
if (!this.opened || !this.chatConnected) return;
this.heartbeatInterval = setInterval(() => {
if (!this.chatWs || this.chatWs.readyState !== WebSocket.OPEN) {
console.warn('[Chat WS] Heartbeat failed - connection not open, attempting reconnect');
this.stopHeartbeat();
this.scheduleReconnect();
return;
}
try {
// Send a ping message to keep connection alive
this.wsSend({ type: 'ping' });
console.log('[Chat WS] Heartbeat sent');
} catch (error) {
console.warn('[Chat WS] Heartbeat failed:', error);
this.stopHeartbeat();
this.scheduleReconnect();
}
}, this.heartbeatIntervalMs);
},
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
},
getSelectedRoomName() {
const r = this.rooms.find(x => x.id === this.selectedRoom);
return r?.title || r?.name || '';
},
handleIncoming(msg) {
if (!msg) return;
if (msg.type === 'message' || msg.type === 4) {
// Some servers echo sender messages without userName but include token
const isSelfByToken = !!(msg.token && this.token && msg.token === this.token);
const user = msg.userName || msg.from || (isSelfByToken ? (this.user?.username || 'User') : 'User');
let color = null;
if (msg.color) {
color = ('' + msg.color).trim();
if (color && !color.startsWith('#')) color = '#' + color;
this.userColors[user] = color;
} else if (this.userColors[user]) {
color = this.userColors[user];
}
const displayUser = (this.user?.username && user === this.user.username) ? 'ich' : user;
this.messages.push({ id: msg.id || Date.now(), user: displayUser, text: msg.message || msg.text || '', color });
} else if (msg.type === 6 || msg.type === 'scream') {
// Schreien: <user> SCHREIT: <text>
const user = msg.userName || msg.from || 'User';
let color = null;
if (msg.color) {
color = ('' + msg.color).trim();
if (color && !color.startsWith('#')) color = '#' + color;
this.userColors[user] = color;
} else if (this.userColors[user]) {
color = this.userColors[user];
}
this.messages.push({
id: msg.id || Date.now(),
user,
text: (msg.message || msg.text || ''),
color,
type: 'scream'
});
} else if (msg.type === 'system') {
// Nur gezielte Systemmeldungen anzeigen
if (msg.code === 'room_entered' || msg.tr === 'room_entered') {
// Verbunden-Status setzen
this.chatConnected = true;
this.setStatus('connected');
if (this.connectAttemptTimeout) { clearTimeout(this.connectAttemptTimeout); this.connectAttemptTimeout = null; }
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
// Eigene Beitrittsmeldung einmalig mit Raumname anzeigen
if (!this.announcedRoomEnter) {
const room = msg.to || msg.room || msg.roomName || this.getSelectedRoomName();
if (room) {
const text = this.$t('chat.multichat.system.room_entered', { room });
this.messages.push({ id: msg.id || Date.now(), user: 'System', text });
}
this.announcedRoomEnter = true;
}
return;
}
// Color changes
if (msg.code === 'color_changed') {
// Server schickt die gültige Farbe zurück diese verwenden
let color = (msg.color || '').trim();
if (color && !color.startsWith('#')) color = '#' + color;
if (!color) color = '#000000';
// UI-Infozeile
const text = this.$t('chat.multichat.system.color_changed_self', { color });
this.messages.push({ id: msg.id || Date.now(), user: 'System', text });
// Lokalen Zustand aktualisieren, damit künftige Aktionen dieselbe Farbe anzeigen
this.lastColor = color;
if (this.user?.username) {
this.userColors[this.user.username] = color;
}
if (this.showColorPicker) {
this.selectedColor = color;
this.hexInput = color;
// Marker position grob anpassen
try {
const hsl = this.hexToHsl(color);
this.pickX = Math.round((hsl.h / 360) * (this.paletteWidth - 1));
this.pickY = Math.round(((100 - hsl.l) / 100) * (this.paletteHeight - 1));
} catch (_) { }
}
return;
}
if (msg.code === 'user_color_changed') {
let color = (msg.color || '').trim();
if (color && !color.startsWith('#')) color = '#' + color;
if (!color) color = '#000000';
const user = msg.userName || 'User';
const text = this.$t('chat.multichat.system.color_changed_user', { user, color });
this.messages.push({ id: msg.id || Date.now(), user: 'System', text });
// Cache für künftige Messages
this.userColors[user] = color;
// Auch Liste rechts aktualisieren
const idx = this.usersInRoom.findIndex(u => u.name === user);
if (idx >= 0) {
const updated = { ...this.usersInRoom[idx], color };
// Vue 3 reactivity: direct assignment is fine
this.usersInRoom.splice(idx, 1, updated);
}
return;
}
}
},
setStatus(type) {
this.statusType = type;
const t = this.$t;
switch (type) {
case 'connecting':
this.statusText = t('chat.multichat.status.connecting');
break;
case 'connected':
this.statusText = t('chat.multichat.status.connected');
break;
case 'disconnected':
this.statusText = t('chat.multichat.status.disconnected');
break;
case 'error':
this.statusText = t('chat.multichat.status.error');
break;
default:
this.statusText = '';
}
},
handleAutoscroll() {
if (this.autoscroll && !this.mouseOverOutput) {
const out = this.$refs.output;
if (out) out.scrollTop = out.scrollHeight;
}
},
sendMessage() {
if (!this.input.trim()) return;
const payload = { type: 'message', message: this.input };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.input = '';
},
shout() {
if (!this.input.trim()) return;
const payload = { type: 'scream', message: this.input };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.input = '';
},
action() {
if (!this.input.trim()) return;
if (!this.selectedTargetUser) return; // Nur mit Auswahl
const payload = { type: 'do', value: this.input, to: this.selectedTargetUser };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.input = '';
},
roll() {
const payload = { type: 'dice', message: '' };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
},
openColorPicker() {
this.selectedColor = this.lastColor || '#000000';
this.hexInput = this.selectedColor;
this.showColorPicker = true;
this.$nextTick(() => {
this.drawPalette();
// Initialize marker roughly by hue (x) and lightness (y)
let hsl = this.hexToHsl(this.selectedColor);
if (hsl.l > this.maxLightness) {
hsl = { ...hsl, l: this.maxLightness };
const clampedHex = this.hslToHex(hsl.h, 100, hsl.l);
this.selectedColor = clampedHex;
this.hexInput = clampedHex;
}
this.pickX = Math.round((hsl.h / 360) * (this.paletteWidth - 1));
this.pickY = Math.round(((100 - hsl.l) / 100) * (this.paletteHeight - 1));
});
},
onColorOk() {
const color = this.selectedColor;
if (color) {
const payload = { type: 'color', value: color };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.lastColor = color;
}
this.showColorPicker = false;
},
onColorCancel() {
this.showColorPicker = false;
},
onHexInput() {
const val = (this.hexInput || '').trim();
const hex = this.normalizeHex(val);
if (!hex) {
this.hexInvalid = true;
return;
}
this.hexInvalid = false;
let hsl = this.hexToHsl(hex);
if (hsl.l > this.maxLightness) {
hsl = { ...hsl, l: this.maxLightness };
const clamped = this.hslToHex(hsl.h, 100, hsl.l);
this.selectedColor = clamped;
this.hexInput = clamped;
} else {
this.selectedColor = hex;
}
this.pickX = Math.round((hsl.h / 360) * (this.paletteWidth - 1));
this.pickY = Math.round(((100 - hsl.l) / 100) * (this.paletteHeight - 1));
},
// Palette drawing: x -> hue(0..360), y -> lightness(100..0), saturation fixed 100%
drawPalette() {
const canvas = this.$refs.paletteCanvas;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = this.paletteWidth, h = this.paletteHeight;
const img = ctx.createImageData(w, h);
let i = 0;
for (let y = 0; y < h; y++) {
const l = 100 - (y / (h - 1)) * 100; // 100..0
for (let x = 0; x < w; x++) {
const hue = (x / (w - 1)) * 360;
const rgb = this.hslToRgb(hue, 100, l);
img.data[i++] = rgb.r;
img.data[i++] = rgb.g;
img.data[i++] = rgb.b;
img.data[i++] = 255;
}
}
ctx.putImageData(img, 0, 0);
// Overlay the excluded too-light region at the top
const yLimit = Math.round(((100 - this.maxLightness) / 100) * (h - 1));
if (yLimit > 0) {
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillRect(0, 0, w, yLimit);
// Optional: boundary line
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.moveTo(0, yLimit + 0.5);
ctx.lineTo(w, yLimit + 0.5);
ctx.stroke();
}
},
onPaletteDown(e) {
this.isPicking = true;
this.pickFromEvent(e);
},
onPaletteMove(e) {
if (!this.isPicking) return;
this.pickFromEvent(e);
},
onPaletteUp() {
this.isPicking = false;
},
pickFromEvent(e) {
const rect = this.$refs.paletteCanvas.getBoundingClientRect();
let x = Math.round(e.clientX - rect.left);
let y = Math.round(e.clientY - rect.top);
x = Math.max(0, Math.min(this.paletteWidth - 1, x));
y = Math.max(0, Math.min(this.paletteHeight - 1, y));
let hue = (x / (this.paletteWidth - 1)) * 360;
let l = 100 - (y / (this.paletteHeight - 1)) * 100;
if (l > this.maxLightness) {
l = this.maxLightness;
// recompute y aligned with clamped l
y = Math.round(((100 - l) / 100) * (this.paletteHeight - 1));
}
this.pickX = x; this.pickY = y;
const hex = this.hslToHex(hue, 100, l);
this.selectedColor = hex;
this.hexInput = hex;
this.hexInvalid = false;
},
normalizeHex(val) {
let v = val.startsWith('#') ? val.slice(1) : val;
if (v.length === 3 && /^[0-9a-fA-F]{3}$/.test(v)) {
v = v.split('').map(ch => ch + ch).join('');
}
if (/^[0-9a-fA-F]{6}$/.test(v)) {
return '#' + v.toUpperCase();
}
return null;
},
hexToHsl(hex) {
const rgb = this.hexToRgb(hex);
return this.rgbToHsl(rgb.r, rgb.g, rgb.b);
},
hslToHex(h, s, l) {
const rgb = this.hslToRgb(h, s, l);
return this.rgbToHex(rgb.r, rgb.g, rgb.b);
},
hexToRgb(hex) {
const m = /^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(hex);
const r = parseInt(m[1], 16);
const g = parseInt(m[2], 16);
const b = parseInt(m[3], 16);
return { r, g, b };
},
rgbToHex(r, g, b) {
const toHex = (n) => n.toString(16).padStart(2, '0');
return '#' + toHex(r) + toHex(g) + toHex(b);
},
rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h *= 60;
}
return { h: Math.round(h || 0), s: Math.round(s * 100), l: Math.round(l * 100) };
},
hslToRgb(h, s, l) {
s /= 100; l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r1 = 0, g1 = 0, b1 = 0;
if (0 <= h && h < 60) { r1 = c; g1 = x; b1 = 0; }
else if (60 <= h && h < 120) { r1 = x; g1 = c; b1 = 0; }
else if (120 <= h && h < 180) { r1 = 0; g1 = c; b1 = x; }
else if (180 <= h && h < 240) { r1 = 0; g1 = x; b1 = c; }
else if (240 <= h && h < 300) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
const r = Math.round((r1 + m) * 255);
const g = Math.round((g1 + m) * 255);
const b = Math.round((b1 + m) * 255);
return { r, g, b };
},
// utility methods for WebSocket protocol
wsSend(obj) {
try {
const s = JSON.stringify(obj);
if (this.debug) console.log('[Chat WS >> RAW]', s);
this.chatWs?.send(s);
} catch (_) { }
},
sendWithToken(obj) {
if (this.token) {
obj.token = this.token;
this.wsSend(obj);
} else {
this.pending.push(() => this.sendWithToken(obj));
}
},
flushPending() {
const arr = this.pending; this.pending = [];
arr.forEach(fn => { try { fn(); } catch (_) { } });
},
wsProcessChunk(text) {
const parts = this.safeSplitJson(text);
for (const p of parts) {
try {
const obj = JSON.parse(p);
this.onWsObject(obj);
} catch (_) {
this.handleIncoming({ type: 'system', text: p });
}
}
},
safeSplitJson(str) {
if (!str) return [];
const trimmed = str.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) return [trimmed];
const arr = str.split('}{');
if (arr.length === 1) return [str];
return arr.map((seg, i) => (i === 0 ? seg + '}' : (i === arr.length - 1 ? '{' + seg : '{' + seg + '}')));
},
onWsObject(obj) {
if (!obj) return;
// Token handshake: only when explicitly marked as token-type
if (obj.type === 'token' || obj.type === 1) {
let tok = obj.token;
if (!tok) {
if (typeof obj.message === 'string') tok = obj.message;
else if (obj.message && typeof obj.message === 'object') tok = obj.message.token;
}
if (tok) {
this.token = tok;
// No extra join here; we already sent init with room
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
this.flushPending();
}
return;
}
// Type 7: user action (message is a JSON string with { action, to, tr })
if (obj.type === 7) {
const from = (obj.userName || obj.user || obj.name || '').toString();
let fromColor = obj.color ? ('' + obj.color).trim() : null;
if (fromColor && !fromColor.startsWith('#')) fromColor = '#' + fromColor;
let act = '', to = '';
try {
const payload = typeof obj.message === 'string' ? JSON.parse(obj.message) : obj.message;
if (payload && typeof payload === 'object') {
act = (payload.action || '').toString().trim();
to = (payload.to || '').toString().trim();
} else if (typeof payload === 'string') {
// if server ever sends plain string, best-effort: first word action, rest to
const parts = payload.trim().split(/\s+/);
act = parts.shift() || '';
to = parts.join(' ');
}
} catch (_) {
// ignore parse errors; will fallback below if needed
}
const toColor = this.userColors[to] || null;
const senderColor = fromColor || this.userColors[from] || null;
if (from && act) {
if (from && fromColor) this.userColors[from] = fromColor; // cache sender color
this.messages.push({
id: obj.id || Date.now(),
type: 'action',
user: from,
action: act,
to,
color: senderColor,
toColor
});
return;
}
// fallback: show as plain message
const fallbackText = (typeof obj.message === 'string') ? obj.message : JSON.stringify(obj.message || {});
this.handleIncoming({ type: 'message', message: fallbackText, userName: from, color: senderColor });
return;
}
// Type 2: user list in room
if (obj.type === 2) {
console.log('🔍 Type 2 message received:', obj);
const list = Array.isArray(obj.message) ? obj.message :
(Array.isArray(obj.users) ? obj.users :
(Array.isArray(obj.message?.userlist) ? obj.message.userlist : []));
console.log('📊 Extracted user list:', list);
const byName = new Map();
for (const it of list) {
if (it && typeof it === 'object') {
const name = it.userName || it.name || it.username || '';
if (!name) continue;
let color = (it.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
byName.set(name, { name, color: color || null });
// Cache known colors for messages
if (color) this.userColors[name] = color;
}
}
this.usersInRoom = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name, 'de'));
console.log('📊 Final usersInRoom:', this.usersInRoom);
// Auswahl behalten, falls User noch existiert; sonst löschen
if (this.selectedTargetUser && !byName.has(this.selectedTargetUser)) {
this.selectedTargetUser = null;
}
return;
}
if (obj.type === 3 && Array.isArray(obj.message)) {
const names = obj.message.map(r => r.name).filter(Boolean).join(', ');
this.handleIncoming({ type: 'system', text: names ? `Rooms: ${names}` : 'Rooms updated' });
return;
}
if (obj.type === 5) {
const msg = obj.message;
if (typeof msg === 'string') {
// Some servers send a JSON-encoded string here; parse if it looks like JSON
const s = msg.trim();
if (s.startsWith('{') && s.endsWith('}')) {
try {
const jm = JSON.parse(s);
const tr = jm.tr || jm.code || '';
if (tr === 'user_entered_room' || tr === 'user_entered_chat') {
const who = obj.userName || obj.user || obj.name || '';
// cache provided color for future messages
let color = (obj.color || jm.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
if (who && color) this.userColors[who] = color;
const to = jm.to || jm.name || jm.room || this.getSelectedRoomName();
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
const text = this.$t?.('chat.multichat.system.room_entered', { room: to }) || `Du betrittst ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
} else {
const text = `${who} betritt ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
}
return;
}
if (tr === 'user_left_room') {
const who = jm.userName || obj.userName || obj.user || obj.name || 'User';
const dest = jm.destination || jm.to || jm.name || jm.room || '';
let color = (jm.userColor || obj.color || this.userColors[who] || jm.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
// Action-style line with localized action mid-part and colored user name
this.messages.push({
id: Date.now(),
type: 'action',
user: who,
action: this.$t?.('chat.multichat.action_phrases.left_room') || 'wechselt zu Raum',
to: dest,
color,
toColor: null
});
// Update right-side user list (remove from current room)
const idx = this.usersInRoom.findIndex(u => u.name === who);
if (idx >= 0) this.usersInRoom.splice(idx, 1);
if (this.selectedTargetUser === who) this.selectedTargetUser = null;
return;
}
if (tr === 'user_disconnected') {
const who = obj.userName || obj.user || obj.name || 'User';
let color = (obj.color || this.userColors[who] || jm.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
this.messages.push({
id: Date.now(),
type: 'action',
user: who,
action: this.$t?.('chat.multichat.action_phrases.left_chat') || 'hat den Chat verlassen.',
to: '',
color,
toColor: null
});
const idx = this.usersInRoom.findIndex(u => u.name === who);
if (idx >= 0) this.usersInRoom.splice(idx, 1);
if (this.selectedTargetUser === who) this.selectedTargetUser = null;
return;
}
if (tr === 'room_change_user') {
const who = obj.userName || obj.user || obj.name || '';
const to = jm.to || jm.name || jm.room || this.getSelectedRoomName();
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
const text = this.$t?.('chat.multichat.system.room_entered', { room: to }) || `Du betrittst ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
} else {
const text = `${who} betritt ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
}
return;
}
if (tr === 'room_entered') {
const to = jm.to || jm.name || jm.room || '';
this.handleIncoming({ type: 'system', code: 'room_entered', tr: 'room_entered', to });
return;
}
if (tr === 'color_changed' || tr === 'user_color_changed') {
this.handleIncoming({ type: 'system', code: tr, color: obj.color || jm.color, userName: obj.userName || jm.userName || '' });
return;
}
// Fallback: forward as generic system event
{
const to = jm.to || jm.name || jm.room || '';
const code = tr || 'room_entered';
this.handleIncoming({ type: 'system', code, tr: code, to });
return;
}
} catch (_) { /* fall through to plain string handlers */ }
}
if (msg === 'user_entered_chat') {
const who = obj.userName || obj.user || obj.name || '';
// cache provided color for future messages
let color = (obj.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
if (who && color) this.userColors[who] = color;
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
}
const room = this.getSelectedRoomName();
const text = isSelf
? (this.$t?.('chat.multichat.system.room_entered', { room }) || `Du betrittst ${room}`)
: `${who} betritt ${room}`;
this.messages.push({ id: Date.now(), user: 'System', text });
return;
}
if (msg === 'user_entered_room') {
const who = obj.userName || obj.user || obj.name || '';
const room = this.getSelectedRoomName();
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
const text = this.$t?.('chat.multichat.system.room_entered', { room }) || `Du betrittst ${room}`;
this.messages.push({ id: Date.now(), user: 'System', text });
} else if (who) {
const text = `${who} betritt ${room}`;
this.messages.push({ id: Date.now(), user: 'System', text });
}
return;
}
if (msg === 'user_left_room') {
const who = obj.userName || obj.user || obj.name || 'User';
const room = this.getSelectedRoomName();
let color = (obj.color || this.userColors[who] || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
this.messages.push({
id: Date.now(),
type: 'action',
user: who,
action: this.$t?.('chat.multichat.action_phrases.leaves_room') || 'verlässt',
to: room,
color,
toColor: null
});
const idx = this.usersInRoom.findIndex(u => u.name === who);
if (idx >= 0) this.usersInRoom.splice(idx, 1);
if (this.selectedTargetUser === who) this.selectedTargetUser = null;
return;
}
if (msg === 'user_disconnected') {
const who = obj.userName || obj.user || obj.name || 'User';
let color = (obj.color || this.userColors[who] || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
// Render as an action-style system line, localized
this.messages.push({
id: Date.now(),
type: 'action',
user: who,
action: this.$t?.('chat.multichat.action_phrases.left_chat') || 'hat den Chat verlassen.',
to: '',
color,
toColor: null
});
// Remove from right-side user list
const idx = this.usersInRoom.findIndex(u => u.name === who);
if (idx >= 0) this.usersInRoom.splice(idx, 1);
if (this.selectedTargetUser === who) this.selectedTargetUser = null;
return;
}
if (msg === 'room_change_user') {
const who = obj.userName || obj.user || obj.name || '';
const isSelf = !!(this.user?.username && who === this.user.username);
// Without structured payload we can't know target room reliably; show generic info
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
}
const text = isSelf
? this.$t?.('chat.multichat.system.room_entered', { room: this.getSelectedRoomName() }) || `Du betrittst ${this.getSelectedRoomName()}`
: `${who} betritt ${this.getSelectedRoomName()}`;
this.messages.push({ id: Date.now(), user: 'System', text });
return;
}
if (msg === 'room_entered') {
const to = obj.to || obj.name || obj.room || '';
this.handleIncoming({ type: 'system', code: 'room_entered', tr: 'room_entered', to });
return;
}
if (msg === 'color_changed' || msg === 'user_color_changed') {
this.handleIncoming({ type: 'system', code: msg, color: obj.color, userName: obj.userName || '' });
return;
}
this.handleIncoming({ type: 'system', text: msg });
return;
}
if (typeof msg === 'object' && msg) {
const tr = msg.tr || '';
if (tr === 'user_entered_chat') {
const who = obj.userName || obj.user || obj.name || '';
let color = (obj.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
if (who && color) this.userColors[who] = color;
const to = msg.to || msg.name || msg.room || this.getSelectedRoomName();
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
const text = this.$t?.('chat.multichat.system.room_entered', { room: to }) || `Du betrittst ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
} else {
const text = `${who} betritt ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
}
return;
}
if (tr === 'user_entered_room') {
const who = obj.userName || obj.user || obj.name || '';
const to = msg.to || msg.name || msg.room || this.getSelectedRoomName();
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
const text = this.$t?.('chat.multichat.system.room_entered', { room: to }) || `Du betrittst ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
} else {
const text = `${who} betritt ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
}
return;
}
if (tr === 'user_left_room') {
const who = msg.userName || obj.userName || obj.user || obj.name || 'User';
const dest = msg.destination || msg.to || msg.name || msg.room || '';
let color = (msg.userColor || obj.color || this.userColors[who] || msg.color || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
this.messages.push({
id: Date.now(),
type: 'action',
user: who,
action: this.$t?.('chat.multichat.action_phrases.left_room') || 'wechselt zu Raum',
to: dest,
color,
toColor: null
});
const idx = this.usersInRoom.findIndex(u => u.name === who);
if (idx >= 0) this.usersInRoom.splice(idx, 1);
if (this.selectedTargetUser === who) this.selectedTargetUser = null;
return;
}
if (tr === 'user_disconnected') {
const who = obj.userName || obj.user || obj.name || 'User';
let color = (obj.color || this.userColors[who] || '').toString().trim();
if (color && !color.startsWith('#')) color = '#' + color;
this.messages.push({
id: Date.now(),
type: 'action',
user: who,
action: this.$t?.('chat.multichat.action_phrases.left_chat') || 'hat den Chat verlassen.',
to: '',
color,
toColor: null
});
const idx = this.usersInRoom.findIndex(u => u.name === who);
if (idx >= 0) this.usersInRoom.splice(idx, 1);
if (this.selectedTargetUser === who) this.selectedTargetUser = null;
return;
}
// Explicit handling for user room changes
if (tr === 'room_change_user') {
const who = obj.userName || obj.user || obj.name || '';
const to = msg.to || msg.name || msg.room || '';
const isSelf = !!(this.user?.username && who === this.user.username);
if (isSelf) {
this.chatConnected = true;
this.setStatus('connected');
// Show the same line we use for room_entered for consistency
const text = this.$t?.('chat.multichat.system.room_entered', { room: to }) || `Du betrittst ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
} else {
const text = `${who} betritt ${to}`;
this.messages.push({ id: Date.now(), user: 'System', text });
}
return;
}
const to = msg.to || msg.name || msg.room || '';
const code = tr || 'room_entered';
this.handleIncoming({ type: 'system', code, tr: code, to });
return;
}
}
if (obj.type === 6) {
this.handleIncoming({ type: 'scream', userName: obj.userName || obj.user || obj.name || '', message: obj.message || '', color: obj.color || null });
return;
}
if (typeof obj.message === 'string' && (obj.userName || obj.user || obj.name || obj.token)) {
// Pass through token if present so we can detect self-message when userName is missing
this.handleIncoming({ type: 'message', message: obj.message, userName: obj.userName || obj.user || obj.name, color: obj.color || null, token: obj.token });
return;
}
this.handleIncoming({ type: 'system', text: JSON.stringify(obj) });
}
}
};
</script>
<style scoped>
.multi-chat-top {
display: flex;
align-items: center;
gap: 1em;
margin-bottom: 0.5em;
justify-content: space-between;
}
.room-select {
min-width: 10em;
}
.right-controls {
display: flex;
align-items: center;
gap: 0.6em;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.2em 0.5em;
border-radius: 4px;
border: 1px solid #bbb;
background: #f9f9f9;
}
.status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #999;
}
.status.connected .dot {
background: #2e7d32;
}
.status.connecting .dot {
background: #f9a825;
}
.status.disconnected .dot {
background: #6d4c41;
}
.status.error .dot {
background: #c62828;
}
.reconnect-btn {
margin-left: 0.4em;
border: 1px solid #bbb;
background: #fff;
border-radius: 4px;
padding: 0.1em 0.4em;
cursor: pointer;
}
.options-dropdown-wrapper {
position: relative;
}
.options-btn {
background: #f5f5f5;
border-radius: 4px;
padding: 0.3em 0.8em;
font-size: 0.95em;
border: 1px solid #bbb;
cursor: pointer;
}
.options-dropdown {
position: absolute;
top: 2.2em;
right: 0;
background: #fff;
border: 1px solid #bbb;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 0.7em 1.2em;
z-index: 10;
min-width: 12em;
}
.opts-row {
display: flex;
gap: 0.4em;
align-items: center;
margin-top: 0.4em;
}
.opts-divider {
height: 1px;
background: #eee;
margin: 0.5em 0;
}
.url-override {
min-width: 16em;
}
.multi-chat-output {
background: #fff;
color: #000;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-bottom: 0.5em;
padding: 0.7em;
border-radius: 4px;
font-size: 1em;
display: flex;
flex-direction: column;
border: 1px solid #222;
}
.chat-message {
margin-bottom: 0.3em;
}
.user {
font-weight: bold;
color: #90caf9;
}
.scream-label {
font-weight: bold;
text-transform: none;
}
.action-text,
.action-target {
display: inline;
}
.action-text::before,
.action-target::before {
content: " ";
}
.color-picker-panel {
background: #f5f5f5;
border-radius: 6px;
padding: 1em;
display: flex;
flex-direction: column;
gap: 0.8em;
height: 16em;
}
.picker-row label {
display: flex;
align-items: center;
gap: 0.6em;
}
.picker-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
}
.palette-wrap {
position: relative;
width: max-content;
}
.palette-marker {
position: absolute;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
pointer-events: none;
}
.picker-column {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.picker-label {
min-width: 7em;
}
.hex-input {
width: 10em;
}
.picker-val {
min-width: 3em;
text-align: right;
}
.picker-sample {
border: 1px solid #ddd;
border-radius: 4px;
height: 6em;
}
.picker-error {
color: #b00020;
}
.picker-preview {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.6em 0.8em;
font-weight: bold;
}
.picker-actions {
display: flex;
gap: 0.6em;
}
.picker-actions .btn {
padding: 0.4em 0.8em;
}
.picker-actions .btn.secondary {
background: #eee;
}
.multi-chat-input {
display: flex;
align-items: center;
gap: 0.5em;
}
.chat-input {
flex: 1;
padding: 0.4em 0.7em;
border-radius: 3px;
border: 1px solid #bbb;
}
.send-btn {
padding: 0.3em 1.1em;
border-radius: 3px;
background: #1976d2;
color: #fff;
border: none;
cursor: pointer;
}
.icon-btn {
width: 24px;
height: 24px;
cursor: pointer;
margin-left: 0.2em;
vertical-align: middle;
}
.icon-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dialog-form,
.multi-chat-input {
flex-shrink: 0;
}
.dialog-widget-content {
display: flex;
flex-direction: column;
height: 100%;
}
/* Two-column chat + user list */
.multi-chat-body {
display: grid;
grid-template-columns: 1fr 220px;
gap: 0.6em;
flex: 1 1 auto;
min-height: 0;
}
.user-list {
display: flex;
flex-direction: column;
min-height: 0;
}
.user-list-header {
font-weight: bold;
margin-bottom: 0.4em;
}
.user-list-items {
border: 1px solid #222;
background: #fff;
border-radius: 4px;
padding: 0.4em 0.5em;
overflow-y: auto;
min-height: 0;
}
.user-list-item {
display: flex;
align-items: center;
gap: 0.4em;
padding: 0.15em 0.1em;
}
.user-list-item.selected {
background: rgba(25, 118, 210, 0.12);
border-radius: 4px;
}
.user-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ccc;
}
.user-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>