chat typing and classic typing beeps

This commit is contained in:
legop3
2026-01-22 04:18:55 -05:00
parent c30325afc0
commit dbc5111c53
13 changed files with 549 additions and 147 deletions
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
+2 -2
View File
@@ -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>
+131
View File
@@ -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,
};
+114 -1
View File
@@ -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);
+4
View File
@@ -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)';
+24 -13
View File
@@ -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 };
+32 -6
View File
@@ -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' })}
+10
View File
@@ -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>
);
}
+16 -4
View File
@@ -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' })}
+96 -1
View File
@@ -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>;