Skip to content

Commit 62370ca

Browse files
aster-voidclaude
andcommitted
treewide: add real-time messaging via WebSocket
Server: - Broadcast message:created/updated/deleted events from message routes Client: - Subscribe to WebSocket channel in MessageListController - Handle real-time message events to update UI instantly UI: - Improve chat design based on "Clarity" design language - Add smooth transitions and hover states - Increase whitespace for better readability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent c7cca62 commit 62370ca

File tree

5 files changed

+124
-36
lines changed

5 files changed

+124
-36
lines changed

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,25 +82,29 @@
8282
<div class="flex h-full flex-col">
8383
<!-- Channel header -->
8484
<header
85-
class="border-subtle flex items-center justify-between border-b px-4 py-3"
85+
class="border-subtle flex items-center justify-between border-b px-6 py-4"
8686
>
87-
<div class="flex items-center gap-2">
88-
<MdiPound class="text-muted h-5 w-5" />
89-
<h1 class="font-semibold">{selectedChannel.data?.name ?? "..."}</h1>
87+
<div class="flex items-center gap-3">
88+
<MdiPound class="h-5 w-5 opacity-50" />
89+
<h1 class="text-base font-semibold">
90+
{selectedChannel.data?.name ?? "..."}
91+
</h1>
9092
{#if selectedChannel.data?.description}
91-
<span class="text-muted hidden text-sm sm:inline">
93+
<span class="hidden text-sm opacity-40 sm:inline">
9294
— {selectedChannel.data.description}
9395
</span>
9496
{/if}
9597
</div>
9698

9799
<div class="flex items-center gap-2">
98100
{#if showSearch}
99-
<div class="relative">
101+
<div
102+
class="animate-in fade-in slide-in-from-right-2 relative duration-200"
103+
>
100104
<input
101105
type="text"
102106
placeholder="メッセージを検索..."
103-
class="input input-sm input-bordered bg-base-200 w-48 pr-8 text-sm"
107+
class="input input-sm input-bordered bg-base-200 w-56 pr-8 text-sm transition-all duration-150 focus:w-64"
104108
bind:value={searchQuery}
105109
onkeydown={(e) => e.key === "Enter" && handleSearch()}
106110
/>
@@ -112,11 +116,13 @@
112116
</div>
113117
{/if}
114118
<button
115-
class="btn btn-ghost btn-sm btn-square"
119+
class="btn btn-ghost btn-sm btn-square transition-all duration-150"
116120
title="検索"
117121
onclick={() => (showSearch = !showSearch)}
118122
>
119-
<MdiMagnify class="text-muted h-5 w-5" />
123+
<MdiMagnify
124+
class="h-5 w-5 opacity-50 transition-opacity duration-150 hover:opacity-80"
125+
/>
120126
</button>
121127
</div>
122128
</header>

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

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,27 +46,35 @@
4646

4747
<article
4848
id="message-{message.id}"
49-
class="group hover:bg-base-200/50 relative px-4 py-1.5 transition-colors"
49+
class="group hover:bg-base-200/30 relative px-6 py-3 transition-all duration-200"
5050
oncontextmenu={onContextMenu}
5151
>
5252
<!-- Reply reference -->
5353
{#if parentMessage}
54-
<div class="text-muted mb-1 flex items-center gap-1.5 text-xs">
54+
<div
55+
class="mb-2 flex items-center gap-1.5 text-xs opacity-60 transition-opacity duration-150 group-hover:opacity-80"
56+
>
5557
<MdiReply class="h-3 w-3" />
5658
<span class="text-primary/80 font-medium">{parentMessage.author}</span>
57-
<span class="truncate opacity-70">{parentMessage.content}</span>
59+
<span class="truncate">{parentMessage.content}</span>
5860
</div>
5961
{/if}
6062

6163
<!-- Message header -->
62-
<div class="flex items-baseline gap-2">
63-
<span class="text-primary font-medium">{message.author}</span>
64-
<span class="timestamp">{formatTime(message.createdAt)}</span>
64+
<div class="flex items-baseline gap-2.5">
65+
<span class="text-primary text-sm font-semibold">{message.author}</span>
66+
<span
67+
class="text-xs opacity-40 transition-opacity duration-150 group-hover:opacity-60"
68+
>{formatTime(message.createdAt)}</span
69+
>
6570
{#if isEdited}
66-
<span class="text-muted text-xs">(編集済み)</span>
71+
<span
72+
class="text-xs opacity-30 transition-opacity duration-150 group-hover:opacity-50"
73+
>(編集済み)</span
74+
>
6775
{/if}
6876
{#if message.pinnedAt}
69-
<span class="text-warning flex items-center gap-1 text-xs">
77+
<span class="text-warning flex items-center gap-1 text-xs opacity-70">
7078
<MdiPin class="h-3 w-3" />
7179
ピン留め
7280
</span>
@@ -75,9 +83,9 @@
7583

7684
<!-- Message content -->
7785
{#if isEditing}
78-
<div class="mt-2 space-y-2">
86+
<div class="mt-3 space-y-2">
7987
<textarea
80-
class="textarea textarea-bordered bg-base-300 w-full text-sm"
88+
class="textarea textarea-bordered bg-base-300 w-full text-sm transition-all duration-150"
8189
value={editedContent}
8290
oninput={(e) =>
8391
onEditChange?.(
@@ -96,16 +104,14 @@
96104
</div>
97105
</div>
98106
{:else}
99-
<div
100-
class="text-base-content/90 text-sm leading-relaxed whitespace-pre-wrap"
101-
>
107+
<div class="mt-1.5 text-sm leading-relaxed whitespace-pre-wrap opacity-90">
102108
{message.content}
103109
</div>
104110
{/if}
105111

106112
<!-- Attachments -->
107113
{#if message.attachments && message.attachments.length > 0}
108-
<div class="mt-2 space-y-1">
114+
<div class="mt-3 space-y-2">
109115
{#each message.attachments as fileId}
110116
<FileAttachment {fileId} compact={false} />
111117
{/each}
@@ -114,7 +120,7 @@
114120

115121
<!-- Vote -->
116122
{#if message.vote}
117-
<div class="mt-2">
123+
<div class="mt-3">
118124
<VoteViewer voteId={message.vote} />
119125
</div>
120126
{/if}
@@ -124,14 +130,14 @@
124130

125131
<!-- Actions (hover) -->
126132
<div
127-
class="border-subtle bg-base-100 absolute top-1 right-2 flex gap-0.5 rounded border opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
133+
class="border-subtle bg-base-100 absolute top-2 right-4 flex gap-0.5 rounded border opacity-0 shadow-sm transition-all duration-200 group-hover:opacity-100"
128134
>
129135
<button
130-
class="btn btn-ghost btn-xs btn-square"
136+
class="btn btn-ghost btn-xs btn-square transition-colors duration-150"
131137
onclick={onDotsClick}
132138
title="メニュー"
133139
>
134-
<MdiDotsVertical class="h-4 w-4" />
140+
<MdiDotsVertical class="h-4 w-4 opacity-60" />
135141
</button>
136142
</div>
137143
</article>

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
}
3333
3434
$effect(() => {
35-
if (controller.messages.data) {
35+
if (controller.messagesData.length > 0) {
3636
setTimeout(scrollToBottom, 0);
3737
}
3838
});
@@ -44,9 +44,9 @@
4444

4545
<Modal manager={modalManager} />
4646

47-
<div bind:this={messagesContainer} class="flex-1 space-y-2 overflow-y-auto p-4">
48-
{#if controller.messages.data}
49-
{#each controller.messages.data as message (message.id)}
47+
<div bind:this={messagesContainer} class="flex-1 overflow-y-auto scroll-smooth">
48+
{#if controller.messagesData.length > 0}
49+
{#each controller.messagesData as message (message.id)}
5050
{#snippet reactionListSnippet()}
5151
<ReactionList {organizationId} messageId={message.id} />
5252
{/snippet}
@@ -100,13 +100,13 @@
100100
onEditCancel={() => controller.cancelEditing()}
101101
/>
102102
{:else}
103-
<div class="text-center text-base-content/60 py-8">
104-
まだメッセージがありません
103+
<div class="flex h-full items-center justify-center py-16">
104+
<p class="text-sm opacity-40">まだメッセージがありません</p>
105105
</div>
106106
{/each}
107107
{:else}
108-
<div class="text-base-content/60 py-8 text-center">
109-
メッセージを読み込み中...
108+
<div class="flex h-full items-center justify-center py-16">
109+
<p class="text-sm opacity-40">メッセージを読み込み中...</p>
110110
</div>
111111
{/if}
112112
</div>

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
useQuery,
88
} from "@/lib/api.svelte";
99
import { useAuth } from "@/lib/auth.svelte";
10+
import { getWebSocket } from "@/lib/websocket";
11+
import type { WsEvent } from "@/lib/websocket/types";
1012

1113
/**
1214
* Controller for managing message list state and operations.
@@ -22,6 +24,7 @@ export class MessageListController {
2224
refetch: () => Promise<void>;
2325
};
2426
messagesById: Map<string, Message>;
27+
messagesData = $state<Message[]>([]);
2528

2629
// UI State
2730
clientX = $state(0);
@@ -61,10 +64,17 @@ export class MessageListController {
6164

6265
this.messagesById = $derived(
6366
new Map(
64-
this.messages.data?.map((message: Message) => [message.id, message]),
67+
this.messagesData.map((message: Message) => [message.id, message]),
6568
),
6669
);
6770

71+
// Sync messagesData with query data
72+
$effect(() => {
73+
if (this.messages.data) {
74+
this.messagesData = this.messages.data;
75+
}
76+
});
77+
6878
this.addReaction = useMutation(
6979
async (args: { messageId: string; emoji: string }) => {
7080
const response = await getMessage(api, args.messageId).reactions.post({
@@ -102,6 +112,52 @@ export class MessageListController {
102112
return unwrapResponse(response);
103113
});
104114

115+
// WebSocket integration
116+
try {
117+
const ws = getWebSocket();
118+
ws.subscribe(this.channelId);
119+
120+
// Handle new messages
121+
ws.on("message:created", (event: WsEvent) => {
122+
if (
123+
event.type === "message:created" &&
124+
event.channelId === this.channelId
125+
) {
126+
const newMessage = event.message as Message;
127+
if (!this.messagesById.get(newMessage.id)) {
128+
this.messagesData = [...this.messagesData, newMessage];
129+
}
130+
}
131+
});
132+
133+
// Handle message updates
134+
ws.on("message:updated", (event: WsEvent) => {
135+
if (
136+
event.type === "message:updated" &&
137+
event.channelId === this.channelId
138+
) {
139+
const updated = event.message as Message;
140+
this.messagesData = this.messagesData.map((m) =>
141+
m.id === updated.id ? updated : m,
142+
);
143+
}
144+
});
145+
146+
// Handle message deletions
147+
ws.on("message:deleted", (event: WsEvent) => {
148+
if (
149+
event.type === "message:deleted" &&
150+
event.channelId === this.channelId
151+
) {
152+
this.messagesData = this.messagesData.filter(
153+
(m) => m.id !== event.messageId,
154+
);
155+
}
156+
});
157+
} catch (error) {
158+
console.warn("WebSocket not initialized:", error);
159+
}
160+
105161
// Close dropdowns on click outside
106162
document.addEventListener("click", () => {
107163
this.visibleDropdown = null;

apps/server/src/domains/messages/routes.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
users,
99
} from "../../db/schema.ts";
1010
import { authMiddleware } from "../../middleware/auth.ts";
11+
import { wsManager } from "../../ws/manager.ts";
1112
import { requireOrganizationMembership } from "../organizations/permissions.ts";
1213
import { messagePinRoutes } from "./pins.ts";
1314
import { messageReactionRoutes } from "./reactions.ts";
@@ -152,6 +153,12 @@ export const messageRoutes = new Elysia({ prefix: "/messages" })
152153
);
153154
}
154155

156+
wsManager.broadcast(body.channelId, {
157+
type: "message:created",
158+
channelId: body.channelId,
159+
message,
160+
});
161+
155162
return message;
156163
},
157164
{
@@ -215,6 +222,13 @@ export const messageRoutes = new Elysia({ prefix: "/messages" })
215222
.where(eq(messages.id, params.id))
216223
.returning();
217224

225+
wsManager.broadcast(message.channelId, {
226+
type: "message:updated",
227+
channelId: message.channelId,
228+
messageId: params.id,
229+
message: updatedMessage,
230+
});
231+
218232
return updatedMessage;
219233
},
220234
{
@@ -265,6 +279,12 @@ export const messageRoutes = new Elysia({ prefix: "/messages" })
265279

266280
await db.delete(messages).where(eq(messages.id, params.id));
267281

282+
wsManager.broadcast(message.channelId, {
283+
type: "message:deleted",
284+
channelId: message.channelId,
285+
messageId: params.id,
286+
});
287+
268288
return { success: true };
269289
},
270290
{

0 commit comments

Comments
 (0)