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);
}
}
}