Ä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.
1781 lines
80 KiB
Vue
1781 lines
80 KiB
Vue
<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>
|