From 187f71e68eea99fa37e50212f9913b05911b7b11 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:50:03 +0200 Subject: [PATCH] feat: add dm_typing_stop event to clear typing indicator on message send Broadcasts dm_typing_stop to DM members before dm_message_created, so the typing indicator clears immediately when a message arrives instead of lingering for up to 3 seconds after delivery. --- packages/server/src/routes/dm.ts | 11 +++++++++++ packages/server/src/ws/events.ts | 8 ++++++++ packages/shared/src/types.ts | 1 + packages/web/src/hooks/useWebSocket.ts | 7 ++++++- 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index ac5db21b..ac8a3269 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -180,6 +180,17 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) .all(); + // Clear typing indicator for the message author — the message itself proves they stopped + for (const member of dmMembers) { + if (member.userId !== message.userId) { + connectionManager.sendToUser(member.userId, { + type: 'dm_typing_stop', + dmChannelId, + userId: message.userId, + }); + } + } + for (const member of dmMembers) { // If this member had closed the DM, resurface it first if (member.closed === 1) { diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 15dd82ba..7b689ab6 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -893,6 +893,14 @@ function handleDmMessageCreate(event: Record, userId: string): // Federation: queue for relay queueDmRelay(dmMessage, dmChannelId, 'create'); + // Clear any pending typing timeout for this user+channel + const typingKey = `dm:${userId}:${dmChannelId}`; + const existingTimeout = typingTimeouts.get(typingKey); + if (existingTimeout) { + clearTimeout(existingTimeout); + typingTimeouts.delete(typingKey); + } + // Resolve embeds asynchronously setImmediate(() => { resolveEmbeds(messageId, hasContent ? content!.trim() : null, dmChannelId, true, null).catch(() => {}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5cea3ecf..0cc9552e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -378,6 +378,7 @@ export type ServerEvent = | { type: 'dm_message_updated'; message: DmMessageWithUser } | { type: 'dm_message_deleted'; messageId: string; dmChannelId: string } | { type: 'dm_typing'; dmChannelId: string; userId: string; username: string } + | { type: 'dm_typing_stop'; dmChannelId: string; userId: string } | { type: 'reaction_added'; messageId: string; reaction: Reaction } | { type: 'reaction_removed'; messageId: string; userId: string; emoji: string } | { type: 'channel_ack'; channelId: string; messageId: string } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 7a94165a..08abe14a 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -87,7 +87,7 @@ function handleEvent(origin: string, event: ServerEvent): void { const isHome = origin === HOME_ORIGIN; const { setUser } = useAuthStore.getState(); const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState(); - const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); + const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, clearTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); const { addVoiceUser, removeVoiceUser, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState(); switch (event.type) { @@ -680,6 +680,11 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'dm_typing_stop': { + clearTyping(event.dmChannelId as string, event.userId as string); + break; + } + // ─── Reactions (all origins) ──────────────────────────────────────────── case 'reaction_added':