Skip to content

Commit 4942dc1

Browse files
aster-voidclaude
andcommitted
treewide: extend real-time updates with proper cleanup
- Add useWebSocket hook for reusable subscriptions with auto-cleanup - Add WebSocket broadcast for reactions (add/remove) - Add real-time updates for pinned messages - Replace polling with event-driven unread count updates in ChannelList - Add destroy() method to MessageListController for proper cleanup - Fix event listener accumulation by tracking handlers for removal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 62370ca commit 4942dc1

File tree

10 files changed

+277
-21
lines changed

10 files changed

+277
-21
lines changed

apps/desktop/src/components/channels/ChannelList.svelte

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { onMount } from "svelte";
44
import { getApiClient, unwrapResponse, useQuery } from "@/lib/api.svelte";
55
import { UnreadManager } from "@/lib/unread.svelte";
6+
import { useWebSocket } from "@/lib/websocket";
67
import type { Selection } from "$components/chat/types";
78
import DMList from "$components/dms/DMList.svelte";
89
import UserSearch from "$components/dms/UserSearch.svelte";
@@ -28,13 +29,14 @@
2829
const unreadManager = $derived(new UnreadManager(api, organizationId));
2930
let showUserSearch = $state(false);
3031
32+
// WebSocket: refresh unread counts on new messages (auto-cleanup)
33+
const ws = useWebSocket();
34+
ws.on("message:created", () => {
35+
unreadManager.fetchUnreadCounts();
36+
});
37+
3138
onMount(() => {
3239
unreadManager.fetchUnreadCounts();
33-
const interval = setInterval(
34-
() => unreadManager.fetchUnreadCounts(),
35-
30000,
36-
);
37-
return () => clearInterval(interval);
3840
});
3941
</script>
4042

apps/desktop/src/components/chat/MessageList.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import type { Message } from "@apps/api-client";
3-
import { onMount } from "svelte";
3+
import { onDestroy, onMount } from "svelte";
44
import Modal, { ModalManager } from "$lib/modal/modal.svelte";
55
import EmojiPalette from "./EmojiPalette.svelte";
66
import MessageDropdown from "./MessageDropdown.svelte";
@@ -40,6 +40,10 @@
4040
onMount(() => {
4141
scrollToBottom();
4242
});
43+
44+
onDestroy(() => {
45+
controller.destroy();
46+
});
4347
</script>
4448

4549
<Modal manager={modalManager} />

apps/desktop/src/components/chat/PinnedMessages.svelte

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import type { Message } from "@apps/api-client";
33
import MdiPin from "@/icons/mdi-pin.svelte";
44
import { getApiClient, unwrapResponse, useQuery } from "@/lib/api.svelte";
5+
import type { WsEvent } from "@/lib/websocket";
6+
import { getWebSocket } from "@/lib/websocket";
57
68
interface Props {
79
channelId: string;
@@ -10,6 +12,7 @@
1012
let { channelId }: Props = $props();
1113
1214
const api = getApiClient();
15+
const ws = getWebSocket();
1316
1417
const pinnedMessages = useQuery<Message[]>(async () => {
1518
const response = await api.messages.pins.get({
@@ -18,6 +21,45 @@
1821
return unwrapResponse(response);
1922
});
2023
24+
let messages = $state<Message[]>([]);
25+
26+
$effect(() => {
27+
if (pinnedMessages.data) {
28+
messages = [...pinnedMessages.data];
29+
}
30+
});
31+
32+
$effect(() => {
33+
const handleMessageUpdate = (event: WsEvent) => {
34+
if (event.type !== "message:updated" || event.channelId !== channelId) {
35+
return;
36+
}
37+
38+
const updatedMessage = event.message as Message;
39+
const existingIndex = messages.findIndex(
40+
(m) => m.id === updatedMessage.id,
41+
);
42+
43+
if (updatedMessage.pinnedAt) {
44+
if (existingIndex !== -1) {
45+
messages[existingIndex] = updatedMessage;
46+
} else {
47+
messages = [...messages, updatedMessage];
48+
}
49+
} else {
50+
if (existingIndex !== -1) {
51+
messages = messages.filter((m) => m.id !== updatedMessage.id);
52+
}
53+
}
54+
};
55+
56+
ws.on("message:updated", handleMessageUpdate);
57+
58+
return () => {
59+
ws.off("message:updated", handleMessageUpdate);
60+
};
61+
});
62+
2163
function formatTime(timestamp: Date | number | string) {
2264
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
2365
return date.toLocaleTimeString("ja-JP", {
@@ -32,14 +74,14 @@
3274
}
3375
</script>
3476

35-
{#if pinnedMessages.data && pinnedMessages.data.length > 0}
77+
{#if messages.length > 0}
3678
<div class="bg-base-200 border-b p-2">
3779
<div class="text-warning mb-2 flex items-center gap-2">
3880
<MdiPin class="h-4 w-4" />
3981
<span class="text-sm font-semibold">ピン留めされたメッセージ</span>
4082
</div>
4183
<div class="space-y-1">
42-
{#each pinnedMessages.data as message}
84+
{#each messages as message}
4385
<button
4486
class="hover:bg-base-300 w-full rounded p-2 text-left transition-colors"
4587
onclick={() => scrollToMessage(message.id)}

apps/desktop/src/components/chat/ReactionButtons.svelte

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
useMutation,
99
useQuery,
1010
} from "@/lib/api.svelte";
11+
import { useWebSocket } from "@/lib/websocket";
1112
1213
interface Props {
1314
messageId: string;
@@ -16,6 +17,7 @@
1617
let { messageId }: Props = $props();
1718
1819
const api = getApiClient();
20+
const ws = useWebSocket();
1921
2022
const reactions = useQuery<Reaction[]>(async () => {
2123
const response = await getMessage(api, messageId).reactions.get();
@@ -27,6 +29,33 @@
2729
return unwrapResponse(response);
2830
});
2931
32+
// Local state for real-time updates
33+
let reactionsData = $state<Reaction[]>([]);
34+
35+
$effect(() => {
36+
if (reactions.data) {
37+
reactionsData = reactions.data;
38+
}
39+
});
40+
41+
// WebSocket subscriptions (auto-cleanup via useWebSocket)
42+
ws.on("reaction:added", (event) => {
43+
if (event.messageId === messageId) {
44+
const newReaction = event.reaction as Reaction;
45+
if (!reactionsData.some((r) => r.id === newReaction.id)) {
46+
reactionsData = [...reactionsData, newReaction];
47+
}
48+
}
49+
});
50+
51+
ws.on("reaction:removed", (event) => {
52+
if (event.messageId === messageId) {
53+
reactionsData = reactionsData.filter(
54+
(r) => !(r.emoji === event.emoji && r.userId === event.userId),
55+
);
56+
}
57+
});
58+
3059
const addReaction = useMutation(
3160
async ({ messageId: mid, emoji }: { messageId: string; emoji: string }) => {
3261
const response = await getMessage(api, mid).reactions.post({
@@ -49,10 +78,7 @@
4978
5079
const reactionsByEmoji = $derived.by(() => {
5180
const counts = new Map<string, { count: number; me: boolean }>();
52-
if (!reactions.data) {
53-
return counts;
54-
}
55-
for (const r of reactions.data) {
81+
for (const r of reactionsData) {
5682
counts.set(r.emoji, {
5783
count: (counts.get(r.emoji)?.count ?? 0) + 1,
5884
me: counts.get(r.emoji)?.me || r.userId === me.data?.id,

apps/desktop/src/components/chat/messageList.controller.svelte.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { useAuth } from "@/lib/auth.svelte";
1010
import { getWebSocket } from "@/lib/websocket";
1111
import type { WsEvent } from "@/lib/websocket/types";
1212

13+
type WsHandler = (event: WsEvent) => void;
14+
1315
/**
1416
* Controller for managing message list state and operations.
1517
* Handles fetching messages, managing UI state for dropdowns/palettes,
@@ -48,6 +50,10 @@ export class MessageListController {
4850
pinMessage: ReturnType<typeof useMutation<{ messageId: string }, Message>>;
4951
unpinMessage: ReturnType<typeof useMutation<{ messageId: string }, Message>>;
5052

53+
// WebSocket cleanup
54+
private wsHandlers: { type: string; handler: WsHandler }[] = [];
55+
private ws: ReturnType<typeof getWebSocket> | null = null;
56+
5157
constructor(props: () => { organizationId: string; channelId: string }) {
5258
const api = getApiClient();
5359
this.organizationId = props().organizationId;
@@ -114,11 +120,11 @@ export class MessageListController {
114120

115121
// WebSocket integration
116122
try {
117-
const ws = getWebSocket();
118-
ws.subscribe(this.channelId);
123+
this.ws = getWebSocket();
124+
this.ws.subscribe(this.channelId);
119125

120126
// Handle new messages
121-
ws.on("message:created", (event: WsEvent) => {
127+
const handleCreated: WsHandler = (event) => {
122128
if (
123129
event.type === "message:created" &&
124130
event.channelId === this.channelId
@@ -128,10 +134,10 @@ export class MessageListController {
128134
this.messagesData = [...this.messagesData, newMessage];
129135
}
130136
}
131-
});
137+
};
132138

133139
// Handle message updates
134-
ws.on("message:updated", (event: WsEvent) => {
140+
const handleUpdated: WsHandler = (event) => {
135141
if (
136142
event.type === "message:updated" &&
137143
event.channelId === this.channelId
@@ -141,10 +147,10 @@ export class MessageListController {
141147
m.id === updated.id ? updated : m,
142148
);
143149
}
144-
});
150+
};
145151

146152
// Handle message deletions
147-
ws.on("message:deleted", (event: WsEvent) => {
153+
const handleDeleted: WsHandler = (event) => {
148154
if (
149155
event.type === "message:deleted" &&
150156
event.channelId === this.channelId
@@ -153,7 +159,17 @@ export class MessageListController {
153159
(m) => m.id !== event.messageId,
154160
);
155161
}
156-
});
162+
};
163+
164+
this.ws.on("message:created", handleCreated);
165+
this.ws.on("message:updated", handleUpdated);
166+
this.ws.on("message:deleted", handleDeleted);
167+
168+
this.wsHandlers = [
169+
{ type: "message:created", handler: handleCreated },
170+
{ type: "message:updated", handler: handleUpdated },
171+
{ type: "message:deleted", handler: handleDeleted },
172+
];
157173
} catch (error) {
158174
console.warn("WebSocket not initialized:", error);
159175
}
@@ -242,4 +258,16 @@ export class MessageListController {
242258
await this.messages.refetch();
243259
this.visibleDropdown = null;
244260
}
261+
262+
/**
263+
* Cleanup WebSocket subscriptions. Call this when the component unmounts.
264+
*/
265+
destroy() {
266+
if (this.ws) {
267+
for (const { type, handler } of this.wsHandlers) {
268+
this.ws.off(type, handler);
269+
}
270+
this.ws.unsubscribe(this.channelId);
271+
}
272+
}
245273
}

apps/desktop/src/lib/websocket/client.svelte.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ export class WebSocketClient {
8585
this.eventManager.on(eventType, callback);
8686
}
8787

88+
/**
89+
* Removes an event listener.
90+
*/
91+
off(eventType: string, callback: (event: WsEvent) => void) {
92+
this.eventManager.off(eventType, callback);
93+
}
94+
8895
private send(data: unknown) {
8996
if (this.ws?.readyState === WebSocket.OPEN) {
9097
this.ws.send(JSON.stringify(data));

apps/desktop/src/lib/websocket/events.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export class EventManager {
1616
this.listeners.get(eventType)?.add(callback);
1717
}
1818

19+
/**
20+
* Removes an event listener.
21+
*/
22+
off(eventType: string, callback: (event: WsEvent) => void) {
23+
this.listeners.get(eventType)?.delete(callback);
24+
}
25+
1926
/**
2027
* Notifies all listeners for a given event.
2128
*/

apps/desktop/src/lib/websocket/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export function getWebSocket(): WebSocketClient {
2525
}
2626

2727
export type { ConnectionStatus, WsEvent } from "./types.ts";
28+
export { useWebSocket } from "./useWebSocket.svelte.ts";

0 commit comments

Comments
 (0)