mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
chat typing and classic typing beeps
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CmQgqYSs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Vt8gngD9.css">
|
||||
<script type="module" crossorigin src="/assets/index-CP3udCxF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-GYsISCRb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -33,6 +33,10 @@ const profanityMatcher = new RegExpMatcher({
|
||||
});
|
||||
const DUPLICATE_WINDOW_MS = 15000;
|
||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||
const typingBySocket = new Map(); // socketId -> boolean
|
||||
const TYPING_START_NOTE = 72;
|
||||
const TYPING_SEND_NOTE = 79;
|
||||
const TYPING_NOTE_DURATION = 8;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
@@ -101,6 +105,50 @@ function buildMessage(socket, text, meta = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const socketId = socket?.id || null;
|
||||
const fromDiscord = Boolean(meta.fromDiscord);
|
||||
let typingId = meta.typingId || null;
|
||||
if (!typingId) {
|
||||
if (fromDiscord) {
|
||||
if (meta.discordUserId) {
|
||||
typingId = `discord:${meta.discordUserId}`;
|
||||
} else if (meta.discordUserName) {
|
||||
typingId = `discord:${meta.discordUserName}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `discord:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'discord:unknown';
|
||||
}
|
||||
} else if (socketId) {
|
||||
typingId = `socket:${socketId}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `socket:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'socket:unknown';
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
typingId,
|
||||
isTyping: Boolean(meta.isTyping),
|
||||
socketId,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
fromDiscord,
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(message) {
|
||||
history.push(message);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
@@ -113,6 +161,27 @@ function broadcastMessage(message) {
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
}
|
||||
|
||||
function broadcastTyping(payload) {
|
||||
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
||||
}
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note, duration: TYPING_NOTE_DURATION }],
|
||||
},
|
||||
});
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone sent', { roverId, note, socketId });
|
||||
} catch (err) {
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtsOptions(raw = {}) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const speak = raw.speak !== false;
|
||||
@@ -162,6 +231,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
const ttsOptions = normalizeTtsOptions(tts);
|
||||
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
broadcastMessage(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
@@ -233,9 +303,63 @@ function sendExternalMessage({
|
||||
return message;
|
||||
}
|
||||
|
||||
function sendExternalTyping({
|
||||
nickname = 'Discord',
|
||||
role = 'user',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
isTyping = true,
|
||||
}) {
|
||||
const payload = buildTypingPayload(null, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
isTyping,
|
||||
});
|
||||
broadcastTyping(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.emit('chat:init', history);
|
||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||
socket.on('chat:typing', (payload = {}) => {
|
||||
const isTyping = Boolean(payload?.isTyping);
|
||||
const wasTyping = typingBySocket.get(socket.id);
|
||||
if (isTyping) {
|
||||
typingBySocket.set(socket.id, true);
|
||||
if (!wasTyping) {
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||
}
|
||||
} else {
|
||||
typingBySocket.delete(socket.id);
|
||||
}
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (!typingBySocket.has(socket.id)) return;
|
||||
typingBySocket.delete(socket.id);
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
@@ -243,7 +367,14 @@ subscribe('chat:message', ({ payload }) => {
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
|
||||
subscribe('chat:typing', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:typing', payload);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const { loadConfig } = require('../helpers/configLoader');
|
||||
const { subscribe } = require('./eventBus');
|
||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||
const { MODES, getMode, setMode } = require('./modeManager');
|
||||
const { sendExternalMessage } = require('./chatService');
|
||||
const { sendExternalMessage, sendExternalTyping } = require('./chatService');
|
||||
const { buildReplayVideo } = require('./replayBuildService');
|
||||
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
@@ -45,6 +45,7 @@ if (!enabled) {
|
||||
const intents = [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageTyping,
|
||||
GatewayIntentBits.MessageContent,
|
||||
];
|
||||
|
||||
@@ -54,6 +55,7 @@ const client = new Client({
|
||||
});
|
||||
|
||||
const channelCache = new Map();
|
||||
const typingMessageCache = new Map();
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
const PRESENCE_ROTATE_MS = 20000;
|
||||
let presenceInterval = null;
|
||||
@@ -755,6 +757,60 @@ function formatWebhookUsername(payload) {
|
||||
return suffix ? `${name} · ${suffix}` : name;
|
||||
}
|
||||
|
||||
function getTypingId(payload = {}) {
|
||||
if (payload.typingId) return payload.typingId;
|
||||
if (payload.fromDiscord) {
|
||||
if (payload.discordUserId) return `discord:${payload.discordUserId}`;
|
||||
if (payload.discordUserName) return `discord:${payload.discordUserName}`;
|
||||
if (payload.nickname) return `discord:${payload.nickname}`;
|
||||
return 'discord:unknown';
|
||||
}
|
||||
if (payload.socketId) return `socket:${payload.socketId}`;
|
||||
if (payload.nickname) return `socket:${payload.nickname}`;
|
||||
return 'socket:unknown';
|
||||
}
|
||||
|
||||
function typingCacheKey(guildId, typingId) {
|
||||
return `${guildId}:${typingId}`;
|
||||
}
|
||||
|
||||
async function clearTypingMessage(guildId, typingId) {
|
||||
const key = typingCacheKey(guildId, typingId);
|
||||
const record = typingMessageCache.get(key);
|
||||
if (!record) return;
|
||||
typingMessageCache.delete(key);
|
||||
if (record.timeoutId) clearTimeout(record.timeoutId);
|
||||
const channel = await fetchChannel(record.channelId);
|
||||
if (!channel?.messages?.fetch) return;
|
||||
try {
|
||||
const msg = await channel.messages.fetch(record.messageId);
|
||||
await msg.delete();
|
||||
} catch (err) {
|
||||
if (err?.code !== 10008) {
|
||||
logger.warn('Failed to delete typing message', { guildId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTypingMessage(entry, payload) {
|
||||
const typingId = getTypingId(payload);
|
||||
const key = typingCacheKey(entry.guildId, typingId);
|
||||
if (typingMessageCache.has(key)) return;
|
||||
const channel = await fetchChannel(entry.channelId);
|
||||
if (!channel?.send) return;
|
||||
const username = formatWebhookUsername(payload);
|
||||
const content = `-# *${username} is typing...*`;
|
||||
try {
|
||||
const message = await channel.send({ content, allowedMentions: { parse: [] } });
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearTypingMessage(entry.guildId, typingId);
|
||||
}, 20000);
|
||||
typingMessageCache.set(key, { channelId: entry.channelId, messageId: message.id, timeoutId });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to send typing message', { guildId: entry.guildId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBridgeInbound(message) {
|
||||
if (!message.guild) return;
|
||||
const guildConfig = getGuildConfig(message.guild.id);
|
||||
@@ -1218,6 +1274,7 @@ function handleChatBridgeOutbound(event) {
|
||||
const avatarURL = payload.fromDiscord
|
||||
? payload.discordUserAvatarUrl || null
|
||||
: client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
||||
const typingId = getTypingId(payload);
|
||||
guildConfigs.forEach((entry) => {
|
||||
if (!entry?.channelId || !entry?.webhookId || !entry?.webhookToken) return;
|
||||
if (payload.fromDiscord) {
|
||||
@@ -1234,12 +1291,61 @@ function handleChatBridgeOutbound(event) {
|
||||
avatarURL,
|
||||
allowedMentions: { parse: [] },
|
||||
})
|
||||
.then(() => {
|
||||
if (!payload.fromDiscord) {
|
||||
clearTypingMessage(entry.guildId, typingId);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Failed to send webhook message', { guildId: entry.guildId, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleChatTypingOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload || payload.fromDiscord) return;
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
guildConfigs.forEach((entry) => {
|
||||
if (!entry?.channelId) return;
|
||||
if (payload.isTyping) {
|
||||
sendTypingMessage(entry, payload);
|
||||
} else {
|
||||
clearTypingMessage(entry.guildId, getTypingId(payload));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiscordTypingStart(typing) {
|
||||
const channelId = typing?.channelId || typing?.channel?.id || null;
|
||||
const guildId = typing?.guild?.id || typing?.channel?.guild?.id || null;
|
||||
if (!guildId || !channelId) return;
|
||||
const guildConfig = getGuildConfig(guildId);
|
||||
if (!guildConfig?.channelId) return;
|
||||
if (String(channelId) !== String(guildConfig.channelId)) return;
|
||||
const user = typing?.user || null;
|
||||
if (user?.bot) return;
|
||||
const member = typing?.member || null;
|
||||
const nickname = member?.nickname || user?.globalName || user?.username || 'Discord';
|
||||
const role = isAdminUser(user?.id) ? 'admin' : 'user';
|
||||
const guildIconUrl = typing?.guild?.iconURL?.({ extension: 'png', size: 64 }) || null;
|
||||
const userAvatarUrl = user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null;
|
||||
sendExternalTyping({
|
||||
nickname,
|
||||
role,
|
||||
roverId: null,
|
||||
discordGuildId: guildId,
|
||||
discordGuildName: typing?.guild?.name || null,
|
||||
discordGuildIconUrl: guildIconUrl,
|
||||
discordChannelId: channelId,
|
||||
discordUserId: user?.id || null,
|
||||
discordUserName: user?.globalName || user?.username || null,
|
||||
discordUserAvatarUrl: userAvatarUrl,
|
||||
isTyping: true,
|
||||
});
|
||||
}
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
try {
|
||||
await handleCommand(message);
|
||||
@@ -1249,6 +1355,12 @@ client.on('messageCreate', async (message) => {
|
||||
}
|
||||
});
|
||||
|
||||
client.on('typingStart', (typing) => {
|
||||
handleDiscordTypingStart(typing).catch((err) => {
|
||||
logger.warn('Error handling Discord typing', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
client.once('ready', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
schedulePresenceRotation();
|
||||
@@ -1256,6 +1368,7 @@ client.once('ready', () => {
|
||||
|
||||
subscribe('*', handleBusEvent);
|
||||
subscribe('chat:message', handleChatBridgeOutbound);
|
||||
subscribe('chat:typing', handleChatTypingOutbound);
|
||||
|
||||
client.login(discordConfig.token).catch((err) => {
|
||||
logger.error('Discord login failed', err.message);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||
import ChatTypingRow from './ChatTypingRow.jsx';
|
||||
|
||||
const LIFETIME_MS = 3000;
|
||||
const DEFAULT_COLOR = '#2196f3';
|
||||
@@ -68,6 +69,9 @@ function AlertToast({ alert }) {
|
||||
if (alert.kind === 'chat' && alert.payload) {
|
||||
return <ChatMessageRow message={alert.payload} />;
|
||||
}
|
||||
if (alert.kind === 'chat-typing' && alert.payload) {
|
||||
return <ChatTypingRow message={alert.payload} />;
|
||||
}
|
||||
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
|
||||
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
|
||||
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
|
||||
|
||||
@@ -57,23 +57,12 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatMessageRow({ message }) {
|
||||
const isAdmin =
|
||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||
export function ChatIdentity({ message }) {
|
||||
const discordLabel = message.fromDiscord
|
||||
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||
isAdmin
|
||||
? 'border border-amber-400/30'
|
||||
: message.fromDiscord
|
||||
? 'border border-indigo-400/30 bg-indigo-900/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<>
|
||||
{message.fromDiscord ? (
|
||||
<>
|
||||
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
||||
@@ -90,6 +79,26 @@ export default function ChatMessageRow({ message }) {
|
||||
{message.roverId && (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function chatRowClass(message) {
|
||||
const isAdmin =
|
||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||
isAdmin
|
||||
? 'border border-amber-400/30'
|
||||
: message.fromDiscord
|
||||
? 'border border-indigo-400/30 bg-indigo-900/20'
|
||||
: ''
|
||||
}`;
|
||||
}
|
||||
|
||||
export default function ChatMessageRow({ message }) {
|
||||
return (
|
||||
<div className={chatRowClass(message)}>
|
||||
<ChatIdentity message={message} />
|
||||
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
|
||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
||||
{formatTime(message.ts)}
|
||||
@@ -97,3 +106,5 @@ export default function ChatMessageRow({ message }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { chatRowClass };
|
||||
|
||||
@@ -3,13 +3,23 @@ import { useChat } from '../context/ChatContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||
import ChatTypingRow from './ChatTypingRow.jsx';
|
||||
|
||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
||||
|
||||
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
|
||||
const { session } = useSession();
|
||||
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
||||
const {
|
||||
messages,
|
||||
typing,
|
||||
sendMessage,
|
||||
registerInputRef,
|
||||
onInputFocus,
|
||||
onInputBlur,
|
||||
blurChat,
|
||||
setTypingActive,
|
||||
} = useChat();
|
||||
const {
|
||||
value: ttsSettings,
|
||||
save: saveTtsSettings,
|
||||
@@ -31,11 +41,12 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||
|
||||
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
||||
const typingRows = useMemo(() => typing || [], [typing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}, [sorted]);
|
||||
}, [sorted, typingRows]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextEngine = ttsSettings?.engine || 'flite';
|
||||
@@ -74,6 +85,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
await sendMessage(clean, ttsPayload);
|
||||
setDraft('');
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
@@ -86,24 +98,38 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
return (
|
||||
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
||||
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||
{sorted.length === 0 ? (
|
||||
{sorted.length === 0 && typingRows.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||
) : (
|
||||
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
||||
)}
|
||||
{typingRows.map((entry) => (
|
||||
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
||||
))}
|
||||
</div>
|
||||
{!hideInput && (
|
||||
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
|
||||
<input
|
||||
className="field-input flex-1 min-w-[10rem]"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
setTypingActive(Boolean(next.trim()));
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
onInputFocus(event);
|
||||
setTypingActive(Boolean(draft.trim()));
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onInputBlur(event);
|
||||
setTypingActive(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !draft.trim()) {
|
||||
event.preventDefault();
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
}
|
||||
}}
|
||||
ref={(el) => registerInputRef(el, { target: 'panel' })}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ChatIdentity, chatRowClass } from './ChatMessageRow.jsx';
|
||||
|
||||
export default function ChatTypingRow({ message }) {
|
||||
return (
|
||||
<div className={`${chatRowClass(message)} italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
|
||||
<ChatIdentity message={message} />
|
||||
<span className="text-slate-300">typing...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -812,7 +812,7 @@ function LowBatteryOverlay({ charge, config, compact = false }) {
|
||||
|
||||
function HudChatInput({ compact = false }) {
|
||||
const { session } = useSession();
|
||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef } = useChat();
|
||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
|
||||
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -857,6 +857,7 @@ function HudChatInput({ compact = false }) {
|
||||
await sendMessage(clean, ttsPayload);
|
||||
setDraft('');
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
@@ -871,13 +872,24 @@ function HudChatInput({ compact = false }) {
|
||||
<input
|
||||
className={inputClass}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDraft(next);
|
||||
setTypingActive(Boolean(next.trim()));
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
onInputFocus(event);
|
||||
setTypingActive(Boolean(draft.trim()));
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onInputBlur(event);
|
||||
setTypingActive(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !draft.trim()) {
|
||||
event.preventDefault();
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
}
|
||||
}}
|
||||
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
||||
|
||||
@@ -7,12 +7,14 @@ import messageSound from '../assets/message.mp3';
|
||||
|
||||
const ChatContext = createContext({
|
||||
messages: [],
|
||||
typing: [],
|
||||
sendMessage: async () => {},
|
||||
focusChat: () => {},
|
||||
blurChat: () => {},
|
||||
registerInputRef: () => {},
|
||||
onInputFocus: () => {},
|
||||
onInputBlur: () => {},
|
||||
setTypingActive: () => {},
|
||||
isChatFocused: false,
|
||||
});
|
||||
|
||||
@@ -20,10 +22,30 @@ export function ChatProvider({ children }) {
|
||||
const socket = useSocket();
|
||||
const { session, pushAlert } = useSession();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
const panelInputRef = useRef(null);
|
||||
const hudInputRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const typingRef = useRef(new Map());
|
||||
const typingAlertRef = useRef(new Map());
|
||||
const typingStateRef = useRef({ isTyping: false, lastSent: 0 });
|
||||
|
||||
const rebuildTyping = useCallback(() => {
|
||||
const entries = Array.from(typingRef.current.values())
|
||||
.sort((a, b) => a.lastUpdate - b.lastUpdate)
|
||||
.map((entry) => entry.payload);
|
||||
setTyping(entries);
|
||||
}, []);
|
||||
|
||||
const resolveTypingKey = useCallback((payload) => {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
if (payload.typingId) return payload.typingId;
|
||||
if (payload.fromDiscord) {
|
||||
return `discord:${payload.discordUserId || payload.discordUserName || payload.nickname || 'unknown'}`;
|
||||
}
|
||||
return `socket:${payload.socketId || payload.nickname || 'unknown'}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio(messageSound);
|
||||
@@ -57,6 +79,62 @@ export function ChatProvider({ children }) {
|
||||
};
|
||||
}, [playSound, session?.socketId, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleTyping(payload = {}) {
|
||||
const key = resolveTypingKey(payload);
|
||||
if (!key) return;
|
||||
const now = Date.now();
|
||||
if (payload?.socketId && session?.socketId && payload.socketId === session.socketId) {
|
||||
if (!payload.isTyping) {
|
||||
typingRef.current.delete(key);
|
||||
rebuildTyping();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.isTyping) {
|
||||
typingRef.current.set(key, {
|
||||
payload,
|
||||
expiresAt: now + 6000,
|
||||
lastUpdate: now,
|
||||
});
|
||||
const lastAlertAt = typingAlertRef.current.get(key) || 0;
|
||||
if (now - lastAlertAt >= 2500) {
|
||||
typingAlertRef.current.set(key, now);
|
||||
pushAlert?.({
|
||||
kind: 'chat-typing',
|
||||
payload,
|
||||
id: `chat-typing-${key}-${payload.id || Math.random().toString(36).slice(2)}`,
|
||||
receivedAt: now,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
typingRef.current.delete(key);
|
||||
}
|
||||
rebuildTyping();
|
||||
}
|
||||
socket.on('chat:typing', handleTyping);
|
||||
return () => {
|
||||
socket.off('chat:typing', handleTyping);
|
||||
};
|
||||
}, [pushAlert, rebuildTyping, resolveTypingKey, session?.socketId, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
typingRef.current.forEach((entry, key) => {
|
||||
if (entry.expiresAt <= now) {
|
||||
typingRef.current.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
rebuildTyping();
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [rebuildTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleInit(payload = []) {
|
||||
if (!Array.isArray(payload)) return;
|
||||
@@ -75,6 +153,21 @@ export function ChatProvider({ children }) {
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const setTypingActive = useCallback(
|
||||
(next) => {
|
||||
const isTyping = Boolean(next);
|
||||
const now = Date.now();
|
||||
const last = typingStateRef.current;
|
||||
const shouldSendStop = !isTyping && last.isTyping;
|
||||
const shouldSendStart =
|
||||
isTyping && (!last.isTyping || now - last.lastSent >= 3500);
|
||||
if (!shouldSendStart && !shouldSendStop) return;
|
||||
typingStateRef.current = { isTyping, lastSent: now };
|
||||
socket.emit('chat:typing', { isTyping });
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(text, tts = null) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -121,10 +214,12 @@ export function ChatProvider({ children }) {
|
||||
registerInputRef,
|
||||
onInputFocus,
|
||||
onInputBlur,
|
||||
setTypingActive,
|
||||
typing,
|
||||
isChatFocused,
|
||||
selfSocketId: session?.socketId || null,
|
||||
}),
|
||||
[blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId],
|
||||
[blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId, setTypingActive, typing],
|
||||
);
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||
|
||||
Reference in New Issue
Block a user