From bb0aa5e28ea1ba6300bfd3a021520f9ba1af8d88 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 13:27:46 +0200 Subject: [PATCH] feat(chat): rewrite chat page with chatscope UI and custom hooks Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/package.json | 2 + .../src/components/ChatChannelPreview.tsx | 90 ++++++++++ apps/main/src/components/ChatHeader.tsx | 54 ++++++ apps/main/src/lib/routes.tsx | 7 +- apps/main/src/pages/chat.tsx | 157 +++++++++++------- pnpm-lock.yaml | 120 ++++++++++++- 6 files changed, 361 insertions(+), 69 deletions(-) create mode 100644 apps/main/src/components/ChatChannelPreview.tsx create mode 100644 apps/main/src/components/ChatHeader.tsx diff --git a/apps/main/package.json b/apps/main/package.json index 3775a5c..3ffcf32 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -75,6 +75,8 @@ "@blocknote/core": "^0.41.1", "@blocknote/mantine": "^0.41.1", "@blocknote/react": "^0.41.1", + "@chatscope/chat-ui-kit-react": "^2.1.1", + "@chatscope/chat-ui-kit-styles": "^1.4.0", "@datadog/browser-rum": "^6.13.0", "@datadog/browser-rum-react": "^6.13.0", "@hookform/resolvers": "^5.2.2", diff --git a/apps/main/src/components/ChatChannelPreview.tsx b/apps/main/src/components/ChatChannelPreview.tsx new file mode 100644 index 0000000..f647967 --- /dev/null +++ b/apps/main/src/components/ChatChannelPreview.tsx @@ -0,0 +1,90 @@ +import { ChannelBadge } from "@ui/components/ChannelBadge"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { Badge } from "@xtablo/ui/components/badge"; +import { twMerge } from "tailwind-merge"; + +interface ChatChannelPreviewProps { + tablo: UserTablo; + isActive: boolean; + onClick: () => void; + unreadCount: number; + lastMessage?: string; + lastMessageTime?: string; + isOnline: boolean; +} + +function formatTimestamp(timestamp: string | Date): string { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + return date.toLocaleDateString(); +} + +export function ChatChannelPreview({ + tablo, + isActive, + onClick, + unreadCount, + lastMessage, + lastMessageTime, + isOnline, +}: ChatChannelPreviewProps) { + return ( +
+ + +
+
+

+ {tablo.name} +

+ {lastMessageTime && ( + + {formatTimestamp(lastMessageTime)} + + )} +
+ +
+

+ {lastMessage ?? "No messages yet"} +

+ + {unreadCount > 0 && ( +
+ + {unreadCount > 99 ? "99+" : unreadCount} + +
+ )} +
+
+ + {isActive && ( +
+ )} +
+ ); +} diff --git a/apps/main/src/components/ChatHeader.tsx b/apps/main/src/components/ChatHeader.tsx new file mode 100644 index 0000000..da3664e --- /dev/null +++ b/apps/main/src/components/ChatHeader.tsx @@ -0,0 +1,54 @@ +import { ChannelBadge } from "@ui/components/ChannelBadge"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; + +interface ChatHeaderProps { + tablo: UserTablo | null; + onToggleChannelList?: () => void; + isChannelListExpanded?: boolean; + onlineUsers: string[]; +} + +export function ChatHeader({ + tablo, + onToggleChannelList, + isChannelListExpanded = false, + onlineUsers, +}: ChatHeaderProps) { + const memberCount = onlineUsers.length; + + return ( +
+ {onToggleChannelList && ( + + )} + {tablo && ( + <> + 0} /> +
+

{tablo.name}

+ {memberCount > 0 && ( +

+ {memberCount} online +

+ )} +
+ + )} +
+ ); +} diff --git a/apps/main/src/lib/routes.tsx b/apps/main/src/lib/routes.tsx index 412bbc8..a6a2175 100644 --- a/apps/main/src/lib/routes.tsx +++ b/apps/main/src/lib/routes.tsx @@ -28,7 +28,6 @@ import { TabloDetailsPage } from "../pages/tablo-details"; import { TablosPage } from "../pages/tablos"; import { TasksPage } from "../pages/tasks"; import { UpdatePasswordPage } from "../pages/update-password"; -import ChatProvider from "../providers/ChatProvider"; export const routes: RouteObject[] = [ // Protected routes @@ -75,11 +74,7 @@ export const routes: RouteObject[] = [ }, { path: "chat", - element: ( - - - - ), + element: , children: [{ index: true }, { path: ":channelId" }], }, // Notes feature temporarily hidden diff --git a/apps/main/src/pages/chat.tsx b/apps/main/src/pages/chat.tsx index 7b299cf..726b9ba 100644 --- a/apps/main/src/pages/chat.tsx +++ b/apps/main/src/pages/chat.tsx @@ -1,39 +1,58 @@ -import { ChannelPreview } from "@ui/components/ChannelPreview"; -import { CustomChannelHeader } from "@ui/components/CustomChannelHeader"; -import { useEffect, useState } from "react"; +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; import { - Channel, - ChannelList, - MessageInput, + ChatContainer, MessageList, - useChatContext, - Window, -} from "stream-chat-react"; -import { useChannelFromUrl } from "../hooks/channel"; + Message, + MessageInput, + TypingIndicator, +} from "@chatscope/chat-ui-kit-react"; +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ChatChannelPreview } from "../components/ChatChannelPreview"; +import { ChatHeader } from "../components/ChatHeader"; +import { useChat } from "../hooks/useChat"; +import { useChatUnread } from "../hooks/useChatUnread"; import { useTablosList } from "../hooks/tablos"; import { useUser } from "../providers/UserStoreProvider"; export function ChatPage() { const user = useUser(); - const filters = { members: { $in: [user.id] }, type: "messaging" }; - - const { client, channel, setActiveChannel } = useChatContext(); - const { channel: channelFromUrl, isChannelInUrl } = useChannelFromUrl(client); - + const { channelId } = useParams(); + const navigate = useNavigate(); const { data: tablos } = useTablosList(); - const [isChannelListExpanded, setIsChannelListExpanded] = useState(false); + const { getUnreadCount } = useChatUnread(); + const [isChannelListExpanded, setIsChannelListExpanded] = useState(!channelId); - const toggleChannelList = () => { - setIsChannelListExpanded(!isChannelListExpanded); + const { + messages, + sendMessage, + sendTyping, + isConnected, + typingUsers, + onlineUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + } = useChat(channelId); + + const activeTablo = tablos?.find((t) => t.id === channelId) ?? null; + + // Mark as read when channel is focused + useEffect(() => { + if (channelId && messages.length > 0) { + markAsRead(); + } + }, [channelId, messages.length, markAsRead]); + + const handleSend = (innerHtml: string, textContent: string) => { + const text = textContent.trim(); + if (!text) return; + sendMessage(text); }; - useEffect(() => { - if (channelFromUrl) { - setActiveChannel(channelFromUrl); - } else { - setIsChannelListExpanded(true); - } - }, [channelFromUrl]); + const handleChannelSelect = (tabloId: string) => { + navigate(`/chat/${tabloId}`); + }; return (
@@ -41,46 +60,72 @@ export function ChatPage() {

Discussions

+ {/* Channel list sidebar */}
- ( - t.id === channel.id) ?? null} - activeChannel={activeChannel} - setActiveChannel={setActiveChannel} - unreadCount={unread} - latestMessagePreview={latestMessagePreview} +
+ {tablos?.map((tablo) => ( + handleChannelSelect(tablo.id)} + unreadCount={getUnreadCount(tablo.id)} + isOnline={onlineUsers.some((uid) => uid !== user.id)} /> - )} - /> + ))} +
-
- - - + {channelId && activeTablo ? ( + <> + setIsChannelListExpanded(!isChannelListExpanded)} isChannelListExpanded={isChannelListExpanded} + onlineUsers={onlineUsers} /> - - - - +
+ + 0 ? ( + + ) : undefined + } + > + {messages.map((msg) => ( + + ))} + + sendTyping()} + attachButton={false} + /> + +
+ + ) : ( +
+ Select a conversation to start chatting +
+ )}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8825c92..dcd9b8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,25 @@ importers: specifier: ^4.0.8 version: 4.0.8(@types/debug@4.1.12)(@types/node@20.19.23)(@vitest/ui@4.0.8)(happy-dom@20.0.7)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6) + apps/chat-worker: + dependencies: + hono: + specifier: ^4.7.7 + version: 4.10.4 + jose: + specifier: ^6.0.0 + version: 6.2.2 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20250410.0 + version: 4.20260411.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + wrangler: + specifier: ^4.14.0 + version: 4.44.0(@cloudflare/workers-types@4.20260411.1) + apps/external: dependencies: '@tanstack/react-query': @@ -174,7 +193,7 @@ importers: version: 2.2.5 '@cloudflare/vite-plugin': specifier: ^1.9.4 - version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0) + version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1)) '@tailwindcss/vite': specifier: ^4.0.14 version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)) @@ -204,7 +223,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)) wrangler: specifier: ^4.24.3 - version: 4.44.0 + version: 4.44.0(@cloudflare/workers-types@4.20260411.1) apps/main: dependencies: @@ -217,6 +236,12 @@ importers: '@blocknote/react': specifier: ^0.41.1 version: 0.41.1(@floating-ui/dom@1.7.4)(@tiptap/extensions@3.8.0(@tiptap/core@3.8.0(@tiptap/pm@3.8.0))(@tiptap/pm@3.8.0))(@types/hast@3.0.4)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@chatscope/chat-ui-kit-react': + specifier: ^2.1.1 + version: 2.1.1(prop-types@15.8.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@chatscope/chat-ui-kit-styles': + specifier: ^1.4.0 + version: 1.4.0 '@datadog/browser-rum': specifier: ^6.13.0 version: 6.22.0 @@ -373,7 +398,7 @@ importers: version: 2.2.5 '@cloudflare/vite-plugin': specifier: ^1.9.4 - version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0) + version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1)) '@esbuild-plugins/node-globals-polyfill': specifier: ^0.2.3 version: 0.2.3(esbuild@0.25.11) @@ -511,7 +536,7 @@ importers: version: 7.4.0 wrangler: specifier: ^4.24.3 - version: 4.44.0 + version: 4.44.0(@cloudflare/workers-types@4.20260411.1) packages/shared: dependencies: @@ -1623,6 +1648,16 @@ packages: '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + '@chatscope/chat-ui-kit-react@2.1.1': + resolution: {integrity: sha512-rCtE9abdmAbBDkAAUYBC1TDTBMZHquqFIZhADptAfHcJ8z8W3XH/z/ZuwBSJXtzi6h1mwCNc3tBmm1A2NLGhNg==} + peerDependencies: + prop-types: ^15.7.2 + react: ^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0 + react-dom: ^16.12.0 || ^17.0.0 || ^18.2.0 || ^19.0.0 + + '@chatscope/chat-ui-kit-styles@1.4.0': + resolution: {integrity: sha512-016mBJD3DESw7Nh+lkKcPd22xG92ghA0VpIXIbjQtmXhC7Ve6wRazTy8z1Ahut+Tbv179+JxrftuMngsj/yV8Q==} + '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} @@ -1672,6 +1707,9 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workers-types@4.20260411.1': + resolution: {integrity: sha512-SsntcTanLz+LmgJC8yB7sGCtpC8HxboVDmwrOH1hp1SHZwuKnhfmhUfeiwy7O/cE3iVN1cxe1E17stxP5DJXDw==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2180,6 +2218,29 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@fortawesome/fontawesome-common-types@6.7.2': + resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} + engines: {node: '>=6'} + + '@fortawesome/fontawesome-free@6.7.2': + resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==} + engines: {node: '>=6'} + + '@fortawesome/fontawesome-svg-core@6.7.2': + resolution: {integrity: sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==} + engines: {node: '>=6'} + + '@fortawesome/free-solid-svg-icons@6.7.2': + resolution: {integrity: sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==} + engines: {node: '>=6'} + + '@fortawesome/react-fontawesome@0.2.6': + resolution: {integrity: sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==} + deprecated: v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater. + peerDependencies: + '@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7 + react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@google-cloud/common@5.0.2': resolution: {integrity: sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==} engines: {node: '>=14.0.0'} @@ -5480,6 +5541,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -6936,6 +7000,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -11231,6 +11298,20 @@ snapshots: '@braintree/sanitize-url@6.0.4': {} + '@chatscope/chat-ui-kit-react@2.1.1(prop-types@15.8.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@chatscope/chat-ui-kit-styles': 1.4.0 + '@fortawesome/fontawesome-free': 6.7.2 + '@fortawesome/fontawesome-svg-core': 6.7.2 + '@fortawesome/free-solid-svg-icons': 6.7.2 + '@fortawesome/react-fontawesome': 0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@19.0.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + + '@chatscope/chat-ui-kit-styles@1.4.0': {} + '@cloudflare/kv-asset-handler@0.4.0': dependencies: mime: 3.0.0 @@ -11241,7 +11322,7 @@ snapshots: optionalDependencies: workerd: 1.20251011.0 - '@cloudflare/vite-plugin@1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0)': + '@cloudflare/vite-plugin@1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1))': dependencies: '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0) '@remix-run/node-fetch-server': 0.8.1 @@ -11251,7 +11332,7 @@ snapshots: tinyglobby: 0.2.15 unenv: 2.0.0-rc.21 vite: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6) - wrangler: 4.44.0 + wrangler: 4.44.0(@cloudflare/workers-types@4.20260411.1) ws: 8.18.0 transitivePeerDependencies: - bufferutil @@ -11273,6 +11354,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20251011.0': optional: true + '@cloudflare/workers-types@4.20260411.1': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -11651,6 +11734,24 @@ snapshots: dependencies: tslib: 2.8.1 + '@fortawesome/fontawesome-common-types@6.7.2': {} + + '@fortawesome/fontawesome-free@6.7.2': {} + + '@fortawesome/fontawesome-svg-core@6.7.2': + dependencies: + '@fortawesome/fontawesome-common-types': 6.7.2 + + '@fortawesome/free-solid-svg-icons@6.7.2': + dependencies: + '@fortawesome/fontawesome-common-types': 6.7.2 + + '@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@19.0.0)': + dependencies: + '@fortawesome/fontawesome-svg-core': 6.7.2 + prop-types: 15.8.1 + react: 19.0.0 + '@google-cloud/common@5.0.2': dependencies: '@google-cloud/projectify': 4.0.0 @@ -15697,6 +15798,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -17687,6 +17790,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -20926,7 +21031,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20251011.0 '@cloudflare/workerd-windows-64': 1.20251011.0 - wrangler@4.44.0: + wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0) @@ -20937,6 +21042,7 @@ snapshots: unenv: 2.0.0-rc.21 workerd: 1.20251011.0 optionalDependencies: + '@cloudflare/workers-types': 4.20260411.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil