From 7416f37cec7f4e1fc58169a7823b96de7d229275 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Thu, 16 Apr 2026 19:29:34 +0800 Subject: [PATCH] fix(room): prevent double-send, log resubscribe errors, dim pending messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendMessage: guard with sendingRef to prevent concurrent in-flight sends (was missing — rapid clicks could create duplicate messages) - resubscribeAll: log at warn level instead of silently swallowing, so operators can observe auth expiry or persistent failure patterns - RoomMessageBubble: apply opacity-60 when isPending or isFailed, and hide action toolbar for pending messages (can't react/act on unconfirmed messages) --- src/components/room/RoomMessageBubble.tsx | 3 ++- src/contexts/room-context.tsx | 9 +++++++++ src/lib/room-ws-client.ts | 10 ++++++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/components/room/RoomMessageBubble.tsx b/src/components/room/RoomMessageBubble.tsx index 6e06011..42b22ad 100644 --- a/src/components/room/RoomMessageBubble.tsx +++ b/src/components/room/RoomMessageBubble.tsx @@ -215,6 +215,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({ grouped ? 'py-0.5' : 'py-2', !isSystem && 'hover:bg-muted/30', isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5', + (isPending || isFailed) && 'opacity-60', )} > {/* Avatar */} @@ -416,7 +417,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({ )} {/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */} - {!isEditing && !isRevoked && ( + {!isEditing && !isRevoked && !isPending && (
{isNarrow ? ( /* Narrow: all actions in dropdown */ diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index 4b7f552..9322d36 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -784,10 +784,17 @@ export function RoomProvider({ [activeRoomId], ); + // Guard against double-sending while a previous send is in-flight. + // Without this, rapid clicking can queue multiple optimistic messages and + // create duplicate sends on the server. + const sendingRef = useRef(false); + const sendMessage = useCallback( async (content: string, contentType = 'text', inReplyTo?: string) => { const client = wsClientRef.current; if (!activeRoomId || !client) return; + if (sendingRef.current) return; + sendingRef.current = true; // Optimistic update: add message immediately so user sees it instantly const optimisticId = `optimistic-${crypto.randomUUID()}`; @@ -843,6 +850,8 @@ export function RoomProvider({ ), ); handleRoomError('Send message', err); + } finally { + sendingRef.current = false; } }, [activeRoomId, user], diff --git a/src/lib/room-ws-client.ts b/src/lib/room-ws-client.ts index 67b41e8..9ba0476 100644 --- a/src/lib/room-ws-client.ts +++ b/src/lib/room-ws-client.ts @@ -916,15 +916,17 @@ export class RoomWsClient { for (const roomId of this.subscribedRooms) { try { await this.request('room.subscribe', { room_id: roomId }); - } catch { - // ignore + } catch (err) { + // Resubscribe failure is non-fatal — messages still arrive via REST poll. + // Log at warn level so operators can observe patterns (e.g. auth expiry). + console.warn(`[RoomWs] resubscribe room failed (will retry on next reconnect): ${roomId}`, err); } } for (const projectName of this.subscribedProjects) { try { await this.request('project.subscribe', { project_name: projectName }); - } catch { - // ignore + } catch (err) { + console.warn(`[RoomWs] resubscribe project failed (will retry on next reconnect): ${projectName}`, err); } } }