Skip to content

Commit 08d1e44

Browse files
ncavaglioneclaude
andcommitted
fix: Bug contains-studio#8 — pin/unpin messages update in real-time via WebSocket
After pinning or unpinning a message, the backend now emits a message:updated event to all channel members. The frontend already handled this event to update the message store (pin indicator), and PinsPanel now subscribes to message:updated to refresh its list without a page reload. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d2b1bf commit 08d1e44

File tree

4 files changed

+138
-2
lines changed

4 files changed

+138
-2
lines changed

backend/src/routes/threads.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod';
33
import prisma from '../db.js';
44
import { authMiddleware } from '../middleware/auth.js';
55
import { AuthRequest } from '../types.js';
6+
import { getIO } from '../websocket/index.js';
67

78
const router = Router();
89

@@ -264,6 +265,12 @@ router.post('/:id/pin', authMiddleware, async (req: AuthRequest, res: Response)
264265
},
265266
});
266267

268+
// Broadcast the updated message to all users in the channel
269+
const io = getIO();
270+
if (io) {
271+
io.to(`channel:${updated.channelId}`).emit('message:updated', updated);
272+
}
273+
267274
res.json(updated);
268275
} catch (error) {
269276
console.error('Pin message error:', error);
@@ -311,6 +318,12 @@ router.delete('/:id/pin', authMiddleware, async (req: AuthRequest, res: Response
311318
},
312319
});
313320

321+
// Broadcast the updated message to all users in the channel
322+
const io = getIO();
323+
if (io) {
324+
io.to(`channel:${updated.channelId}`).emit('message:updated', updated);
325+
}
326+
314327
res.json(updated);
315328
} catch (error) {
316329
console.error('Unpin message error:', error);

backend/src/websocket/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export function initializeWebSocket(httpServer: HttpServer) {
6060
},
6161
});
6262

63+
// Store module-level reference so REST routes can broadcast events
64+
ioInstance = io;
65+
6366
// Authentication middleware
6467
io.use((socket: AuthenticatedSocket, next) => {
6568
const token = socket.handshake.auth.token;
@@ -435,6 +438,13 @@ export function initializeWebSocket(httpServer: HttpServer) {
435438
return io;
436439
}
437440

441+
// Module-level io reference so REST routes can emit events
442+
let ioInstance: Server | null = null;
443+
444+
export function getIO(): Server | null {
445+
return ioInstance;
446+
}
447+
438448
// Export for use in REST endpoints
439449
export function isUserOnline(userId: number): boolean {
440450
return onlineUsers.has(userId) && onlineUsers.get(userId)!.size > 0;

frontend/src/components/Messages/PinsPanel.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect, useCallback } from 'react';
22
import { X, Pin } from 'lucide-react';
33
import { format } from 'date-fns';
44
import { getPinnedMessages, type ApiMessage } from '@/lib/api';
5+
import { getSocket } from '@/lib/socket';
56
import { Avatar } from '@/components/ui/avatar';
67
import { renderMessageContent } from '@/lib/renderMessageContent';
78

@@ -14,14 +15,38 @@ export function PinsPanel({ channelId, onClose }: PinsPanelProps) {
1415
const [pins, setPins] = useState<ApiMessage[]>([]);
1516
const [isLoading, setIsLoading] = useState(true);
1617

17-
useEffect(() => {
18+
const fetchPins = useCallback(() => {
1819
setIsLoading(true);
1920
getPinnedMessages(channelId)
2021
.then((data) => setPins(data))
2122
.catch((err) => console.error('Failed to fetch pins:', err))
2223
.finally(() => setIsLoading(false));
2324
}, [channelId]);
2425

26+
useEffect(() => {
27+
fetchPins();
28+
}, [fetchPins]);
29+
30+
// Listen for message:updated events — refresh pins when a message in this
31+
// channel is pinned or unpinned so the panel stays current without a reload.
32+
useEffect(() => {
33+
const socket = getSocket();
34+
if (!socket) return;
35+
36+
const handleMessageUpdated = (msg: ApiMessage) => {
37+
if (msg.channelId !== channelId) return;
38+
// Re-fetch the canonical pin list from the server
39+
getPinnedMessages(channelId)
40+
.then((data) => setPins(data))
41+
.catch((err) => console.error('Failed to refresh pins:', err));
42+
};
43+
44+
socket.on('message:updated', handleMessageUpdated);
45+
return () => {
46+
socket.off('message:updated', handleMessageUpdated);
47+
};
48+
}, [channelId]);
49+
2550
return (
2651
<div data-testid="pins-panel" className="flex w-[300px] flex-col border-l border-[#E0E0E0] bg-white">
2752
<div className="flex h-[49px] items-center justify-between border-b border-[#E0E0E0] px-4">

frontend/tests/pins.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,92 @@ test.describe('Pinned Messages', () => {
3535
// The pinned message should appear in the pins panel
3636
await expect(page.getByTestId('pins-panel').getByText(uniqueText)).toBeVisible({ timeout: 5000 });
3737
});
38+
39+
test('pinning a message updates in real-time for another user in the same channel', async ({ browser }) => {
40+
// User 1 sends and pins a message; user 2 (in another browser context) should
41+
// see the pin indicator appear without refreshing.
42+
const email1 = uniqueEmail();
43+
const email2 = uniqueEmail();
44+
45+
// Set up user 1
46+
const context1 = await browser.newContext();
47+
const page1 = await context1.newPage();
48+
await register(page1, 'PinnerUser', email1, 'password123');
49+
await clickChannel(page1, 'general');
50+
await waitForChannelReady(page1);
51+
52+
// Set up user 2 (observer)
53+
const context2 = await browser.newContext();
54+
const page2 = await context2.newPage();
55+
await register(page2, 'ObserverUser', email2, 'password123');
56+
await clickChannel(page2, 'general');
57+
await waitForChannelReady(page2);
58+
59+
// User 1 sends a message
60+
const uniqueText = `RealTimePin ${Date.now()}`;
61+
await sendMessage(page1, uniqueText);
62+
63+
// Both users should see the message
64+
await waitForMessage(page1, uniqueText);
65+
await waitForMessage(page2, uniqueText);
66+
67+
// User 1 pins the message via the dropdown
68+
const messageRow1 = page1.locator('.group.relative.flex.px-5').filter({ hasText: uniqueText }).first();
69+
await messageRow1.hover();
70+
const hoverToolbar1 = page1.locator('.absolute.-top-4.right-5');
71+
await hoverToolbar1.locator('button').last().click();
72+
await page1.getByText('Pin message').click();
73+
74+
// User 1's view: pin indicator appears (optimistic update)
75+
await expect(messageRow1.locator('[data-testid="pin-indicator"]')).toBeVisible({ timeout: 5000 });
76+
77+
// User 2's view: pin indicator should appear in real-time via WebSocket (no page refresh)
78+
const messageRow2 = page2.locator('.group.relative.flex.px-5').filter({ hasText: uniqueText }).first();
79+
await expect(messageRow2.locator('[data-testid="pin-indicator"]')).toBeVisible({ timeout: 10000 });
80+
81+
await context1.close();
82+
await context2.close();
83+
});
84+
85+
test('pinned messages panel updates in real-time when a message is pinned', async ({ browser }) => {
86+
const email1 = uniqueEmail();
87+
const email2 = uniqueEmail();
88+
89+
// Set up user 1 (pinner)
90+
const context1 = await browser.newContext();
91+
const page1 = await context1.newPage();
92+
await register(page1, 'PinnerB', email1, 'password123');
93+
await clickChannel(page1, 'general');
94+
await waitForChannelReady(page1);
95+
96+
// Set up user 2 (observer with pins panel open)
97+
const context2 = await browser.newContext();
98+
const page2 = await context2.newPage();
99+
await register(page2, 'ObserverB', email2, 'password123');
100+
await clickChannel(page2, 'general');
101+
await waitForChannelReady(page2);
102+
103+
// User 2 opens the pins panel
104+
await page2.getByRole('button', { name: 'Pins' }).click();
105+
await expect(page2.getByTestId('pins-panel')).toBeVisible({ timeout: 5000 });
106+
107+
// User 1 sends a message
108+
const uniqueText = `PinsPanel ${Date.now()}`;
109+
await sendMessage(page1, uniqueText);
110+
await waitForMessage(page1, uniqueText);
111+
await waitForMessage(page2, uniqueText);
112+
113+
// User 1 pins the message
114+
const messageRow1 = page1.locator('.group.relative.flex.px-5').filter({ hasText: uniqueText }).first();
115+
await messageRow1.hover();
116+
const hoverToolbar1 = page1.locator('.absolute.-top-4.right-5');
117+
await hoverToolbar1.locator('button').last().click();
118+
await page1.getByText('Pin message').click();
119+
120+
// User 2's pins panel should update in real-time to show the newly pinned message
121+
await expect(page2.getByTestId('pins-panel').getByText(uniqueText)).toBeVisible({ timeout: 10000 });
122+
123+
await context1.close();
124+
await context2.close();
125+
});
38126
});

0 commit comments

Comments
 (0)