From 973d7457533af3eadd7560b72ce03821d97dd517 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 11:40:20 +0200 Subject: [PATCH 01/62] docs: add self-hosted chat design spec (Stream Chat replacement) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-11-self-hosted-chat-design.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md diff --git a/docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md b/docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md new file mode 100644 index 0000000..78f3b68 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md @@ -0,0 +1,283 @@ +# Self-Hosted Chat: Stream Chat Replacement + +**Date**: 2026-04-11 +**Status**: Approved + +## Motivation + +Replace Stream Chat (stream-chat + stream-chat-react) with a self-hosted solution to achieve: + +- **Cost control** — eliminate Stream's per-MAU pricing +- **Data ownership** — messages stored in our own Postgres +- **Vendor independence** — remove dependency on Stream's backend + +## Architecture + +Three layers: + +1. **Cloudflare Durable Objects** — one DO per tablo channel, managing WebSocket connections and real-time message broadcast. Uses the Hibernatable WebSocket API (idle rooms cost nothing). +2. **Cloudflare Worker** — authenticates WebSocket upgrades (validates Supabase JWTs), routes connections to the correct DO, and serves REST endpoints for message history and channel metadata. +3. **Supabase Postgres** — stores messages, read state. Channels and membership are implicit (tablos and tablo/org membership). + +This is a separate Cloudflare Worker from the main app, with its own DO bindings and environment variables. + +## Data Flow + +### Sending a message + +``` +User sends WS message + → DO receives + → assigns server timestamp + message ID + → broadcasts to all connected WS clients in the room + → writes to Postgres (async, non-blocking via Supabase REST API) +``` + +### Loading history (reconnect / initial load) + +``` +Client connects + → Worker authenticates JWT + → Worker checks tablo membership + → routes to DO + → DO accepts WebSocket +Client requests history + → Worker queries Postgres + → returns paginated messages +``` + +## Data Model + +### New tables + +```sql +-- Messages +CREATE TABLE messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz, + deleted_at timestamptz +); + +CREATE INDEX idx_messages_channel_created ON messages(channel_id, created_at DESC); +``` + +```sql +-- Read state (last read position per user per channel) +CREATE TABLE channel_read_state ( + user_id uuid NOT NULL REFERENCES auth.users(id), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + last_read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id) +); +``` + +### No new tables needed for + +- **Channels** — tablos are the channels (tablo ID = channel ID) +- **Membership** — existing tablo/org membership tables +- **Users** — existing Supabase auth + user tables + +### Unread count query + +```sql +SELECT COUNT(*) FROM messages +WHERE channel_id = :channel_id + AND created_at > ( + SELECT last_read_at FROM channel_read_state + WHERE user_id = :user_id AND channel_id = :channel_id + ) + AND deleted_at IS NULL; +``` + +### Soft deletes + +Messages use `deleted_at` (not hard deletion), consistent with the tablo soft-delete pattern. + +## Durable Objects — Real-time Layer + +One DO per tablo channel, identified by tablo ID. + +### WebSocket lifecycle + +- **Connect**: Worker passes authenticated user ID as a tag via `acceptWebSocket(ws, [userId])`. DO tracks connected users. +- **Message**: DO parses incoming message, assigns server timestamp and ID, broadcasts to all other connected sockets via `getWebSockets()`. +- **Close**: DO cleans up. If no connections remain, it hibernates (zero cost). + +### WebSocket message types + +**Client → Server:** + +| Type | Payload | Persisted | +|------|---------|-----------| +| `message.send` | `{ text, clientId }` | Yes | +| `typing.start` | `{}` | No | +| `typing.stop` | `{}` | No | +| `presence.ping` | `{}` | No | + +**Server → Client:** + +| Type | Payload | +|------|---------| +| `message.new` | `{ id, userId, text, createdAt, clientId }` | +| `typing` | `{ userId, isTyping }` | +| `presence.update` | `{ userId, status }` | +| `error` | `{ code, message }` | + +### What the DO does NOT do + +- No message history queries (Worker + Postgres) +- No channel membership management (existing API) +- No authentication (Worker validates JWT before routing) + +### Presence + +The DO knows who's connected via `getWebSockets()`. On connect/disconnect, it broadcasts `presence.update` to the room. + +### Typing indicators + +Purely ephemeral. Client sends `typing.start`, DO broadcasts to others, never persisted. + +## Cloudflare Worker — API & Routing + +### Endpoints + +``` +GET /chat/ws/:channelId # WebSocket upgrade → routed to DO +GET /chat/channels/:channelId/messages?before=&limit=50 # Paginated history +POST /chat/channels/:channelId/read # Mark channel as read +GET /chat/unread # Unread counts for current user +``` + +### Authentication + +Every request (including WebSocket upgrade) includes the Supabase JWT in the `Authorization` header. The Worker validates the JWT using Supabase's public JWT secret (standard JWT verification, no Supabase SDK needed). + +### Membership check + +Before routing a WebSocket connection to a DO, the Worker verifies the user is a member of the tablo by querying existing membership data via the Supabase REST API. + +### Postgres access + +Both the Worker (for history/unread queries) and the DO (for message persistence) use Supabase's PostgREST API with the service role key. The DO calls PostgREST directly — it does not route writes through the Worker. No direct Postgres connection needed at this scale. + +## Frontend + +### Chat client hook — `useChat` + +```typescript +const { + messages, // Message[] — history + live messages merged + sendMessage, // (text: string) => void + isConnected, // boolean + typingUsers, // string[] — user IDs currently typing + onlineUsers, // string[] — user IDs currently connected + loadMoreMessages, // () => void — pagination trigger + hasMoreMessages, // boolean + markAsRead, // () => void +} = useChat(channelId) +``` + +Internally: + +1. Opens WebSocket to `/chat/ws/:channelId` with JWT +2. Fetches initial message history via REST +3. Merges incoming WebSocket messages with history (dedup via `clientId`) +4. Sends typing events with debounce (start on keypress, stop after 2s idle) +5. Calls `markAsRead` when channel is visible/focused + +### Unread counts — `useChatUnread` + +Polls `GET /chat/unread` on interval (every 30s) or on window focus. Replaces `useTabloDiscussionUnread`. + +### UI components — chatscope + +Using `@chatscope/chat-ui-kit-react`, styled to match existing Radix/Tailwind design system: + +| chatscope component | Replaces | +|---------------------|----------| +| `ConversationList` + `Conversation` | Stream `ChannelList` + `ChannelPreview` | +| `ChatContainer` + `MessageList` + `Message` | Stream `Channel` + `MessageList` | +| `MessageInput` | Stream `MessageInput` | +| `TypingIndicator` | New (wasn't explicitly used before) | +| `Avatar` + `StatusIndicator` | `ChannelBadge` online dot | + +### Styling + +Override chatscope's default CSS theme to match existing Tailwind/Radix design system (colors, fonts, border radius, spacing). + +## Migration — What Changes in Existing Code + +### Removed from `apps/api` + +- Stream server client initialization in `middleware.ts` +- `stream-chat` dependency +- Stream token generation in `GET /api/v1/users/me` (`user.ts:64`) +- `STREAM_CHAT_API_KEY` and `STREAM_CHAT_API_SECRET` config variables +- All `streamServerClient.channel()` calls in `tablo.ts` +- All `streamServerClient.upsertUser()` calls in `user.ts`, `helpers.ts`, `tablo.ts` +- All `channel.addMembers()` / `channel.removeMembers()` calls + +### Replaced with Postgres operations + +- **Tablo creation**: No extra chat setup needed. The tablo IS the channel. +- **Add/remove member**: Already handled by tablo/org membership. No chat-specific call. +- **Update channel name**: Not needed. Channel name = tablo name, read from `tablos` table. +- **Delete channel**: `ON DELETE CASCADE` on `messages.channel_id` handles cleanup. +- **Upsert Stream user**: Removed entirely. No user sync needed. + +### Removed from `apps/main` + +- `stream-chat` and `stream-chat-react` dependencies +- `VITE_STREAM_CHAT_API_KEY` from all env files +- `ChatProvider.tsx` +- `streamToken` from user data flow +- `ChannelPreview.tsx`, `CustomChannelHeader.tsx`, `ChannelBadge.tsx` (replaced by chatscope equivalents) +- `useChannelFromUrl`, `useTabloDiscussionUnread` hooks (replaced by `useChat`, `useChatUnread`) + +### Unchanged + +- Tablo CRUD, membership management, auth — all stay in existing API +- Chat page routes (`/chat`, `/chat/:channelId`) — same URLs, new implementation + +## Error Handling & Edge Cases + +### WebSocket disconnection / reconnect + +- `useChat` implements automatic reconnection with exponential backoff +- On reconnect, fetches messages with `?after=` to fill the gap +- Messages sent while disconnected are queued locally and sent on reconnect (or shown as "failed to send" after timeout) + +### Message ordering + +- DO assigns server timestamps, which are authoritative +- Optimistic messages use `clientId` for dedup — replaced by server echo on arrival +- History from Postgres is ordered by `created_at DESC` + +### Membership enforcement + +- Worker checks tablo membership before allowing WebSocket connection +- If a user is removed while connected, they are evicted on next reconnect (acceptable at small scale) + +### Postgres write failures + +- DO retries up to 3 times +- Messages already delivered via WebSocket — user experience unaffected +- Persistent failure is logged as an operational alert + +### Deploys + +- DO eviction on deploy closes all WebSockets +- Clients reconnect and fetch the gap from Postgres via standard reconnect flow + +## Scale Considerations + +Designed for small scale (under 100 concurrent users, occasional messages). At this scale: + +- A single DO per room handles all connections easily +- Supabase PostgREST is sufficient (no connection pooling needed) +- Polling for unread counts (every 30s) is fine +- No need for message queues, caches, or read replicas From f8a0a92fccc9d2df6505c9c339d451e9af368317 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 11:54:05 +0200 Subject: [PATCH 02/62] docs: add self-hosted chat implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-11-self-hosted-chat.md | 2066 +++++++++++++++++ 1 file changed, 2066 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-11-self-hosted-chat.md diff --git a/docs/superpowers/plans/2026-04-11-self-hosted-chat.md b/docs/superpowers/plans/2026-04-11-self-hosted-chat.md new file mode 100644 index 0000000..efc18c7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-self-hosted-chat.md @@ -0,0 +1,2066 @@ +# Self-Hosted Chat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Stream Chat with a self-hosted chat system using Cloudflare Durable Objects for real-time WebSocket messaging, Supabase Postgres for message persistence, and chatscope for the React UI. + +**Architecture:** A new Cloudflare Worker (`apps/chat-worker`) handles WebSocket connections via Durable Objects (one per tablo channel) and REST endpoints for message history/unread counts. Messages persist to Supabase Postgres via PostgREST. The frontend replaces `stream-chat-react` with `@chatscope/chat-ui-kit-react` components wired to a custom `useChat` hook. + +**Tech Stack:** Cloudflare Workers + Durable Objects, Supabase Postgres (PostgREST), @chatscope/chat-ui-kit-react, TypeScript, Hono (Worker routing) + +**Spec:** `docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md` + +--- + +## File Structure + +### New files + +``` +apps/chat-worker/ + package.json # Worker package with wrangler, hono, jose deps + wrangler.toml # DO bindings, env vars (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET) + tsconfig.json # TypeScript config for Workers runtime + src/ + index.ts # Hono Worker entry: JWT auth, routing, DO dispatch + durable-objects/ + ChatRoom.ts # Hibernatable WebSocket DO — broadcast, typing, presence + lib/ + supabase.ts # PostgREST helper (fetch-based, no SDK) + auth.ts # JWT verification using jose + types.ts # WebSocket message types, API response types + +apps/main/src/ + hooks/ + useChat.ts # WebSocket connection, message send/receive, reconnect, typing + useChatUnread.ts # Polls /chat/unread endpoint + pages/ + chat.tsx # REWRITE: replace Stream components with chatscope + components/ + ChatChannelPreview.tsx # chatscope Conversation wrapper (replaces ChannelPreview.tsx) + ChatHeader.tsx # Channel header (replaces CustomChannelHeader.tsx) +``` + +### Modified files + +``` +apps/main/src/lib/routes.tsx # Remove ChatProvider wrapper from chat route +apps/main/src/providers/UserStoreProvider.tsx # Remove streamToken from User type +apps/main/package.json # Remove stream-chat, stream-chat-react; add @chatscope/chat-ui-kit-react +apps/main/.env.local # Remove VITE_STREAM_CHAT_API_KEY, add VITE_CHAT_WS_URL +apps/main/.env.staging # Same +apps/main/.env.production # Same + +apps/api/src/config.ts # Remove STREAM_CHAT_API_KEY, STREAM_CHAT_API_SECRET +apps/api/src/secrets.ts # Remove streamChatApiSecret, streamChatApiSecretStaging +apps/api/src/types/app.types.ts # Remove streamServerClient from BaseEnv +apps/api/src/middlewares/middleware.ts # Remove streamChatMiddleware +apps/api/src/routers/index.ts # Remove streamChat middleware usage +apps/api/src/routers/user.ts # Remove signUpToStream, streamToken from getMe +apps/api/src/routers/tablo.ts # Remove all Stream channel operations +apps/api/src/helpers/helpers.ts # Remove streamServerClient from createInvitedUser +apps/api/package.json # Remove stream-chat dependency +``` + +### Deleted files + +``` +apps/main/src/providers/ChatProvider.tsx # Stream Chat provider — replaced by useChat +apps/main/src/components/ChannelPreview.tsx # Stream-specific — replaced by ChatChannelPreview +apps/main/src/components/CustomChannelHeader.tsx # Stream-specific — replaced by ChatHeader +apps/main/src/hooks/channel.ts # useChannelFromUrl, useTabloDiscussionUnread — replaced +``` + +### Kept as-is + +``` +apps/main/src/components/ChannelBadge.tsx # Generic component, reused in new chat UI +``` + +--- + +## Task 1: Database Migration — Create messages and channel_read_state tables + +**Files:** +- Create: `supabase/migrations/20260411_create_chat_tables.sql` + +This task creates the Postgres tables that the chat system writes to and reads from. Everything else depends on these existing. + +- [ ] **Step 1: Write the migration SQL** + +```sql +-- supabase/migrations/20260411_create_chat_tables.sql + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz, + deleted_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); + +-- Read state table +CREATE TABLE IF NOT EXISTS channel_read_state ( + user_id uuid NOT NULL REFERENCES auth.users(id), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + last_read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id) +); + +-- RLS policies +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE channel_read_state ENABLE ROW LEVEL SECURITY; + +-- Messages: users can read messages in channels they are members of +CREATE POLICY "Users can read messages in their tablos" + ON messages FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tablo_access + WHERE tablo_access.tablo_id = messages.channel_id + AND tablo_access.user_id = auth.uid() + AND tablo_access.is_active = true + ) + ); + +-- Messages: service role inserts (from chat worker) bypass RLS +-- No INSERT policy needed — the chat worker uses the service role key + +-- Read state: users can read/write their own read state +CREATE POLICY "Users can manage their own read state" + ON channel_read_state FOR ALL + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); +``` + +- [ ] **Step 2: Apply the migration** + +Run: `npx supabase db push` (or apply via Supabase dashboard if using hosted migrations) + +Expected: Tables `messages` and `channel_read_state` created with indexes and RLS policies. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260411_create_chat_tables.sql +git commit -m "feat(chat): add messages and channel_read_state tables" +``` + +--- + +## Task 2: Chat Worker — Project scaffold and configuration + +**Files:** +- Create: `apps/chat-worker/package.json` +- Create: `apps/chat-worker/tsconfig.json` +- Create: `apps/chat-worker/wrangler.toml` +- Create: `apps/chat-worker/src/lib/types.ts` + +This task sets up the new Cloudflare Worker project in the monorepo with proper configuration. + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@xtablo/chat-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "deploy:staging": "wrangler deploy --env staging", + "deploy:prod": "wrangler deploy --env production", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.7", + "jose": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250410.0", + "typescript": "^5.8.3", + "wrangler": "^4.14.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create wrangler.toml** + +```toml +name = "xtablo-chat" +main = "src/index.ts" +compatibility_date = "2025-07-09" + +[durable_objects] +bindings = [ + { name = "CHAT_ROOM", class_name = "ChatRoom" } +] + +[[migrations]] +tag = "v1" +new_classes = ["ChatRoom"] + +[observability] +enabled = true + +[vars] +SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co" + +# Secrets (set via `wrangler secret put`): +# SUPABASE_SERVICE_ROLE_KEY +# JWT_SECRET + +[env.staging] +route = { pattern = "chat-staging.xtablo.com", custom_domain = true } + +[env.production] +route = { pattern = "chat.xtablo.com", custom_domain = true } +``` + +- [ ] **Step 4: Create shared types** + +Create `apps/chat-worker/src/lib/types.ts`: + +```typescript +// WebSocket message types — client to server +export type ClientMessage = + | { type: "message.send"; text: string; clientId: string } + | { type: "typing.start" } + | { type: "typing.stop" } + | { type: "presence.ping" }; + +// WebSocket message types — server to client +export type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +// REST API types +export interface ChatMessage { + id: string; + channel_id: string; + user_id: string; + text: string; + created_at: string; + updated_at: string | null; + deleted_at: string | null; +} + +export interface UnreadCount { + channel_id: string; + unread_count: number; +} + +// Worker environment bindings +export interface Env { + CHAT_ROOM: DurableObjectNamespace; + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + JWT_SECRET: string; +} +``` + +- [ ] **Step 5: Install dependencies** + +Run: `cd apps/chat-worker && pnpm install` + +- [ ] **Step 6: Commit** + +```bash +git add apps/chat-worker/ +git commit -m "feat(chat-worker): scaffold Cloudflare Worker project" +``` + +--- + +## Task 3: Chat Worker — JWT auth and Supabase PostgREST helper + +**Files:** +- Create: `apps/chat-worker/src/lib/auth.ts` +- Create: `apps/chat-worker/src/lib/supabase.ts` + +- [ ] **Step 1: Create JWT auth helper** + +Create `apps/chat-worker/src/lib/auth.ts`: + +```typescript +import { jwtVerify, createRemoteJWKSet } from "jose"; + +interface AuthResult { + userId: string; + email: string | null; +} + +/** + * Verify a Supabase JWT and extract the user ID. + * Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim. + */ +export async function verifyJwt(token: string, jwtSecret: string): Promise { + const secret = new TextEncoder().encode(jwtSecret); + const { payload } = await jwtVerify(token, secret, { + issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1", + }); + + if (!payload.sub) { + throw new Error("Missing sub claim in JWT"); + } + + return { + userId: payload.sub, + email: (payload.email as string) ?? null, + }; +} + +/** + * Extract Bearer token from Authorization header. + */ +export function extractToken(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith("Bearer ")) return null; + return authHeader.slice(7); +} +``` + +- [ ] **Step 2: Create Supabase PostgREST helper** + +Create `apps/chat-worker/src/lib/supabase.ts`: + +```typescript +/** + * Thin PostgREST client using fetch — no Supabase SDK dependency. + * Used by both the Worker (history queries) and the Durable Object (message persistence). + */ +export class PostgREST { + private baseUrl: string; + private serviceRoleKey: string; + + constructor(supabaseUrl: string, serviceRoleKey: string) { + this.baseUrl = `${supabaseUrl}/rest/v1`; + this.serviceRoleKey = serviceRoleKey; + } + + private headers(): Record { + return { + "apikey": this.serviceRoleKey, + "Authorization": `Bearer ${this.serviceRoleKey}`, + "Content-Type": "application/json", + "Prefer": "return=representation", + }; + } + + /** Insert a row and return the inserted data. */ + async insert(table: string, data: Record): Promise { + const res = await fetch(`${this.baseUrl}/${table}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST insert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Upsert a row (requires Prefer: resolution=merge-duplicates). */ + async upsert(table: string, data: Record, onConflict: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "return=representation,resolution=merge-duplicates"; + const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST upsert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select rows with PostgREST query string. */ + async select(table: string, query: string): Promise { + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "GET", + headers: this.headers(), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST select failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select with exact count header for unread queries. */ + async count(table: string, query: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "count=exact"; + headers["Range-Unit"] = "items"; + headers["Range"] = "0-0"; + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "HEAD", + headers, + }); + const contentRange = res.headers.get("Content-Range"); + if (!contentRange) return 0; + // Content-Range format: "0-0/42" or "*/0" + const total = contentRange.split("/")[1]; + return total === "*" ? 0 : parseInt(total, 10); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/chat-worker/src/lib/ +git commit -m "feat(chat-worker): add JWT auth and PostgREST helpers" +``` + +--- + +## Task 4: Chat Worker — ChatRoom Durable Object + +**Files:** +- Create: `apps/chat-worker/src/durable-objects/ChatRoom.ts` + +This is the core real-time component. One instance per tablo channel, managing WebSocket connections, broadcasting messages, and persisting to Postgres. + +- [ ] **Step 1: Create the ChatRoom Durable Object** + +Create `apps/chat-worker/src/durable-objects/ChatRoom.ts`: + +```typescript +import { DurableObject } from "cloudflare:workers"; +import type { Env, ClientMessage, ServerMessage } from "../lib/types"; +import { PostgREST } from "../lib/supabase"; + +export class ChatRoom extends DurableObject { + private postgrest: PostgREST | null = null; + + private getPostgREST(): PostgREST { + if (!this.postgrest) { + this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY); + } + return this.postgrest; + } + + /** + * Called by the Worker to initiate a WebSocket connection. + * The userId has already been authenticated by the Worker. + */ + async handleWebSocket(request: Request, userId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + // Accept with userId as a tag for filtering later + this.ctx.acceptWebSocket(server, [userId]); + + // Broadcast presence to existing connections + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + /** + * Hibernatable WebSocket handler — called when a message arrives. + */ + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (!userId) { + ws.close(4001, "Missing user identity"); + return; + } + + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" }); + return; + } + + switch (msg.type) { + case "message.send": + await this.handleSendMessage(ws, userId, msg.text, msg.clientId); + break; + case "typing.start": + this.broadcast({ type: "typing", userId, isTyping: true }, ws); + break; + case "typing.stop": + this.broadcast({ type: "typing", userId, isTyping: false }, ws); + break; + case "presence.ping": + // No-op — the connection itself is the presence signal + break; + } + } + + /** + * Hibernatable WebSocket handler — called when a connection closes. + */ + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (userId) { + // Only broadcast offline if no other connections for this user + const remaining = this.ctx.getWebSockets(userId); + if (remaining.length === 0) { + this.broadcast({ type: "presence.update", userId, status: "offline" }); + } + } + } + + /** + * Hibernatable WebSocket handler — called on error. + */ + async webSocketError(ws: WebSocket, error: unknown): Promise { + console.error("WebSocket error:", error); + ws.close(1011, "Internal error"); + } + + private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise { + if (!text || text.trim().length === 0) { + this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" }); + return; + } + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + // Extract channelId from the DO's own ID name + // The Worker creates the DO with id = channelId, so we read it from ctx.id + const channelId = this.getChannelId(); + + const serverMsg: ServerMessage = { + type: "message.new", + id, + userId, + text: text.trim(), + createdAt, + clientId, + }; + + // Broadcast to all connections (including sender, for server echo) + this.broadcast(serverMsg); + + // Persist to Postgres asynchronously (fire-and-forget with retry) + this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt)); + } + + private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise { + const db = this.getPostgREST(); + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await db.insert("messages", { + id, + channel_id: channelId, + user_id: userId, + text, + created_at: createdAt, + }); + return; + } catch (error) { + console.error(`Message persist attempt ${attempt + 1} failed:`, error); + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); + } + } + } + console.error(`Failed to persist message ${id} after ${maxRetries} attempts`); + } + + /** + * Get the channel ID from the Durable Object's name. + * The Worker creates the DO ID using `env.CHAT_ROOM.idFromName(channelId)`. + */ + private getChannelId(): string { + // The DO name is set by the Worker when creating the stub. + // We store it on first WebSocket connect via the request URL. + // Fallback: use the hex ID (not ideal but safe). + return this.ctx.id.toString(); + } + + /** Send a typed message to a single WebSocket. */ + private sendTo(ws: WebSocket, msg: ServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection already closed + } + } + + /** Broadcast a typed message to all connected WebSockets, optionally excluding one. */ + private broadcast(msg: ServerMessage, exclude?: WebSocket): void { + const payload = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets()) { + if (ws !== exclude) { + try { + ws.send(payload); + } catch { + // Connection already closed, skip + } + } + } + } +} +``` + +Note on `getChannelId()`: The DO's own hex ID isn't the channel UUID. We need the channel ID for Postgres writes. We'll pass it via the WebSocket URL path and store it. Let me fix this: + +Actually, the cleanest approach: the Worker passes the channelId as a query param in the internal DO request URL. The DO reads it on first WebSocket accept and stores it in transactional storage. Let me update the implementation: + +Replace the `handleWebSocket` and `getChannelId` methods: + +```typescript + async handleWebSocket(request: Request, userId: string, channelId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + // Store channelId if not already stored + const stored = await this.ctx.storage.get("channelId"); + if (!stored) { + await this.ctx.storage.put("channelId", channelId); + } + + this.ctx.acceptWebSocket(server, [userId]); + + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + private async getChannelId(): Promise { + const channelId = await this.ctx.storage.get("channelId"); + if (!channelId) throw new Error("channelId not stored in DO"); + return channelId; + } +``` + +And update `handleSendMessage` to `await this.getChannelId()`. + +The full file should incorporate these changes. Here is the complete `ChatRoom.ts`: + +```typescript +import { DurableObject } from "cloudflare:workers"; +import type { Env, ClientMessage, ServerMessage } from "../lib/types"; +import { PostgREST } from "../lib/supabase"; + +export class ChatRoom extends DurableObject { + private postgrest: PostgREST | null = null; + + private getPostgREST(): PostgREST { + if (!this.postgrest) { + this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY); + } + return this.postgrest; + } + + async handleWebSocket(request: Request, userId: string, channelId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + const stored = await this.ctx.storage.get("channelId"); + if (!stored) { + await this.ctx.storage.put("channelId", channelId); + } + + this.ctx.acceptWebSocket(server, [userId]); + + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (!userId) { + ws.close(4001, "Missing user identity"); + return; + } + + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" }); + return; + } + + switch (msg.type) { + case "message.send": + await this.handleSendMessage(ws, userId, msg.text, msg.clientId); + break; + case "typing.start": + this.broadcast({ type: "typing", userId, isTyping: true }, ws); + break; + case "typing.stop": + this.broadcast({ type: "typing", userId, isTyping: false }, ws); + break; + case "presence.ping": + break; + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (userId) { + const remaining = this.ctx.getWebSockets(userId); + if (remaining.length === 0) { + this.broadcast({ type: "presence.update", userId, status: "offline" }); + } + } + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + console.error("WebSocket error:", error); + ws.close(1011, "Internal error"); + } + + private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise { + if (!text || text.trim().length === 0) { + this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" }); + return; + } + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const channelId = await this.getChannelId(); + + const serverMsg: ServerMessage = { + type: "message.new", + id, + userId, + text: text.trim(), + createdAt, + clientId, + }; + + this.broadcast(serverMsg); + this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt)); + } + + private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise { + const db = this.getPostgREST(); + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await db.insert("messages", { + id, + channel_id: channelId, + user_id: userId, + text, + created_at: createdAt, + }); + return; + } catch (error) { + console.error(`Message persist attempt ${attempt + 1} failed:`, error); + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); + } + } + } + console.error(`Failed to persist message ${id} after ${maxRetries} attempts`); + } + + private async getChannelId(): Promise { + const channelId = await this.ctx.storage.get("channelId"); + if (!channelId) throw new Error("channelId not stored in DO"); + return channelId; + } + + private sendTo(ws: WebSocket, msg: ServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection already closed + } + } + + private broadcast(msg: ServerMessage, exclude?: WebSocket): void { + const payload = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets()) { + if (ws !== exclude) { + try { + ws.send(payload); + } catch { + // Connection already closed + } + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/chat-worker/src/durable-objects/ChatRoom.ts +git commit -m "feat(chat-worker): implement ChatRoom Durable Object with WebSocket hibernation" +``` + +--- + +## Task 5: Chat Worker — Hono entry point with routing + +**Files:** +- Create: `apps/chat-worker/src/index.ts` + +The Worker entry point: authenticates requests, checks membership, dispatches WebSocket upgrades to DOs, and serves REST endpoints for message history, unread counts, and marking channels as read. + +- [ ] **Step 1: Create the Worker entry point** + +Create `apps/chat-worker/src/index.ts`: + +```typescript +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { ChatRoom } from "./durable-objects/ChatRoom"; +import { extractToken, verifyJwt } from "./lib/auth"; +import { PostgREST } from "./lib/supabase"; +import type { Env, ChatMessage, UnreadCount } from "./lib/types"; + +// Re-export DO class for wrangler +export { ChatRoom }; + +const app = new Hono<{ Bindings: Env }>(); + +// CORS — allow the main app origins +app.use("*", cors({ + origin: [ + "http://localhost:5173", + "https://app.xtablo.com", + "https://app-staging.xtablo.com", + ], + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], +})); + +// Auth middleware — extract and verify JWT for all routes +// For WebSocket upgrades, the token comes via query param (?token=...) since browsers +// cannot send custom headers on WebSocket connections. +// For REST requests, the token comes via the Authorization header. +app.use("*", async (c, next) => { + const isWebSocket = c.req.header("Upgrade") === "websocket"; + const token = isWebSocket + ? new URL(c.req.url).searchParams.get("token") + : extractToken(c.req.header("Authorization")); + + if (!token) { + return c.json({ error: "Missing authorization" }, 401); + } + try { + const auth = await verifyJwt(token, c.env.JWT_SECRET); + c.set("userId" as never, auth.userId); + } catch (error) { + return c.json({ error: "Invalid token" }, 401); + } + await next(); +}); + +// Helper: check tablo membership via PostgREST +async function checkMembership(db: PostgREST, channelId: string, userId: string): Promise { + const rows = await db.select<{ user_id: string }>( + "tablo_access", + `tablo_id=eq.${channelId}&user_id=eq.${userId}&is_active=eq.true&select=user_id&limit=1` + ); + return rows.length > 0; +} + +// WebSocket upgrade — route to Durable Object +app.get("/chat/ws/:channelId", async (c) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader !== "websocket") { + return c.json({ error: "Expected WebSocket upgrade" }, 426); + } + + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const id = c.env.CHAT_ROOM.idFromName(channelId); + const stub = c.env.CHAT_ROOM.get(id); + return stub.handleWebSocket(c.req.raw, userId, channelId); +}); + +// GET message history — paginated +app.get("/chat/channels/:channelId/messages", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const before = c.req.query("before"); + const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 100); + + let query = `channel_id=eq.${channelId}&deleted_at=is.null&select=id,channel_id,user_id,text,created_at&order=created_at.desc&limit=${limit}`; + if (before) { + query += `&created_at=lt.${before}`; + } + + const messages = await db.select( + "messages", + query + ); + + return c.json({ messages: messages.reverse(), hasMore: messages.length === limit }); +}); + +// POST mark channel as read +app.post("/chat/channels/:channelId/read", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + await db.upsert("channel_read_state", { + user_id: userId, + channel_id: channelId, + last_read_at: new Date().toISOString(), + }, "user_id,channel_id"); + + return c.json({ ok: true }); +}); + +// GET unread counts for current user across all channels +app.get("/chat/unread", async (c) => { + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + // Get all tablos the user has access to + const accessRows = await db.select<{ tablo_id: string }>( + "tablo_access", + `user_id=eq.${userId}&is_active=eq.true&select=tablo_id` + ); + + if (accessRows.length === 0) { + return c.json({ unread: [] }); + } + + // For each channel, get unread count + // Use a Postgres function or do it in a loop (at small scale, the loop is fine) + const unread: UnreadCount[] = []; + + for (const { tablo_id } of accessRows) { + // Get last read time + const readState = await db.select<{ last_read_at: string }>( + "channel_read_state", + `user_id=eq.${userId}&channel_id=eq.${tablo_id}&select=last_read_at&limit=1` + ); + + const lastReadAt = readState[0]?.last_read_at ?? "1970-01-01T00:00:00Z"; + + const count = await db.count( + "messages", + `channel_id=eq.${tablo_id}&deleted_at=is.null&created_at=gt.${lastReadAt}` + ); + + if (count > 0) { + unread.push({ channel_id: tablo_id, unread_count: count }); + } + } + + return c.json({ unread }); +}); + +export default app; +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd apps/chat-worker && pnpm typecheck` + +Expected: No type errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/chat-worker/src/index.ts +git commit -m "feat(chat-worker): add Hono entry point with WebSocket routing and REST endpoints" +``` + +--- + +## Task 6: Frontend — useChat hook + +**Files:** +- Create: `apps/main/src/hooks/useChat.ts` + +The core frontend hook that manages the WebSocket connection, message state, typing indicators, presence, and reconnection logic. + +- [ ] **Step 1: Create the useChat hook** + +Create `apps/main/src/hooks/useChat.ts`: + +```typescript +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; + +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + /** True while the message is only local (not yet echoed by server). */ + optimistic?: boolean; +} + +type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +const CHAT_WS_BASE = import.meta.env.VITE_CHAT_WS_URL as string; +const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string; + +export function useChat(channelId: string | undefined) { + const { session } = useSession(); + const token = session?.access_token; + + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [typingUsers, setTypingUsers] = useState([]); + const [onlineUsers, setOnlineUsers] = useState([]); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef>(); + const typingTimerRef = useRef>(); + const isTypingRef = useRef(false); + + // Fetch message history from REST endpoint + const fetchHistory = useCallback(async (before?: string) => { + if (!channelId || !token) return; + + const params = new URLSearchParams({ limit: "50" }); + if (before) params.set("before", before); + + const res = await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/messages?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) return; + + const data = await res.json() as { messages: ChatMessage[]; hasMore: boolean }; + setHasMoreMessages(data.hasMore); + + if (before) { + // Prepend older messages + setMessages((prev) => [...data.messages, ...prev]); + } else { + // Initial load + setMessages(data.messages); + } + }, [channelId, token]); + + // Load more (pagination) + const loadMoreMessages = useCallback(() => { + if (messages.length === 0 || !hasMoreMessages) return; + const oldest = messages[0]; + fetchHistory(oldest.createdAt); + }, [messages, hasMoreMessages, fetchHistory]); + + // WebSocket connection management + useEffect(() => { + if (!channelId || !token) return; + + const connect = () => { + // Token passed via query param because browsers cannot send custom headers on WS connections + const wsUrl = `${CHAT_WS_BASE}/chat/ws/${channelId}?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + setIsConnected(true); + reconnectAttemptRef.current = 0; + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as ServerMessage; + + switch (msg.type) { + case "message.new": + setMessages((prev) => { + // Deduplicate: replace optimistic message with server version + const withoutOptimistic = prev.filter( + (m) => !(m.clientId === msg.clientId && m.optimistic) + ); + // Avoid duplicate if message already received + if (withoutOptimistic.some((m) => m.id === msg.id)) { + return withoutOptimistic; + } + return [...withoutOptimistic, { + id: msg.id, + userId: msg.userId, + text: msg.text, + createdAt: msg.createdAt, + clientId: msg.clientId, + }]; + }); + break; + + case "typing": + setTypingUsers((prev) => + msg.isTyping + ? prev.includes(msg.userId) ? prev : [...prev, msg.userId] + : prev.filter((id) => id !== msg.userId) + ); + break; + + case "presence.update": + setOnlineUsers((prev) => + msg.status === "online" + ? prev.includes(msg.userId) ? prev : [...prev, msg.userId] + : prev.filter((id) => id !== msg.userId) + ); + break; + + case "error": + console.error("Chat error:", msg.code, msg.message); + break; + } + }; + + ws.onclose = () => { + setIsConnected(false); + wsRef.current = null; + + // Exponential backoff reconnect + const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000); + reconnectAttemptRef.current++; + reconnectTimerRef.current = setTimeout(connect, delay); + }; + + ws.onerror = () => { + ws.close(); + }; + + wsRef.current = ws; + }; + + // Load initial history then connect WebSocket + fetchHistory().then(connect); + + return () => { + clearTimeout(reconnectTimerRef.current); + clearTimeout(typingTimerRef.current); + wsRef.current?.close(); + wsRef.current = null; + setMessages([]); + setIsConnected(false); + setTypingUsers([]); + setOnlineUsers([]); + }; + }, [channelId, token, fetchHistory]); + + // Send message + const sendMessage = useCallback((text: string) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + const clientId = crypto.randomUUID(); + + // Optimistic update + setMessages((prev) => [ + ...prev, + { + id: `optimistic-${clientId}`, + userId: session?.user?.id ?? "", + text, + createdAt: new Date().toISOString(), + clientId, + optimistic: true, + }, + ]); + + wsRef.current.send(JSON.stringify({ type: "message.send", text, clientId })); + + // Stop typing when sending + if (isTypingRef.current) { + wsRef.current.send(JSON.stringify({ type: "typing.stop" })); + isTypingRef.current = false; + clearTimeout(typingTimerRef.current); + } + }, [session?.user?.id]); + + // Typing indicator + const sendTyping = useCallback(() => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + if (!isTypingRef.current) { + isTypingRef.current = true; + wsRef.current.send(JSON.stringify({ type: "typing.start" })); + } + + clearTimeout(typingTimerRef.current); + typingTimerRef.current = setTimeout(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "typing.stop" })); + } + isTypingRef.current = false; + }, 2000); + }, []); + + // Mark as read + const markAsRead = useCallback(async () => { + if (!channelId || !token) return; + await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/read`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + }, [channelId, token]); + + return { + messages, + sendMessage, + sendTyping, + isConnected, + typingUsers, + onlineUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/main/src/hooks/useChat.ts +git commit -m "feat(chat): add useChat hook with WebSocket connection and reconnection" +``` + +--- + +## Task 7: Frontend — useChatUnread hook + +**Files:** +- Create: `apps/main/src/hooks/useChatUnread.ts` + +Polls the chat worker for unread counts across all channels. Replaces `useTabloDiscussionUnread`. + +- [ ] **Step 1: Create the useChatUnread hook** + +Create `apps/main/src/hooks/useChatUnread.ts`: + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; + +const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string; + +interface UnreadCount { + channel_id: string; + unread_count: number; +} + +export function useChatUnread() { + const { session } = useSession(); + const token = session?.access_token; + + const { data } = useQuery({ + queryKey: ["chat-unread"], + queryFn: async (): Promise => { + const res = await fetch(`${CHAT_API_BASE}/chat/unread`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return []; + const json = await res.json() as { unread: UnreadCount[] }; + return json.unread; + }, + enabled: !!token, + refetchInterval: 30_000, + refetchOnWindowFocus: true, + }); + + return { + unreadCounts: data ?? [], + getUnreadCount: (channelId: string) => + data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0, + hasUnread: (channelId: string) => + (data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0) > 0, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/main/src/hooks/useChatUnread.ts +git commit -m "feat(chat): add useChatUnread hook for polling unread counts" +``` + +--- + +## Task 8: Frontend — Chat UI components with chatscope + +**Files:** +- Create: `apps/main/src/components/ChatChannelPreview.tsx` +- Create: `apps/main/src/components/ChatHeader.tsx` +- Modify: `apps/main/src/pages/chat.tsx` +- Modify: `apps/main/src/lib/routes.tsx` +- Modify: `apps/main/package.json` + +This task rewrites the chat page to use chatscope components instead of stream-chat-react. + +- [ ] **Step 1: Install chatscope** + +Run: `cd apps/main && pnpm add @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles` + +- [ ] **Step 2: Create ChatChannelPreview component** + +Create `apps/main/src/components/ChatChannelPreview.tsx`: + +```typescript +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 && ( +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Create ChatHeader component** + +Create `apps/main/src/components/ChatHeader.tsx`: + +```typescript +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 +

+ )} +
+ + )} +
+ ); +} +``` + +- [ ] **Step 4: Rewrite the chat page** + +Replace the contents of `apps/main/src/pages/chat.tsx` with: + +```typescript +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; +import { + ChatContainer, + MessageList, + Message, + MessageInput, + TypingIndicator, +} from "@chatscope/chat-ui-kit-react"; +import { useEffect, useRef, 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 { channelId } = useParams(); + const navigate = useNavigate(); + const { data: tablos } = useTablosList(); + const { getUnreadCount } = useChatUnread(); + const [isChannelListExpanded, setIsChannelListExpanded] = useState(!channelId); + + 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); + }; + + const handleChannelSelect = (tabloId: string) => { + navigate(`/chat/${tabloId}`); + }; + + return ( +
+
+

Discussions

+
+
+ {/* Channel list sidebar */} +
+
+ {tablos?.map((tablo) => ( + handleChannelSelect(tablo.id)} + unreadCount={getUnreadCount(tablo.id)} + isOnline={onlineUsers.some((uid) => uid !== user.id)} + /> + ))} +
+
+ + {/* Chat area */} +
+ {channelId && activeTablo ? ( + <> + setIsChannelListExpanded(!isChannelListExpanded)} + isChannelListExpanded={isChannelListExpanded} + onlineUsers={onlineUsers} + /> +
+ + 0 ? ( + + ) : undefined + } + > + {messages.map((msg) => ( + + ))} + + sendTyping()} + attachButton={false} + /> + +
+ + ) : ( +
+ Select a conversation to start chatting +
+ )} +
+
+
+ ); +} +``` + +- [ ] **Step 5: Update routes — remove ChatProvider wrapper** + +In `apps/main/src/lib/routes.tsx`, change the chat route from: + +```typescript +import ChatProvider from "../providers/ChatProvider"; +``` +and +```typescript + { + path: "chat", + element: ( + + + + ), + children: [{ index: true }, { path: ":channelId" }], + }, +``` + +to: + +```typescript + { + path: "chat", + element: , + children: [{ index: true }, { path: ":channelId" }], + }, +``` + +Remove the `import ChatProvider` line entirely. + +- [ ] **Step 6: Commit** + +```bash +git add apps/main/src/components/ChatChannelPreview.tsx apps/main/src/components/ChatHeader.tsx apps/main/src/pages/chat.tsx apps/main/src/lib/routes.tsx apps/main/package.json +git commit -m "feat(chat): rewrite chat page with chatscope UI and custom hooks" +``` + +--- + +## Task 9: Frontend — Environment variables + +**Files:** +- Modify: `apps/main/.env.local` +- Modify: `apps/main/.env.staging` +- Modify: `apps/main/.env.production` + +- [ ] **Step 1: Update .env.local** + +Remove the line `VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx"` and add: + +``` +VITE_CHAT_WS_URL=ws://localhost:8787 +VITE_CHAT_API_URL=http://localhost:8787 +``` + +- [ ] **Step 2: Update .env.staging** + +Remove the line `VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"` and add: + +``` +VITE_CHAT_WS_URL=wss://chat-staging.xtablo.com +VITE_CHAT_API_URL=https://chat-staging.xtablo.com +``` + +- [ ] **Step 3: Update .env.production** + +Remove the line `VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx"` and add: + +``` +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/.env.local apps/main/.env.staging apps/main/.env.production +git commit -m "feat(chat): update env vars — replace Stream API key with chat worker URLs" +``` + +--- + +## Task 10: Backend — Remove Stream Chat from API + +**Files:** +- Modify: `apps/api/src/types/app.types.ts` — remove `streamServerClient` from BaseEnv +- Modify: `apps/api/src/middlewares/middleware.ts` — remove streamChatMiddleware +- Modify: `apps/api/src/routers/index.ts` — remove `streamChat` middleware usage +- Modify: `apps/api/src/config.ts` — remove STREAM_CHAT_API_KEY, STREAM_CHAT_API_SECRET +- Modify: `apps/api/src/secrets.ts` — remove streamChatApiSecret, streamChatApiSecretStaging +- Modify: `apps/api/src/routers/user.ts` — remove signUpToStream, streamToken from getMe, Stream from inviteToOrganization and removeOrganizationMember +- Modify: `apps/api/src/routers/tablo.ts` — remove all Stream channel operations +- Modify: `apps/api/src/helpers/helpers.ts` — remove streamServerClient from createInvitedUser +- Modify: `apps/api/package.json` — remove stream-chat dependency +- Modify: `apps/api/src/__tests__/routes/tablo.test.ts` — remove Stream Chat mocks +- Modify: `apps/api/src/__tests__/config/stripe-config.test.ts` — remove streamChat mock data + +This is the largest task but is entirely removal. Each change is a deletion, not a rewrite. + +- [ ] **Step 1: Remove StreamChat from app.types.ts** + +In `apps/api/src/types/app.types.ts`: + +Remove the import: `import type { StreamChat } from "stream-chat";` + +Remove `streamServerClient: StreamChat;` from the `BaseEnv.Variables` type. + +The `BaseEnv` becomes: +```typescript +export type BaseEnv = { + Variables: { + supabase: SupabaseClient; + s3_client: S3Client; + transporter: Transporter; + stripe: Stripe; + stripeSync: StripeSync; + }; +}; +``` + +- [ ] **Step 2: Remove streamChatMiddleware from middleware.ts** + +In `apps/api/src/middlewares/middleware.ts`: + +1. Remove the import: `import { StreamChat } from "stream-chat";` +2. Remove `streamChatMiddleware` from the `Middlewares` type (lines 28-30) +3. Remove the `streamChatMiddleware` creation (lines 171-178) +4. Remove `streamChatMiddleware` from the return object (line 258) +5. Remove the `get streamChat()` getter (lines 285-287) + +- [ ] **Step 3: Remove streamChat from router index** + +In `apps/api/src/routers/index.ts`, remove line 20: +```typescript + mainRouter.use(middlewareManager.streamChat); +``` + +- [ ] **Step 4: Remove Stream config vars** + +In `apps/api/src/config.ts`: + +1. Remove lines 11-12 from `AppConfig`: + ```typescript + STREAM_CHAT_API_KEY: string; + STREAM_CHAT_API_SECRET: string; + ``` +2. Remove lines 62-63 (the `getStreamChatApiSecret` helper) +3. Remove lines 85-89 (the config assignments for `STREAM_CHAT_API_KEY` and `STREAM_CHAT_API_SECRET`) + +- [ ] **Step 5: Remove Stream from secrets.ts** + +In `apps/api/src/secrets.ts`: + +1. Remove from the `Secrets` type: `streamChatApiSecret: string;` and `streamChatApiSecretStaging: string;` +2. Remove from `loadSecrets()`: the lines fetching `stream-chat-api-secret-staging` and `stream-chat-api-secret` + +- [ ] **Step 6: Remove Stream from user.ts** + +In `apps/api/src/routers/user.ts`: + +1. Remove the `signUpToStream` handler entirely (lines 14-32) +2. In `getMe` handler (lines 34-71): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 37) + - Remove `const token = streamServerClient.createToken(user_id);` (line 64) + - Remove `streamToken: token` from the JSON response (line 69) + - The response becomes: `return c.json({ ...userData, plan: effectivePlan });` +3. In `inviteToOrganization` (lines 514-715): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 518) + - Remove `streamServerClient` from `createInvitedUser` call (line 614-621) — pass only `supabase, transporter, ...` + - Remove the Stream channel addMembers loop (lines 676-683) +4. In `removeOrganizationMember` (lines 717-850): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 720) + - Remove the Stream channel removeMembers loop (lines 829-836) +5. Remove the route: `userRouter.post("/sign-up-to-stream", ...signUpToStream);` (line 855) + +- [ ] **Step 7: Remove Stream from tablo.ts** + +In `apps/api/src/routers/tablo.ts`: + +1. Remove the `isAlreadyMemberError` helper (lines 21-29) +2. Remove the `upsertStreamUserFromProfile` helper (lines 31-47) +3. Remove the `ensureTabloChannelMember` helper (lines 49-96) +4. In `createTablo` (lines 98-170): + - Remove `const streamServerClient = c.get("streamServerClient");` and the channel.create block (lines 150-157) +5. In `updateTablo` (lines 172-220): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 176) + - Remove the channel.update block (lines 207-217) +6. In `deleteTablo` (lines 222-281): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 225) + - Remove the channel.delete block (lines 273-278) +7. In `inviteToTablo` (lines 283-435): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 291) + - Remove `streamServerClient` from `createInvitedUser` call (line 356-363) + - Remove the `ensureTabloChannelMember` call (lines 384-389) +8. In `cancelPendingInvite` (lines 437-526): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 441) + - Remove the channel.removeMembers block (lines 517-522) +9. In `acceptInviteById` (lines 572-632): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 576) + - Remove the `upsertStreamUserFromProfile` call (lines 601-606) + - Remove the `ensureTabloChannelMember` call (lines 624-629) +10. In `joinTablo` (lines 634-697): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 639) + - Remove the `upsertStreamUserFromProfile` call (lines 660-665) + - Remove the `ensureTabloChannelMember` call (lines 689-694) +11. In `leaveTablo` (lines 748-768): + - Remove `const streamServerClient = c.get("streamServerClient");` (line 751) + - Remove the `channel.removeMembers` call (lines 754-755) +12. In `getTabloRouter` (lines 869-891): + - Remove `tabloRouter.use(middlewareManager.streamChat);` (line 875) + +- [ ] **Step 8: Remove Stream from helpers.ts** + +In `apps/api/src/helpers/helpers.ts`: + +1. Remove `import type { StreamChat } from "stream-chat";` (line 6) +2. In `createInvitedUser` (lines 291-373): + - Remove `streamServerClient: StreamChat` from the parameter list (line 293) + - Remove the `streamServerClient.upsertUser()` call (lines 337-341) + +- [ ] **Step 9: Remove stream-chat dependency** + +Run: `cd apps/api && pnpm remove stream-chat` + +- [ ] **Step 10: Update test files** + +In `apps/api/src/__tests__/routes/tablo.test.ts`: +- Remove the Stream Chat mock block (lines 12-38) +- Remove any `mockChannel*` expectations in individual tests + +In `apps/api/src/__tests__/config/stripe-config.test.ts`: +- Remove `streamChatApiSecret` and `streamChatApiSecretStaging` from mock data (lines 13, 16) + +Update any other test files that reference Stream Chat mocks. + +- [ ] **Step 11: Run tests** + +Run: `pnpm test:api` + +Expected: All tests pass with Stream Chat removed. + +- [ ] **Step 12: Commit** + +```bash +git add apps/api/ +git commit -m "refactor(api): remove all Stream Chat dependencies and operations" +``` + +--- + +## Task 11: Frontend — Remove Stream Chat dependencies + +**Files:** +- Delete: `apps/main/src/providers/ChatProvider.tsx` +- Delete: `apps/main/src/components/ChannelPreview.tsx` +- Delete: `apps/main/src/components/CustomChannelHeader.tsx` +- Delete: `apps/main/src/hooks/channel.ts` +- Modify: `apps/main/src/providers/UserStoreProvider.tsx` — remove streamToken +- Modify: `apps/main/package.json` — remove stream-chat, stream-chat-react +- Modify: `packages/shared/src/hooks/auth.ts` — remove useSignUpToStream + +- [ ] **Step 1: Delete Stream-specific files** + +Delete these files: +- `apps/main/src/providers/ChatProvider.tsx` +- `apps/main/src/components/ChannelPreview.tsx` +- `apps/main/src/components/CustomChannelHeader.tsx` +- `apps/main/src/hooks/channel.ts` + +- [ ] **Step 2: Remove streamToken from UserStoreProvider** + +In `apps/main/src/providers/UserStoreProvider.tsx`: + +Change the `User` type (line 10-12) from: +```typescript +export type User = Tables<"profiles"> & { + streamToken: string | null; +}; +``` +to: +```typescript +export type User = Tables<"profiles">; +``` + +- [ ] **Step 3: Remove useSignUpToStream from auth.ts** + +In `packages/shared/src/hooks/auth.ts`: + +1. Remove the `useSignUpToStream` function entirely (lines 85-101) +2. In `useSignUpWithoutPassword`, remove: + - `const { signUpToStream } = useSignUpToStream(api);` (line 15) + - The `signUpToStream` call in the mutation (lines 38-40): + ```typescript + if (response.session?.access_token) { + await signUpToStream(response.session.access_token); + } + ``` + +- [ ] **Step 4: Remove Stream packages** + +Run: `cd apps/main && pnpm remove stream-chat stream-chat-react` + +- [ ] **Step 5: Search for any remaining Stream references** + +Run: `grep -r "stream-chat\|streamToken\|STREAM_CHAT\|StreamChat\|useChannelFromUrl\|useTabloDiscussionUnread\|ChatProvider" apps/main/src/ --include="*.ts" --include="*.tsx" -l` + +Expected: No files returned (only test mocks, if any). + +- [ ] **Step 6: Remove VITE_STREAM_CHAT_API_KEY from external app if present** + +Check `apps/external/.env.production` — if it references `VITE_STREAM_CHAT_API_KEY`, remove it. + +- [ ] **Step 7: Run typecheck and tests** + +Run: `pnpm typecheck && cd apps/main && pnpm test` + +Expected: No type errors, all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(main): remove all Stream Chat dependencies and components" +``` + +--- + +## Task 12: Frontend — Remove Stream Chat API env var from API .env files + +**Files:** +- Modify: `apps/api/.env.development` + +- [ ] **Step 1: Remove STREAM_CHAT_API_KEY** + +In `apps/api/.env.development`, remove line 3: +``` +STREAM_CHAT_API_KEY=h7bwnn8ynjpx +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/.env.development +git commit -m "chore: remove STREAM_CHAT_API_KEY from API env files" +``` + +--- + +## Task 13: Integration testing — End-to-end chat flow + +**Files:** +- No new files — manual testing + +- [ ] **Step 1: Start the chat worker locally** + +Run: `cd apps/chat-worker && pnpm dev` + +Expected: Worker starts on `http://localhost:8787` with DO bindings. + +Note: Before this, set the required secrets locally: +```bash +cd apps/chat-worker +echo "your-supabase-service-role-key" | wrangler secret put SUPABASE_SERVICE_ROLE_KEY --local +echo "your-jwt-secret" | wrangler secret put JWT_SECRET --local +``` + +- [ ] **Step 2: Start the main app** + +Run: `pnpm dev:main` + +- [ ] **Step 3: Test the golden path** + +1. Log in to the app +2. Navigate to `/chat` +3. Select a tablo channel from the sidebar +4. Send a message — verify it appears immediately (optimistic UI) +5. Open a second browser tab with the same channel — verify the message appears there +6. Send a message from the second tab — verify it appears in both tabs +7. Type in one tab — verify typing indicator shows in the other + +- [ ] **Step 4: Test reconnection** + +1. Stop the chat worker (`ctrl+c` in the terminal running `pnpm dev` in chat-worker) +2. Verify the UI shows disconnected state +3. Restart the chat worker +4. Verify the client reconnects and loads missed messages + +- [ ] **Step 5: Test unread counts** + +1. Open `/chat` in one tab +2. Navigate away from the chat page in a second tab +3. Send a message from the first tab +4. In the second tab, the unread badge should appear within 30 seconds (polling interval) + +- [ ] **Step 6: Verify API has no Stream references** + +Run: `pnpm dev:api` +Run: Test various tablo operations (create, update, delete, invite) and verify they work without Stream Chat errors. + +- [ ] **Step 7: Run full test suite** + +Run: `pnpm test` + +Expected: All tests pass. From a9dc771ffba9f69b12d1a23adb707f241cf2fb6f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 11:58:10 +0200 Subject: [PATCH 03/62] feat(chat): add messages and channel_read_state tables --- .../20260411_create_chat_tables.sql | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 supabase/migrations/20260411_create_chat_tables.sql diff --git a/supabase/migrations/20260411_create_chat_tables.sql b/supabase/migrations/20260411_create_chat_tables.sql new file mode 100644 index 0000000..63eee12 --- /dev/null +++ b/supabase/migrations/20260411_create_chat_tables.sql @@ -0,0 +1,47 @@ +-- supabase/migrations/20260411_create_chat_tables.sql + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz, + deleted_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); + +-- Read state table +CREATE TABLE IF NOT EXISTS channel_read_state ( + user_id uuid NOT NULL REFERENCES auth.users(id), + channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + last_read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id) +); + +-- RLS policies +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE channel_read_state ENABLE ROW LEVEL SECURITY; + +-- Messages: users can read messages in channels they are members of +CREATE POLICY "Users can read messages in their tablos" + ON messages FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tablo_access + WHERE tablo_access.tablo_id = messages.channel_id + AND tablo_access.user_id = auth.uid() + AND tablo_access.is_active = true + ) + ); + +-- Messages: service role inserts (from chat worker) bypass RLS +-- No INSERT policy needed — the chat worker uses the service role key + +-- Read state: users can read/write their own read state +CREATE POLICY "Users can manage their own read state" + ON channel_read_state FOR ALL + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); From d3f428720091cde18885ba7b52cc552d4d7c54cb Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 11:59:27 +0200 Subject: [PATCH 04/62] feat(chat-worker): scaffold Cloudflare Worker project Co-Authored-By: Claude Sonnet 4.6 --- apps/chat-worker/package.json | 22 ++++++++++++++++++ apps/chat-worker/src/lib/types.ts | 37 +++++++++++++++++++++++++++++++ apps/chat-worker/tsconfig.json | 15 +++++++++++++ apps/chat-worker/wrangler.toml | 28 +++++++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 apps/chat-worker/package.json create mode 100644 apps/chat-worker/src/lib/types.ts create mode 100644 apps/chat-worker/tsconfig.json create mode 100644 apps/chat-worker/wrangler.toml diff --git a/apps/chat-worker/package.json b/apps/chat-worker/package.json new file mode 100644 index 0000000..9230b22 --- /dev/null +++ b/apps/chat-worker/package.json @@ -0,0 +1,22 @@ +{ + "name": "@xtablo/chat-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "deploy:staging": "wrangler deploy --env staging", + "deploy:prod": "wrangler deploy --env production", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.7", + "jose": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250410.0", + "typescript": "^5.8.3", + "wrangler": "^4.14.0" + } +} diff --git a/apps/chat-worker/src/lib/types.ts b/apps/chat-worker/src/lib/types.ts new file mode 100644 index 0000000..882f57e --- /dev/null +++ b/apps/chat-worker/src/lib/types.ts @@ -0,0 +1,37 @@ +// WebSocket message types — client to server +export type ClientMessage = + | { type: "message.send"; text: string; clientId: string } + | { type: "typing.start" } + | { type: "typing.stop" } + | { type: "presence.ping" }; + +// WebSocket message types — server to client +export type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +// REST API types +export interface ChatMessage { + id: string; + channel_id: string; + user_id: string; + text: string; + created_at: string; + updated_at: string | null; + deleted_at: string | null; +} + +export interface UnreadCount { + channel_id: string; + unread_count: number; +} + +// Worker environment bindings +export interface Env { + CHAT_ROOM: DurableObjectNamespace; + SUPABASE_URL: string; + SUPABASE_SERVICE_ROLE_KEY: string; + JWT_SECRET: string; +} diff --git a/apps/chat-worker/tsconfig.json b/apps/chat-worker/tsconfig.json new file mode 100644 index 0000000..a4473e3 --- /dev/null +++ b/apps/chat-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/apps/chat-worker/wrangler.toml b/apps/chat-worker/wrangler.toml new file mode 100644 index 0000000..ee06486 --- /dev/null +++ b/apps/chat-worker/wrangler.toml @@ -0,0 +1,28 @@ +name = "xtablo-chat" +main = "src/index.ts" +compatibility_date = "2025-07-09" + +[durable_objects] +bindings = [ + { name = "CHAT_ROOM", class_name = "ChatRoom" } +] + +[[migrations]] +tag = "v1" +new_classes = ["ChatRoom"] + +[observability] +enabled = true + +[vars] +SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co" + +# Secrets (set via `wrangler secret put`): +# SUPABASE_SERVICE_ROLE_KEY +# JWT_SECRET + +[env.staging] +route = { pattern = "chat-staging.xtablo.com", custom_domain = true } + +[env.production] +route = { pattern = "chat.xtablo.com", custom_domain = true } From f6a56fdbddcd84d5fb23b15be9504d5ffaab3e3d Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 12:00:25 +0200 Subject: [PATCH 05/62] feat(chat-worker): add JWT auth and PostgREST helpers --- apps/chat-worker/src/lib/auth.ts | 34 ++++++++++++ apps/chat-worker/src/lib/supabase.ts | 82 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/chat-worker/src/lib/auth.ts create mode 100644 apps/chat-worker/src/lib/supabase.ts diff --git a/apps/chat-worker/src/lib/auth.ts b/apps/chat-worker/src/lib/auth.ts new file mode 100644 index 0000000..62ef91e --- /dev/null +++ b/apps/chat-worker/src/lib/auth.ts @@ -0,0 +1,34 @@ +import { jwtVerify } from "jose"; + +interface AuthResult { + userId: string; + email: string | null; +} + +/** + * Verify a Supabase JWT and extract the user ID. + * Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim. + */ +export async function verifyJwt(token: string, jwtSecret: string): Promise { + const secret = new TextEncoder().encode(jwtSecret); + const { payload } = await jwtVerify(token, secret, { + issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1", + }); + + if (!payload.sub) { + throw new Error("Missing sub claim in JWT"); + } + + return { + userId: payload.sub, + email: (payload.email as string) ?? null, + }; +} + +/** + * Extract Bearer token from Authorization header. + */ +export function extractToken(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith("Bearer ")) return null; + return authHeader.slice(7); +} diff --git a/apps/chat-worker/src/lib/supabase.ts b/apps/chat-worker/src/lib/supabase.ts new file mode 100644 index 0000000..f572bd0 --- /dev/null +++ b/apps/chat-worker/src/lib/supabase.ts @@ -0,0 +1,82 @@ +/** + * Thin PostgREST client using fetch — no Supabase SDK dependency. + * Used by both the Worker (history queries) and the Durable Object (message persistence). + */ +export class PostgREST { + private baseUrl: string; + private serviceRoleKey: string; + + constructor(supabaseUrl: string, serviceRoleKey: string) { + this.baseUrl = `${supabaseUrl}/rest/v1`; + this.serviceRoleKey = serviceRoleKey; + } + + private headers(): Record { + return { + "apikey": this.serviceRoleKey, + "Authorization": `Bearer ${this.serviceRoleKey}`, + "Content-Type": "application/json", + "Prefer": "return=representation", + }; + } + + /** Insert a row and return the inserted data. */ + async insert(table: string, data: Record): Promise { + const res = await fetch(`${this.baseUrl}/${table}`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST insert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Upsert a row (requires Prefer: resolution=merge-duplicates). */ + async upsert(table: string, data: Record, onConflict: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "return=representation,resolution=merge-duplicates"; + const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST upsert failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select rows with PostgREST query string. */ + async select(table: string, query: string): Promise { + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "GET", + headers: this.headers(), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`PostgREST select failed (${res.status}): ${body}`); + } + return res.json() as Promise; + } + + /** Select with exact count header for unread queries. */ + async count(table: string, query: string): Promise { + const headers = this.headers(); + headers["Prefer"] = "count=exact"; + headers["Range-Unit"] = "items"; + headers["Range"] = "0-0"; + const res = await fetch(`${this.baseUrl}/${table}?${query}`, { + method: "HEAD", + headers, + }); + const contentRange = res.headers.get("Content-Range"); + if (!contentRange) return 0; + // Content-Range format: "0-0/42" or "*/0" + const total = contentRange.split("/")[1]; + return total === "*" ? 0 : parseInt(total, 10); + } +} From 986b31eff0e429f504b8addc34febeb26440af96 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 12:01:24 +0200 Subject: [PATCH 06/62] feat(chat-worker): implement ChatRoom Durable Object with WebSocket hibernation Co-Authored-By: Claude Sonnet 4.6 --- .../src/durable-objects/ChatRoom.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 apps/chat-worker/src/durable-objects/ChatRoom.ts diff --git a/apps/chat-worker/src/durable-objects/ChatRoom.ts b/apps/chat-worker/src/durable-objects/ChatRoom.ts new file mode 100644 index 0000000..8c1982c --- /dev/null +++ b/apps/chat-worker/src/durable-objects/ChatRoom.ts @@ -0,0 +1,155 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env, ClientMessage, ServerMessage } from "../lib/types"; +import { PostgREST } from "../lib/supabase"; + +export class ChatRoom extends DurableObject { + private postgrest: PostgREST | null = null; + + private getPostgREST(): PostgREST { + if (!this.postgrest) { + this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY); + } + return this.postgrest; + } + + async handleWebSocket(request: Request, userId: string, channelId: string): Promise { + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + + const stored = await this.ctx.storage.get("channelId"); + if (!stored) { + await this.ctx.storage.put("channelId", channelId); + } + + this.ctx.acceptWebSocket(server, [userId]); + + this.broadcast({ + type: "presence.update", + userId, + status: "online", + }, server); + + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (!userId) { + ws.close(4001, "Missing user identity"); + return; + } + + let msg: ClientMessage; + try { + msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); + } catch { + this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" }); + return; + } + + switch (msg.type) { + case "message.send": + await this.handleSendMessage(ws, userId, msg.text, msg.clientId); + break; + case "typing.start": + this.broadcast({ type: "typing", userId, isTyping: true }, ws); + break; + case "typing.stop": + this.broadcast({ type: "typing", userId, isTyping: false }, ws); + break; + case "presence.ping": + break; + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const tags = this.ctx.getTags(ws); + const userId = tags[0]; + if (userId) { + const remaining = this.ctx.getWebSockets(userId); + if (remaining.length === 0) { + this.broadcast({ type: "presence.update", userId, status: "offline" }); + } + } + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + console.error("WebSocket error:", error); + ws.close(1011, "Internal error"); + } + + private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise { + if (!text || text.trim().length === 0) { + this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" }); + return; + } + + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const channelId = await this.getChannelId(); + + const serverMsg: ServerMessage = { + type: "message.new", + id, + userId, + text: text.trim(), + createdAt, + clientId, + }; + + this.broadcast(serverMsg); + this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt)); + } + + private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise { + const db = this.getPostgREST(); + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await db.insert("messages", { + id, + channel_id: channelId, + user_id: userId, + text, + created_at: createdAt, + }); + return; + } catch (error) { + console.error(`Message persist attempt ${attempt + 1} failed:`, error); + if (attempt < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); + } + } + } + console.error(`Failed to persist message ${id} after ${maxRetries} attempts`); + } + + private async getChannelId(): Promise { + const channelId = await this.ctx.storage.get("channelId"); + if (!channelId) throw new Error("channelId not stored in DO"); + return channelId; + } + + private sendTo(ws: WebSocket, msg: ServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection already closed + } + } + + private broadcast(msg: ServerMessage, exclude?: WebSocket): void { + const payload = JSON.stringify(msg); + for (const ws of this.ctx.getWebSockets()) { + if (ws !== exclude) { + try { + ws.send(payload); + } catch { + // Connection already closed + } + } + } + } +} From 2811e51109efd2e0c94e539cddd397540d0ac266 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 12:02:43 +0200 Subject: [PATCH 07/62] feat(chat-worker): add Hono entry point with WebSocket routing and REST endpoints --- apps/chat-worker/src/index.ts | 158 ++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 apps/chat-worker/src/index.ts diff --git a/apps/chat-worker/src/index.ts b/apps/chat-worker/src/index.ts new file mode 100644 index 0000000..ec03c56 --- /dev/null +++ b/apps/chat-worker/src/index.ts @@ -0,0 +1,158 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { ChatRoom } from "./durable-objects/ChatRoom"; +import { extractToken, verifyJwt } from "./lib/auth"; +import { PostgREST } from "./lib/supabase"; +import type { Env, ChatMessage, UnreadCount } from "./lib/types"; + +// Re-export DO class for wrangler +export { ChatRoom }; + +const app = new Hono<{ Bindings: Env }>(); + +// CORS — allow the main app origins +app.use("*", cors({ + origin: [ + "http://localhost:5173", + "https://app.xtablo.com", + "https://app-staging.xtablo.com", + ], + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], +})); + +// Auth middleware — extract and verify JWT for all routes +// For WebSocket upgrades, the token comes via query param (?token=...) since browsers +// cannot send custom headers on WebSocket connections. +// For REST requests, the token comes via the Authorization header. +app.use("*", async (c, next) => { + const isWebSocket = c.req.header("Upgrade") === "websocket"; + const token = isWebSocket + ? new URL(c.req.url).searchParams.get("token") + : extractToken(c.req.header("Authorization")); + + if (!token) { + return c.json({ error: "Missing authorization" }, 401); + } + try { + const auth = await verifyJwt(token, c.env.JWT_SECRET); + c.set("userId" as never, auth.userId); + } catch (error) { + return c.json({ error: "Invalid token" }, 401); + } + await next(); +}); + +// Helper: check tablo membership via PostgREST +async function checkMembership(db: PostgREST, channelId: string, userId: string): Promise { + const rows = await db.select<{ user_id: string }>( + "tablo_access", + `tablo_id=eq.${channelId}&user_id=eq.${userId}&is_active=eq.true&select=user_id&limit=1` + ); + return rows.length > 0; +} + +// WebSocket upgrade — route to Durable Object +app.get("/chat/ws/:channelId", async (c) => { + const upgradeHeader = c.req.header("Upgrade"); + if (upgradeHeader !== "websocket") { + return c.json({ error: "Expected WebSocket upgrade" }, 426); + } + + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const id = c.env.CHAT_ROOM.idFromName(channelId); + const stub = c.env.CHAT_ROOM.get(id); + return (stub as any).handleWebSocket(c.req.raw, userId, channelId); +}); + +// GET message history — paginated +app.get("/chat/channels/:channelId/messages", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + const isMember = await checkMembership(db, channelId, userId); + if (!isMember) { + return c.json({ error: "Not a member of this channel" }, 403); + } + + const before = c.req.query("before"); + const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 100); + + let query = `channel_id=eq.${channelId}&deleted_at=is.null&select=id,channel_id,user_id,text,created_at&order=created_at.desc&limit=${limit}`; + if (before) { + query += `&created_at=lt.${before}`; + } + + const messages = await db.select( + "messages", + query + ); + + return c.json({ messages: messages.reverse(), hasMore: messages.length === limit }); +}); + +// POST mark channel as read +app.post("/chat/channels/:channelId/read", async (c) => { + const channelId = c.req.param("channelId"); + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + await db.upsert("channel_read_state", { + user_id: userId, + channel_id: channelId, + last_read_at: new Date().toISOString(), + }, "user_id,channel_id"); + + return c.json({ ok: true }); +}); + +// GET unread counts for current user across all channels +app.get("/chat/unread", async (c) => { + const userId = c.get("userId" as never) as string; + const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY); + + // Get all tablos the user has access to + const accessRows = await db.select<{ tablo_id: string }>( + "tablo_access", + `user_id=eq.${userId}&is_active=eq.true&select=tablo_id` + ); + + if (accessRows.length === 0) { + return c.json({ unread: [] }); + } + + // For each channel, get unread count + const unread: UnreadCount[] = []; + + for (const { tablo_id } of accessRows) { + // Get last read time + const readState = await db.select<{ last_read_at: string }>( + "channel_read_state", + `user_id=eq.${userId}&channel_id=eq.${tablo_id}&select=last_read_at&limit=1` + ); + + const lastReadAt = readState[0]?.last_read_at ?? "1970-01-01T00:00:00Z"; + + const count = await db.count( + "messages", + `channel_id=eq.${tablo_id}&deleted_at=is.null&created_at=gt.${lastReadAt}` + ); + + if (count > 0) { + unread.push({ channel_id: tablo_id, unread_count: count }); + } + } + + return c.json({ unread }); +}); + +export default app; From 2833b4b2c12ec6ed9c3e66abba3156993814ec59 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 12:04:03 +0200 Subject: [PATCH 08/62] feat(chat): add useChat hook with WebSocket connection and reconnection --- apps/main/src/hooks/useChat.ts | 230 +++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 apps/main/src/hooks/useChat.ts diff --git a/apps/main/src/hooks/useChat.ts b/apps/main/src/hooks/useChat.ts new file mode 100644 index 0000000..1f5e9a2 --- /dev/null +++ b/apps/main/src/hooks/useChat.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; + +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + /** True while the message is only local (not yet echoed by server). */ + optimistic?: boolean; +} + +type ServerMessage = + | { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string } + | { type: "typing"; userId: string; isTyping: boolean } + | { type: "presence.update"; userId: string; status: "online" | "offline" } + | { type: "error"; code: string; message: string }; + +const CHAT_WS_BASE = import.meta.env.VITE_CHAT_WS_URL as string; +const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string; + +export function useChat(channelId: string | undefined) { + const { session } = useSession(); + const token = session?.access_token; + + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [typingUsers, setTypingUsers] = useState([]); + const [onlineUsers, setOnlineUsers] = useState([]); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef>(); + const typingTimerRef = useRef>(); + const isTypingRef = useRef(false); + + // Fetch message history from REST endpoint + const fetchHistory = useCallback(async (before?: string) => { + if (!channelId || !token) return; + + const params = new URLSearchParams({ limit: "50" }); + if (before) params.set("before", before); + + const res = await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/messages?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) return; + + const data = await res.json() as { messages: ChatMessage[]; hasMore: boolean }; + setHasMoreMessages(data.hasMore); + + if (before) { + // Prepend older messages + setMessages((prev) => [...data.messages, ...prev]); + } else { + // Initial load + setMessages(data.messages); + } + }, [channelId, token]); + + // Load more (pagination) + const loadMoreMessages = useCallback(() => { + if (messages.length === 0 || !hasMoreMessages) return; + const oldest = messages[0]; + fetchHistory(oldest.createdAt); + }, [messages, hasMoreMessages, fetchHistory]); + + // WebSocket connection management + useEffect(() => { + if (!channelId || !token) return; + + const connect = () => { + // Token passed via query param because browsers cannot send custom headers on WS connections + const wsUrl = `${CHAT_WS_BASE}/chat/ws/${channelId}?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + setIsConnected(true); + reconnectAttemptRef.current = 0; + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as ServerMessage; + + switch (msg.type) { + case "message.new": + setMessages((prev) => { + // Deduplicate: replace optimistic message with server version + const withoutOptimistic = prev.filter( + (m) => !(m.clientId === msg.clientId && m.optimistic) + ); + // Avoid duplicate if message already received + if (withoutOptimistic.some((m) => m.id === msg.id)) { + return withoutOptimistic; + } + return [...withoutOptimistic, { + id: msg.id, + userId: msg.userId, + text: msg.text, + createdAt: msg.createdAt, + clientId: msg.clientId, + }]; + }); + break; + + case "typing": + setTypingUsers((prev) => + msg.isTyping + ? prev.includes(msg.userId) ? prev : [...prev, msg.userId] + : prev.filter((id) => id !== msg.userId) + ); + break; + + case "presence.update": + setOnlineUsers((prev) => + msg.status === "online" + ? prev.includes(msg.userId) ? prev : [...prev, msg.userId] + : prev.filter((id) => id !== msg.userId) + ); + break; + + case "error": + console.error("Chat error:", msg.code, msg.message); + break; + } + }; + + ws.onclose = () => { + setIsConnected(false); + wsRef.current = null; + + // Exponential backoff reconnect + const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000); + reconnectAttemptRef.current++; + reconnectTimerRef.current = setTimeout(connect, delay); + }; + + ws.onerror = () => { + ws.close(); + }; + + wsRef.current = ws; + }; + + // Load initial history then connect WebSocket + fetchHistory().then(connect); + + return () => { + clearTimeout(reconnectTimerRef.current); + clearTimeout(typingTimerRef.current); + wsRef.current?.close(); + wsRef.current = null; + setMessages([]); + setIsConnected(false); + setTypingUsers([]); + setOnlineUsers([]); + }; + }, [channelId, token, fetchHistory]); + + // Send message + const sendMessage = useCallback((text: string) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + const clientId = crypto.randomUUID(); + + // Optimistic update + setMessages((prev) => [ + ...prev, + { + id: `optimistic-${clientId}`, + userId: session?.user?.id ?? "", + text, + createdAt: new Date().toISOString(), + clientId, + optimistic: true, + }, + ]); + + wsRef.current.send(JSON.stringify({ type: "message.send", text, clientId })); + + // Stop typing when sending + if (isTypingRef.current) { + wsRef.current.send(JSON.stringify({ type: "typing.stop" })); + isTypingRef.current = false; + clearTimeout(typingTimerRef.current); + } + }, [session?.user?.id]); + + // Typing indicator + const sendTyping = useCallback(() => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + + if (!isTypingRef.current) { + isTypingRef.current = true; + wsRef.current.send(JSON.stringify({ type: "typing.start" })); + } + + clearTimeout(typingTimerRef.current); + typingTimerRef.current = setTimeout(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "typing.stop" })); + } + isTypingRef.current = false; + }, 2000); + }, []); + + // Mark as read + const markAsRead = useCallback(async () => { + if (!channelId || !token) return; + await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/read`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + }, [channelId, token]); + + return { + messages, + sendMessage, + sendTyping, + isConnected, + typingUsers, + onlineUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + }; +} From db59316dc39287d83022d3fd762d7e5ba461e61c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 12:04:27 +0200 Subject: [PATCH 09/62] feat(chat): add useChatUnread hook for polling unread counts --- apps/main/src/hooks/useChatUnread.ts | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/main/src/hooks/useChatUnread.ts diff --git a/apps/main/src/hooks/useChatUnread.ts b/apps/main/src/hooks/useChatUnread.ts new file mode 100644 index 0000000..ff7f806 --- /dev/null +++ b/apps/main/src/hooks/useChatUnread.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; + +const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string; + +interface UnreadCount { + channel_id: string; + unread_count: number; +} + +export function useChatUnread() { + const { session } = useSession(); + const token = session?.access_token; + + const { data } = useQuery({ + queryKey: ["chat-unread"], + queryFn: async (): Promise => { + const res = await fetch(`${CHAT_API_BASE}/chat/unread`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return []; + const json = await res.json() as { unread: UnreadCount[] }; + return json.unread; + }, + enabled: !!token, + refetchInterval: 30_000, + refetchOnWindowFocus: true, + }); + + return { + unreadCounts: data ?? [], + getUnreadCount: (channelId: string) => + data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0, + hasUnread: (channelId: string) => + (data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0) > 0, + }; +} From bb0aa5e28ea1ba6300bfd3a021520f9ba1af8d88 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 13:27:46 +0200 Subject: [PATCH 10/62] 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 From 54a13c3c30a5f346f22eaa73af29c0e8c56ef434 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 13:28:12 +0200 Subject: [PATCH 11/62] =?UTF-8?q?feat(chat):=20update=20env=20vars=20?= =?UTF-8?q?=E2=80=94=20replace=20Stream=20API=20key=20with=20chat=20worker?= =?UTF-8?q?=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/.env.production | 3 ++- apps/main/.env.staging | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/main/.env.production b/apps/main/.env.production index cf7799d..eec9fa3 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -4,7 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx" +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpXuXgpirUWOj24dnb7DzbcEAu45nU1S5k66Nm4liY3IlGOW00pndRsgUM VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF diff --git a/apps/main/.env.staging b/apps/main/.env.staging index dfcb28d..57c4288 100644 --- a/apps/main/.env.staging +++ b/apps/main/.env.staging @@ -4,7 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" +VITE_CHAT_WS_URL=wss://chat-staging.xtablo.com +VITE_CHAT_API_URL=https://chat-staging.xtablo.com VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0 VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva From 37a94ef2b37d210d95ebb1918a9bce59884e96bc Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 13:44:30 +0200 Subject: [PATCH 12/62] refactor(api): remove all Stream Chat dependencies and operations Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/package.json | 1 - .../__tests__/config/stripe-config.test.ts | 2 - .../__tests__/middlewares/middlewares.test.ts | 26 --- apps/api/src/__tests__/routes/invite.test.ts | 40 ---- apps/api/src/__tests__/routes/tablo.test.ts | 60 +----- apps/api/src/__tests__/routes/tasks.test.ts | 28 +-- apps/api/src/__tests__/routes/user.test.ts | 94 +--------- apps/api/src/config.ts | 9 - apps/api/src/helpers/helpers.ts | 8 - apps/api/src/middlewares/middleware.ts | 18 -- apps/api/src/routers/index.ts | 1 - apps/api/src/routers/invite.ts | 30 --- apps/api/src/routers/tablo.ts | 177 +----------------- apps/api/src/routers/tasks.ts | 13 -- apps/api/src/routers/user.ts | 51 +---- apps/api/src/secrets.ts | 4 - apps/api/src/types/app.types.ts | 2 - 17 files changed, 10 insertions(+), 554 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 31fe087..5c18feb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,7 +33,6 @@ "multer": "^2.0.2", "nodemailer": "^7.0.4", "sharp": "^0.34.5", - "stream-chat": "^9.8.0", "stripe": "^20.0.0", "ts-node": "^10.9.2" }, diff --git a/apps/api/src/__tests__/config/stripe-config.test.ts b/apps/api/src/__tests__/config/stripe-config.test.ts index e8f9ca6..fdc130b 100644 --- a/apps/api/src/__tests__/config/stripe-config.test.ts +++ b/apps/api/src/__tests__/config/stripe-config.test.ts @@ -10,10 +10,8 @@ const baseSecrets: Secrets = { emailRefreshToken: "email-refresh-token", r2AccessKeyId: "r2-access-key-id", r2SecretAccessKey: "r2-secret-access-key", - streamChatApiSecret: "stream-chat-api-secret", stripeSecretKey: "sk_live_secret_manager", stripeWebhookSecret: "whsec_live_secret_manager", - streamChatApiSecretStaging: "stream-chat-api-secret-staging", stripeSecretKeyStaging: "sk_live_staging_secret_manager", stripeWebhookSecretStaging: "whsec_live_staging_secret_manager", }; diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index c18bf7e..489506f 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -427,26 +427,6 @@ describe("Middleware Tests", () => { }); }); - describe("StreamChat Middleware", () => { - it("should inject StreamChat client into context", async () => { - const app = new Hono(); - app.use(middlewareManager.streamChat); - app.get("/test", (c) => { - const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests - (c as any).get("streamServerClient"); - return c.json({ hasStreamClient: !!streamClient }); - }); - - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access - const client = testClient(app) as any; - const res = await client.test.$get(); - const data = await res.json(); - - expect(res.status).toBe(200); - expect(data.hasStreamClient).toBe(true); - }); - }); - describe("R2 Middleware", () => { it("should inject S3 client into context", async () => { const app = new Hono(); @@ -531,18 +511,14 @@ describe("Middleware Tests", () => { it("should chain multiple middlewares correctly", async () => { const app = new Hono(); app.use(middlewareManager.supabase); - app.use(middlewareManager.streamChat); app.use(middlewareManager.stripe); app.get("/test", (c) => { const supabase = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests (c as any).get("supabase"); - const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests - (c as any).get("streamServerClient"); const stripe = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests (c as any).get("stripe"); return c.json({ hasSupabase: !!supabase, - hasStreamClient: !!streamClient, hasStripe: !!stripe, }); }); @@ -554,7 +530,6 @@ describe("Middleware Tests", () => { expect(res.status).toBe(200); expect(data.hasSupabase).toBe(true); - expect(data.hasStreamClient).toBe(true); expect(data.hasStripe).toBe(true); }); @@ -562,7 +537,6 @@ describe("Middleware Tests", () => { const app = new Hono(); app.use(middlewareManager.supabase); app.use(middlewareManager.auth); // This will fail - app.use(middlewareManager.streamChat); // This should not execute app.get("/test", (c) => c.json({ success: true })); // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access diff --git a/apps/api/src/__tests__/routes/invite.test.ts b/apps/api/src/__tests__/routes/invite.test.ts index 8fd7f5f..4f8fb8c 100644 --- a/apps/api/src/__tests__/routes/invite.test.ts +++ b/apps/api/src/__tests__/routes/invite.test.ts @@ -1,31 +1,11 @@ import { createClient } from "@supabase/supabase-js"; import { testClient } from "hono/testing"; -import type { Channel, StreamChat } from "stream-chat"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import { getTestUser } from "../helpers/dbSetup.js"; -// Mock the stream-chat module -vi.mock("stream-chat", () => { - const mockChannel = { - create: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), - }; - - const mockStreamChatInstance = { - channel: vi.fn(() => mockChannel), - upsertUser: vi.fn().mockResolvedValue({ users: {} }), - }; - - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstance), - }, - }; -}); - // Mock nodemailer const mockSendMail = vi.fn(); vi.mock("nodemailer", () => ({ @@ -54,16 +34,7 @@ describe("Booking Endpoint", () => { const createdTablos: string[] = []; const createdUsers: string[] = []; - // Get references to the mocked functions for assertions - let mockStreamChat: StreamChat; - let mockChannel: Channel; - beforeAll(async () => { - // Get references to the mocked instances - const { StreamChat } = await import("stream-chat"); - mockStreamChat = StreamChat.getInstance("test_api_key", "test_api_secret"); - mockChannel = mockStreamChat.channel("messaging", "test_channel_id"); - // Get owner's short_user_id const { data: ownerProfile } = await supabase .from("profiles") @@ -324,10 +295,6 @@ describe("Booking Endpoint", () => { createdUsers.push(userProfile.id); } - // Verify Stream Chat channel was created - expect(mockChannel.create).toHaveBeenCalledTimes(1); - expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1); - // Verify emails were sent (3 emails: welcome to new user, one to owner, one to booker) expect(mockSendMail).toHaveBeenCalledTimes(3); @@ -407,10 +374,6 @@ describe("Booking Endpoint", () => { createdTablos.push(data.tablo_id); createdBookings.push(data.tablo_id); - // Verify Stream Chat channel was created - expect(mockChannel.create).toHaveBeenCalledTimes(1); - expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1); - // Verify emails were sent (2 emails: one to owner, one to booker) expect(mockSendMail).toHaveBeenCalledTimes(2); @@ -511,9 +474,6 @@ describe("Booking Endpoint", () => { expect(data2.tablo_id).toBe(firstTabloId); expect(data2.hasCreatedAccount).toBe(false); - // Stream Chat channel should still be created for the second booking - expect(mockChannel.create).toHaveBeenCalledTimes(1); - // Verify emails were sent for second booking (2 emails) expect(mockSendMail).toHaveBeenCalledTimes(2); diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index f8c7c7b..b445c90 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -8,35 +8,6 @@ import { getMainRouter } from "../../routers/index.js"; import type { TestUserData } from "../helpers/dbSetup.js"; import { getTestUser } from "../helpers/dbSetup.js"; -// Mock Stream Chat operations -const mockChannelCreate = vi.fn(); -const mockChannelUpdate = vi.fn(); -const mockChannelDelete = vi.fn(); -const mockChannelRemoveMembers = vi.fn(); -const mockChannelAddMembers = vi.fn(); - -// Mock the channel method to return our mocked channel -const mockChannel = { - create: mockChannelCreate, - update: mockChannelUpdate, - delete: mockChannelDelete, - removeMembers: mockChannelRemoveMembers, - addMembers: mockChannelAddMembers, -}; - -// Mock the stream-chat module -vi.mock("stream-chat", () => { - const mockStreamChatInstance = { - channel: vi.fn(() => mockChannel), - }; - - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstance), - }, - }; -}); - // Mock nodemailer for email sending const mockSendMail = vi.fn(); vi.mock("nodemailer", () => ({ @@ -67,11 +38,6 @@ describe("Tablo Endpoint", () => { beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); - mockChannelCreate.mockResolvedValue(undefined); - mockChannelUpdate.mockResolvedValue(undefined); - mockChannelDelete.mockResolvedValue(undefined); - mockChannelRemoveMembers.mockResolvedValue(undefined); - mockChannelAddMembers.mockResolvedValue(undefined); mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); }); @@ -195,7 +161,7 @@ describe("Tablo Endpoint", () => { await supabaseAdmin.from("profiles").update({ plan: "standard" }).eq("id", ownerUser.userId); }); - it("should allow owner to create a tablo and create a Stream Chat channel", async () => { + it("should allow owner to create a tablo", async () => { const res = await createTabloRequest(ownerUser, client, { name: "New Owner Tablo", status: "todo", @@ -205,11 +171,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo created successfully"); - - // Verify Stream Chat channel was created - expect(mockChannelCreate).toHaveBeenCalledTimes(1); - // Verify it was called (the channel is created with tablo data) - expect(mockChannelCreate).toHaveBeenCalled(); }); it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => { @@ -323,7 +284,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toBe("You have reached your tablo limit"); - expect(mockChannelCreate).not.toHaveBeenCalled(); } finally { await supabaseAdmin .from("profiles") @@ -392,17 +352,13 @@ describe("Tablo Endpoint", () => { }); describe("DELETE /tablos/delete - Delete Tablo", () => { - it("should allow owner with admin access to delete tablo and delete Stream Chat channel", async () => { + it("should allow owner with admin access to delete tablo", async () => { // Owner has admin access to their tablos const res = await deleteTabloRequest(ownerUser, client, "test_tablo_owner_private"); expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo deleted successfully"); - - // Verify Stream Chat channel was deleted - expect(mockChannelDelete).toHaveBeenCalledTimes(1); - expect(mockChannelDelete).toHaveBeenCalled(); }); it("should deny temp user without admin access from deleting tablo", async () => { @@ -558,7 +514,7 @@ describe("Tablo Endpoint", () => { return tabloId; }; - it("should allow temp user to leave a shared tablo and remove from Stream Chat channel", async () => { + it("should allow temp user to leave a shared tablo", async () => { const tabloId = await createSharedTabloForLeaveTest({ ownerId: ownerUser.userId, memberId: temporaryUser.userId, @@ -569,13 +525,9 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo left successfully"); - - // Verify Stream Chat channel removeMembers was called - expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1); - expect(mockChannelRemoveMembers).toHaveBeenCalledWith([temporaryUser.userId]); }); - it("should allow owner to leave a tablo and remove from Stream Chat channel", async () => { + it("should allow owner to leave a tablo", async () => { const tabloId = await createSharedTabloForLeaveTest({ ownerId: temporaryUser.userId, memberId: ownerUser.userId, @@ -587,10 +539,6 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.message).toBe("Tablo left successfully"); - - // Verify Stream Chat channel removeMembers was called - expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1); - expect(mockChannelRemoveMembers).toHaveBeenCalledWith([ownerUser.userId]); }); it("should deny unauthenticated leave request", async () => { diff --git a/apps/api/src/__tests__/routes/tasks.test.ts b/apps/api/src/__tests__/routes/tasks.test.ts index e6d320e..fc1bbd3 100644 --- a/apps/api/src/__tests__/routes/tasks.test.ts +++ b/apps/api/src/__tests__/routes/tasks.test.ts @@ -6,27 +6,6 @@ import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; -// Mock Stream Chat operations -const mockChannelUpdate = vi.fn(); - -// Mock the channel method to return our mocked channel -const mockChannel = { - update: mockChannelUpdate, -}; - -// Mock the stream-chat module -vi.mock("stream-chat", () => { - const mockStreamChatInstance = { - channel: vi.fn(() => mockChannel), - }; - - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstance), - }, - }; -}); - // Create S3 mock for calendar file operations const s3Mock = mockClient(S3Client); @@ -45,9 +24,6 @@ describe("Tasks Endpoint", () => { // Mock PutObjectCommand for calendar file writes s3Mock.on(PutObjectCommand).resolves({}); - - // Mock Stream Chat channel update - mockChannelUpdate.mockResolvedValue(undefined); }); describe("POST /tasks/sync-calendars - Sync Calendar Files", () => { @@ -107,8 +83,8 @@ describe("Tasks Endpoint", () => { }); }); - describe("POST /tasks/sync-tablo-names - Sync Tablo Names to Stream", () => { - it("should call sync tablo names endpoint with basic auth and update Stream Chat channels (returns 200 if TASKS_SECRET properly configured)", async () => { + describe("POST /tasks/sync-tablo-names - Sync Tablo Names", () => { + it("should call sync tablo names endpoint with basic auth (returns 200 if TASKS_SECRET properly configured)", async () => { const res = await client.tasks["sync-tablo-names"].$post( {}, { diff --git a/apps/api/src/__tests__/routes/user.test.ts b/apps/api/src/__tests__/routes/user.test.ts index 0567f9e..753c550 100644 --- a/apps/api/src/__tests__/routes/user.test.ts +++ b/apps/api/src/__tests__/routes/user.test.ts @@ -12,25 +12,6 @@ import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import { getTestUser } from "../helpers/dbSetup.js"; -// Mock Stream Chat operations -const mockUpsertUser = vi.fn(); -const mockCreateToken = vi.fn(); - -// Create an instance object that holds the mocks (like the working pattern in tablo.test.ts) -const mockStreamChatInstanceMethods = { - upsertUser: mockUpsertUser, - createToken: mockCreateToken, -}; - -// Mock the stream-chat module -vi.mock("stream-chat", () => { - return { - StreamChat: { - getInstance: vi.fn(() => mockStreamChatInstanceMethods), - }, - }; -}); - // Create S3 mock for avatar operations const s3Mock = mockClient(S3Client); @@ -50,10 +31,6 @@ describe("User Endpoint", () => { vi.clearAllMocks(); s3Mock.reset(); - // Mock Stream Chat operations - mockUpsertUser.mockResolvedValue({ users: { [ownerUser.userId]: {} } }); - mockCreateToken.mockReturnValue("mock-stream-token-123"); - // Mock S3 operations s3Mock.on(PutObjectCommand).resolves({}); s3Mock.on(ListObjectsV2Command).resolves({ @@ -63,7 +40,7 @@ describe("User Endpoint", () => { }); describe("GET /me - Get User Profile", () => { - it("should return owner user profile with stream token", async () => { + it("should return owner user profile", async () => { const res = await client.users.me.$get( {}, { @@ -78,14 +55,9 @@ describe("User Endpoint", () => { const data = await res.json(); expect(data.id).toBe(ownerUser.userId); expect(data.email).toBe(ownerUser.email); - expect(data.streamToken).toBe("mock-stream-token-123"); - - // Verify Stream Chat createToken was called - expect(mockCreateToken).toHaveBeenCalledTimes(1); - expect(mockCreateToken).toHaveBeenCalledWith(ownerUser.userId); }); - it("should return temp user profile with stream token", async () => { + it("should return temp user profile", async () => { const res = await client.users.me.$get( {}, { @@ -100,11 +72,6 @@ describe("User Endpoint", () => { const data = await res.json(); expect(data.id).toBe(temporaryUser.userId); expect(data.email).toBe(temporaryUser.email); - expect(data.streamToken).toBe("mock-stream-token-123"); - - // Verify Stream Chat createToken was called - expect(mockCreateToken).toHaveBeenCalledTimes(1); - expect(mockCreateToken).toHaveBeenCalledWith(temporaryUser.userId); }); it("should deny unauthenticated access", async () => { @@ -114,63 +81,6 @@ describe("User Endpoint", () => { }); }); - describe("POST /sign-up-to-stream - Sign Up User to Stream Chat", () => { - it("should sign up owner user to stream chat", async () => { - const res = await client.users["sign-up-to-stream"].$post( - {}, - { - headers: { - Authorization: `Bearer ${ownerUser.accessToken}`, - "Content-Type": "application/json", - }, - } - ); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.message).toBe("User signed up to stream"); - - // Verify Stream Chat upsertUser was called - expect(mockUpsertUser).toHaveBeenCalledTimes(1); - expect(mockUpsertUser).toHaveBeenCalledWith({ - id: ownerUser.userId, - name: expect.any(String), - language: "fr", - }); - }); - - it("should sign up temp user to stream chat", async () => { - const res = await client.users["sign-up-to-stream"].$post( - {}, - { - headers: { - Authorization: `Bearer ${temporaryUser.accessToken}`, - "Content-Type": "application/json", - }, - } - ); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.message).toBe("User signed up to stream"); - - // Verify Stream Chat upsertUser was called - expect(mockUpsertUser).toHaveBeenCalledTimes(1); - expect(mockUpsertUser).toHaveBeenCalledWith({ - id: temporaryUser.userId, - name: expect.any(String), - language: "fr", - }); - }); - - it("should deny unauthenticated stream signup", async () => { - const res = await client.users["sign-up-to-stream"].$post({}); - - expect(res.status).toBe(401); - expect(mockUpsertUser).not.toHaveBeenCalled(); - }); - }); - describe("POST /profile/avatar - Upload Avatar", () => { it("should upload avatar for owner user", async () => { const res = await client.users.profile.avatar.$post( diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index d7c5b14..38f74f0 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -8,8 +8,6 @@ export interface AppConfig { SUPABASE_SERVICE_ROLE_KEY: string; SUPABASE_CONNECTION_STRING: string; SUPABASE_CA_CERT: string; - STREAM_CHAT_API_KEY: string; - STREAM_CHAT_API_SECRET: string; STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string; STRIPE_SOLO_PRICE_ID: string; @@ -59,8 +57,6 @@ export function createConfig(secrets?: Secrets): AppConfig { const isTestMode = NODE_ENV === "test"; const isStagingMode = NODE_ENV === "staging"; - const getStreamChatApiSecret = (isStagingMode: boolean) => - isStagingMode ? secrets!.streamChatApiSecretStaging : secrets!.streamChatApiSecret; const getStripeSecretKey = (isStagingMode: boolean) => isStagingMode ? secrets!.stripeSecretKeyStaging : secrets!.stripeSecretKey; const getStripeWebhookSecret = (isStagingMode: boolean) => @@ -82,11 +78,6 @@ export function createConfig(secrets?: Secrets): AppConfig { SUPABASE_CA_CERT: isTestMode ? validateEnvVar("SUPABASE_CA_CERT", process.env.SUPABASE_CA_CERT) : secrets!.supabaseCaCert, - STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY), - // Env dependent - STREAM_CHAT_API_SECRET: isTestMode - ? validateEnvVar("STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET) - : getStreamChatApiSecret(isStagingMode), STRIPE_SECRET_KEY: isTestMode ? validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY) : getStripeSecretKeyFromEnv() || getStripeSecretKey(isStagingMode), diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index eba5984..25ec729 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -3,7 +3,6 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { EventAndTablo } from "@xtablo/shared-types"; import type { Context, Next } from "hono"; import type { Transporter } from "nodemailer"; -import type { StreamChat } from "stream-chat"; import { generatePassword } from "./token.js"; export const MAX_TABLO_LIMIT = 10; @@ -290,7 +289,6 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => { */ export const createInvitedUser = async ( supabase: SupabaseClient, - streamServerClient: StreamChat, transporter: Transporter, recipientEmail: string, senderEmail: string, @@ -334,12 +332,6 @@ export const createInvitedUser = async ( return { success: false, error: updateProfileError.message }; } - await streamServerClient.upsertUser({ - id: newUser.user.id, - name: recipientEmail.split("@")[0], - language: "fr", - }); - // Send welcome email to the new user await transporter.sendMail({ from: `${senderEmail} via XTablo `, diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 989e670..773a20b 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -4,7 +4,6 @@ import { createClient, type SupabaseClient, type User } from "@supabase/supabase import type { Context, MiddlewareHandler, Next } from "hono"; import { createMiddleware } from "hono/factory"; import type { Transporter } from "nodemailer"; -import { StreamChat } from "stream-chat"; import { Stripe } from "stripe"; import { type AppConfig } from "../config.js"; import { authenticateFromHeader } from "../helpers/auth.js"; @@ -25,9 +24,6 @@ export type Middlewares = { Variables: { supabase: SupabaseClient; user: User }; Bindings: { user: User }; }>; - streamChatMiddleware: MiddlewareHandler<{ - Variables: { streamServerClient: StreamChat }; - }>; r2Middleware: MiddlewareHandler<{ Variables: { s3_client: S3Client }; }>; @@ -168,15 +164,6 @@ export class MiddlewareManager { await next(); }); - const streamChatMiddleware = createMiddleware(async (c: Context, next: Next) => { - const serverClient = StreamChat.getInstance( - config.STREAM_CHAT_API_KEY, - config.STREAM_CHAT_API_SECRET - ); - c.set("streamServerClient", serverClient); - await next(); - }); - const r2Middleware = createMiddleware(async (c: Context, next: Next) => { const s3 = new S3Client({ region: "auto", @@ -255,7 +242,6 @@ export class MiddlewareManager { basicAuthMiddleware, authMiddleware, maybeAuthenticatedMiddleware, - streamChatMiddleware, r2Middleware, regularUserCheckMiddleware, billingCheckoutAccessMiddleware, @@ -282,10 +268,6 @@ export class MiddlewareManager { return this.middlewares.maybeAuthenticatedMiddleware; } - get streamChat() { - return this.middlewares.streamChatMiddleware; - } - get r2() { return this.middlewares.r2Middleware; } diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts index ab4d2a8..1ca996e 100644 --- a/apps/api/src/routers/index.ts +++ b/apps/api/src/routers/index.ts @@ -17,7 +17,6 @@ export const getMainRouter = (config: AppConfig) => { mainRouter.use(middlewareManager.supabase); // Apply remaining middlewares after public routes - mainRouter.use(middlewareManager.streamChat); mainRouter.use(middlewareManager.r2); mainRouter.use(middlewareManager.transporter); mainRouter.use(middlewareManager.stripe); diff --git a/apps/api/src/routers/invite.ts b/apps/api/src/routers/invite.ts index b48c440..b2b47b2 100644 --- a/apps/api/src/routers/invite.ts +++ b/apps/api/src/routers/invite.ts @@ -9,7 +9,6 @@ const factory = createFactory(); const bookSlot = factory.createHandlers(async (c) => { const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const transporter = c.get("transporter"); const maybeUser = c.get("user"); @@ -55,7 +54,6 @@ const bookSlot = factory.createHandlers(async (c) => { // Create a temporary user for the booking const result = await createInvitedUser( supabase, - streamServerClient, transporter, data.user_details.email, ownerData.email, @@ -220,28 +218,6 @@ const bookSlot = factory.createHandlers(async (c) => { return c.json({ error: tabloAccessError.message }, 500); } - // Create Stream chat channel with the owner as creator - const { data: organizationMembers, error: organizationMembersError } = await supabase - .from("profiles") - .select("id") - .eq("organization_id", ownerOrganizationId); - - if (organizationMembersError) { - return c.json({ error: "Failed to load organization members" }, 500); - } - - const channelMembers = Array.from( - new Set((organizationMembers || []).map((member) => member.id).concat(bookerUserDataTyped.id)) - ); - - const channel = streamServerClient.channel("messaging", tabloData.id, { - // @ts-ignore - name: tabloData.name, - created_by_id: ownerId, - members: channelMembers, - }); - await channel.create(); - const newEvent: TablesInsert<"events"> = { description: eventTypeConfig.description || "", end_time: data.event_details.end_time || "", @@ -258,12 +234,6 @@ const bookSlot = factory.createHandlers(async (c) => { return c.json({ error: "Failed to create event" }, 500); } - // Send a welcome message to the channel - await channel.sendMessage({ - text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${newEvent.title}" est confirmé pour le ${newEvent.start_date} de ${newEvent.start_time} à ${newEvent.end_time}.`, - user_id: ownerId, - }); - // Send email notifications to both owner and invited user // Send email to the owner await transporter.sendMail({ diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 43f6ace..5ce02b7 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -18,83 +18,6 @@ type PostTablo = Omit & { const factory = createFactory(); -const isAlreadyMemberError = (error: unknown): boolean => { - if (!error) return false; - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - return ( - message.includes("already a member") || - message.includes("already member") || - message.includes("member already exists") - ); -}; - -const upsertStreamUserFromProfile = async ( - supabase: AuthEnv["Variables"]["supabase"], - streamServerClient: AuthEnv["Variables"]["streamServerClient"], - userId: string -) => { - const { data: profile } = await supabase - .from("profiles") - .select("name") - .eq("id", userId) - .maybeSingle(); - - await streamServerClient.upsertUser({ - id: userId, - name: profile?.name ?? "", - language: "fr", - }); -}; - -const ensureTabloChannelMember = async ( - supabase: AuthEnv["Variables"]["supabase"], - streamServerClient: AuthEnv["Variables"]["streamServerClient"], - tabloId: string, - userId: string -) => { - const channel = streamServerClient.channel("messaging", tabloId); - - try { - await channel.addMembers([userId]); - return; - } catch (error) { - if (isAlreadyMemberError(error)) { - return; - } - } - - const { data: tablo } = await supabase - .from("tablos") - .select("name, owner_id") - .eq("id", tabloId) - .maybeSingle(); - - const { data: accessRows } = await supabase - .from("tablo_access") - .select("user_id") - .eq("tablo_id", tabloId) - .eq("is_active", true); - - const members = Array.from(new Set((accessRows || []).map((row) => row.user_id).concat(userId))); - - const channelToCreate = streamServerClient.channel("messaging", tabloId, { - // @ts-ignore - name: tablo?.name ?? "Tablo", - created_by_id: tablo?.owner_id ?? userId, - members, - }); - - try { - await channelToCreate.create(); - } catch (error) { - if (isAlreadyMemberError(error)) { - return; - } - - await channel.addMembers([userId]); - } -}; - const createTablo = (middlewareManager: ReturnType) => factory.createHandlers( middlewareManager.regularUserCheck, @@ -134,28 +57,6 @@ const createTablo = (middlewareManager: ReturnType; - const { data: organizationMembers, error: membersError } = await supabase - .from("profiles") - .select("id") - .eq("organization_id", profile.organization_id); - - if (membersError) { - return c.json({ error: "Failed to load organization members" }, 500); - } - - const channelMembers = Array.from( - new Set((organizationMembers || []).map((member) => member.id).concat(user.id)) - ); - - const streamServerClient = c.get("streamServerClient"); - const channel = streamServerClient.channel("messaging", tabloData.id, { - // @ts-ignore - name: tabloData.name, - created_by_id: user.id, - members: channelMembers, - }); - await channel.create(); - if (typedPayload.events) { const eventsToInsert = typedPayload.events.map((event) => ({ ...event, @@ -173,7 +74,6 @@ const updateTablo = (middlewareManager: ReturnType { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const data = await c.req.json(); const { id, ...tablo } = data; @@ -190,7 +90,7 @@ const updateTablo = (middlewareManager: ReturnType; - const isUpdatingName = tablo.name !== undefined; - - if (isUpdatingName) { - const channel = streamServerClient.channel("messaging", updatedTablo.id); - try { - await channel.update({ - // @ts-ignore - name: updatedTablo.name, - }); - } catch (error) { - console.error("error updating channel", error); - } - } - return c.json({ message: "Tablo updated successfully" }); }); const deleteTablo = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const data = await c.req.json(); const { id } = data; @@ -270,13 +154,6 @@ const deleteTablo = factory.createHandlers(async (c) => { return c.json({ error: error.message }, 500); } - const channel = streamServerClient.channel("messaging", id); - try { - await channel.delete(); - } catch (error) { - console.error("error deleting channel", error); - } - return c.json({ message: "Tablo deleted successfully" }); }); @@ -288,7 +165,6 @@ const inviteToTablo = ( const transporter = c.get("transporter"); const sender = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const tabloId = c.req.param("tabloId"); const { email: recipientmail } = await c.req.json(); @@ -355,7 +231,6 @@ const inviteToTablo = ( // Create a new invited user and add them to the tablo const result = await createInvitedUser( supabase, - streamServerClient, transporter, recipientEmail, sender.email, @@ -381,13 +256,6 @@ const inviteToTablo = ( return c.json({ error: tabloAccessError.message }, 500); } - try { - await ensureTabloChannelMember(supabase, streamServerClient, tabloId, result.userId); - } catch (streamError) { - console.error("error adding temporary invited user to channel", streamError); - return c.json({ error: "Failed to sync chat access for invited user" }, 500); - } - return c.json({ message: "User created and invite sent successfully", }); @@ -438,7 +306,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const tabloId = c.req.param("tabloId"); const inviteId = Number(c.req.param("inviteId")); @@ -513,13 +380,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const inviteId = Number(c.req.param("inviteId")); if (!Number.isInteger(inviteId) || inviteId <= 0) { @@ -598,13 +457,6 @@ const acceptInviteById = (middlewareManager: ReturnType { const joiner = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const { data: inviteData, error } = await supabase .from("tablo_invites") @@ -657,13 +501,6 @@ const joinTablo = factory.createHandlers(async (c) => { const { id: invite_id, tablo_id, invited_by } = inviteData; - try { - await upsertStreamUserFromProfile(supabase, streamServerClient, joiner.id); - } catch (error) { - console.error("error upserting joining user to stream", error); - return c.json({ error: "Failed to provision chat user" }, 500); - } - const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ tablo_id, user_id: joiner.id, @@ -686,13 +523,6 @@ const joinTablo = factory.createHandlers(async (c) => { // Mark invite as accepted instead of deleting (maintains audit trail) await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id); - try { - await ensureTabloChannelMember(supabase, streamServerClient, tablo_id, joiner.id); - } catch (error) { - console.error("error adding member to channel", error); - return c.json({ error: "Failed to sync chat access for this tablo" }, 500); - } - return c.json({ tablo_id }); }); @@ -748,12 +578,8 @@ const getTabloMembers = factory.createHandlers(async (c) => { const leaveTablo = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const { tablo_id } = await c.req.json(); - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.removeMembers([user.id]); - const { error } = await supabase .from("tablo_access") .update({ is_active: false }) @@ -872,7 +698,6 @@ export const getTabloRouter = (config: AppConfig) => { tabloRouter.use(middlewareManager.supabase); tabloRouter.use(middlewareManager.auth); - tabloRouter.use(middlewareManager.streamChat); tabloRouter.post("/create", ...createTablo(middlewareManager)); tabloRouter.patch("/update", ...updateTablo(middlewareManager)); diff --git a/apps/api/src/routers/tasks.ts b/apps/api/src/routers/tasks.ts index 428f313..fb17794 100644 --- a/apps/api/src/routers/tasks.ts +++ b/apps/api/src/routers/tasks.ts @@ -39,7 +39,6 @@ const syncCalendars = factory.createHandlers(async (c) => { const syncTabloNames = factory.createHandlers(async (c) => { const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const fifteenMinutesInMilliseconds = 1000 * 60 * 15; @@ -54,18 +53,6 @@ const syncTabloNames = factory.createHandlers(async (c) => { const tablosData = data as { id: string; name: string }[]; - tablosData.forEach(async (tablo) => { - const channel = streamServerClient.channel("messaging", tablo.id); - try { - await channel.update({ - // @ts-ignore - name: tablo.name, - }); - } catch (error) { - console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`); - } - }); - return c.json({ message: `Synced ${tablosData.length} tablo names` }); }); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 7616c79..f5cdde4 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -11,30 +11,9 @@ const factory = createFactory(); const isMissingRelationError = (code: string | undefined) => code === "42P01" || code === "PGRST205"; -const signUpToStream = factory.createHandlers(async (c) => { - const { id } = c.get("user"); - const supabase = c.get("supabase"); - - const { data } = await supabase.from("profiles").select("*").eq("id", id).single(); - - const user = data as Tables<"profiles">; - - const streamServerClient = c.get("streamServerClient"); - await streamServerClient.upsertUser({ - id, - name: user.name ?? "", - language: "fr", - }); - - return c.json({ - message: "User signed up to stream", - }); -}); - const getMe = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); @@ -60,14 +39,7 @@ const getMe = factory.createHandlers(async (c) => { effectivePlan = organizationPlan; } - const user_id = data.id; - const token = streamServerClient.createToken(user_id); - - return c.json({ - ...userData, - plan: effectivePlan, - streamToken: token, - }); + return c.json({ ...userData, plan: effectivePlan }); }); const markTemporary = factory.createHandlers(async (c) => { @@ -515,7 +487,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); const transporter = c.get("transporter"); - const streamServerClient = c.get("streamServerClient"); const body = await c.req.json(); const rawEmail = typeof body?.email === "string" ? body.email : ""; const recipientEmail = rawEmail.trim().toLowerCase(); @@ -613,7 +584,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const invitedUser = await createInvitedUser( supabase, - streamServerClient, transporter, recipientEmail, senderProfile.email, @@ -673,15 +643,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { } } - for (const tablo of organizationTablos || []) { - const channel = streamServerClient.channel("messaging", tablo.id); - try { - await channel.addMembers([invitedUser.userId]); - } catch (error) { - console.error("Failed to add invited user to Stream channel:", error); - } - } - if (oldOrganizationId && oldOrganizationId !== organizationId) { const { count: oldOrgMembersCount } = await supabase .from("profiles") @@ -717,7 +678,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const removeOrganizationMember = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); const memberId = c.req.param("memberId"); if (!memberId) { @@ -826,14 +786,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => { return c.json({ error: "Failed to revoke member tablo permissions" }, 500); } - for (const tabloId of tabloIds) { - try { - const channel = streamServerClient.channel("messaging", tabloId); - await channel.removeMembers([memberId]); - } catch (error) { - console.error("Failed to remove organization member from Stream channel:", error); - } - } } const { error: inviteCleanupError } = await supabase @@ -852,7 +804,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => { export const getUserRouter = () => { const userRouter = new Hono(); - userRouter.post("/sign-up-to-stream", ...signUpToStream); userRouter.get("/me", ...getMe); userRouter.post("/mark-temporary", ...markTemporary); userRouter.post("/profile/avatar", ...uploadAvatar); diff --git a/apps/api/src/secrets.ts b/apps/api/src/secrets.ts index 4a69b8e..2126133 100644 --- a/apps/api/src/secrets.ts +++ b/apps/api/src/secrets.ts @@ -26,11 +26,9 @@ export type Secrets = { r2AccessKeyId: string; r2SecretAccessKey: string; // Env dependent - streamChatApiSecret: string; stripeSecretKey: string; stripeWebhookSecret: string; // Staging - streamChatApiSecretStaging: string; stripeSecretKeyStaging: string; stripeWebhookSecretStaging: string; }; @@ -50,11 +48,9 @@ export async function loadSecrets(): Promise { r2SecretAccessKey: await fetchSecret("r2-secret-access-key"), // Env dependent // Staging - streamChatApiSecretStaging: await fetchSecret("stream-chat-api-secret-staging"), stripeSecretKeyStaging: await fetchSecret("stripe-secret-key-staging"), stripeWebhookSecretStaging: await fetchSecret("stripe-webhook-secret-staging"), // Production - streamChatApiSecret: await fetchSecret("stream-chat-api-secret"), stripeSecretKey: await fetchSecret("stripe-secret-key"), stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"), }; diff --git a/apps/api/src/types/app.types.ts b/apps/api/src/types/app.types.ts index f895966..1f14da7 100644 --- a/apps/api/src/types/app.types.ts +++ b/apps/api/src/types/app.types.ts @@ -3,7 +3,6 @@ import type { StripeSync } from "@supabase/stripe-sync-engine"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import type { Hono } from "hono"; import type { Transporter } from "nodemailer"; -import type { StreamChat } from "stream-chat"; import type Stripe from "stripe"; /** @@ -12,7 +11,6 @@ import type Stripe from "stripe"; export type BaseEnv = { Variables: { supabase: SupabaseClient; - streamServerClient: StreamChat; s3_client: S3Client; transporter: Transporter; stripe: Stripe; From 6081ada0135a4d24362b6f3180c6ab8383c4a6a1 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 13:48:35 +0200 Subject: [PATCH 13/62] refactor(main): remove all Stream Chat dependencies and components - Delete ChatProvider, ChannelPreview, CustomChannelHeader, hooks/channel.ts - Replace TabloDiscussionSection with chatscope-based implementation using useChat - Update tablo-details.tsx to use useChatUnread instead of useTabloDiscussionUnread - Remove streamToken field from User type in UserStoreProvider - Remove useSignUpToStream from shared auth hooks - Remove stream-chat and stream-chat-react packages - Remove stream-chat-react CSS import from main.tsx - Clean up all streamToken references from test mocks and helpers - Update chat.test.tsx and tablo-details.layout.test.tsx for new implementation Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/external/.env.production | 1 - apps/external/src/UserStoreProvider.tsx | 4 +- apps/external/worker-configuration.d.ts | 1 - apps/main/package.json | 2 - .../src/components/ChannelPreview.test.tsx | 105 ---- apps/main/src/components/ChannelPreview.tsx | 138 ----- .../components/CustomChannelHeader.test.tsx | 117 ---- .../src/components/CustomChannelHeader.tsx | 53 -- .../src/components/ProtectedRoute.test.tsx | 1 - .../src/components/SubscriptionCard.test.tsx | 195 ++++++ .../TabloDiscussionSection.test.tsx | 35 +- .../src/components/TabloDiscussionSection.tsx | 108 ++-- .../main/src/components/UpgradePanel.test.tsx | 140 +++++ .../src/contexts/UpgradeBlockContext.test.tsx | 180 ++++++ apps/main/src/hooks/channel.ts | 109 ---- apps/main/src/main.tsx | 1 - apps/main/src/pages/chat.test.tsx | 96 +-- .../src/pages/tablo-details.layout.test.tsx | 8 +- apps/main/src/pages/tablo-details.tsx | 5 +- apps/main/src/providers/ChatProvider.test.tsx | 55 -- apps/main/src/providers/ChatProvider.tsx | 40 -- .../src/providers/UserStoreProvider.test.tsx | 3 - apps/main/src/providers/UserStoreProvider.tsx | 4 +- apps/main/src/utils/testHelpers.tsx | 1 - apps/main/worker-configuration.d.ts | 1 - packages/shared/package.json | 4 +- packages/shared/src/hooks/auth.ts | 22 - pnpm-lock.yaml | 558 ------------------ 28 files changed, 628 insertions(+), 1359 deletions(-) delete mode 100644 apps/main/src/components/ChannelPreview.test.tsx delete mode 100644 apps/main/src/components/ChannelPreview.tsx delete mode 100644 apps/main/src/components/CustomChannelHeader.test.tsx delete mode 100644 apps/main/src/components/CustomChannelHeader.tsx create mode 100644 apps/main/src/components/SubscriptionCard.test.tsx create mode 100644 apps/main/src/components/UpgradePanel.test.tsx create mode 100644 apps/main/src/contexts/UpgradeBlockContext.test.tsx delete mode 100644 apps/main/src/hooks/channel.ts delete mode 100644 apps/main/src/providers/ChatProvider.test.tsx delete mode 100644 apps/main/src/providers/ChatProvider.tsx diff --git a/apps/external/.env.production b/apps/external/.env.production index 49c8887..0c8f9cf 100644 --- a/apps/external/.env.production +++ b/apps/external/.env.production @@ -2,6 +2,5 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app \ No newline at end of file diff --git a/apps/external/src/UserStoreProvider.tsx b/apps/external/src/UserStoreProvider.tsx index 8861f98..1df3c10 100644 --- a/apps/external/src/UserStoreProvider.tsx +++ b/apps/external/src/UserStoreProvider.tsx @@ -5,9 +5,7 @@ import React from "react"; import { createStore, StoreApi, useStore } from "zustand"; import { api } from "./lib/api"; -export type User = Tables<"profiles"> & { - streamToken: string | null; -}; +export type User = Tables<"profiles">; const UserStoreContext = React.createContext | null>(null); diff --git a/apps/external/worker-configuration.d.ts b/apps/external/worker-configuration.d.ts index 64d868a..8c48ac3 100644 --- a/apps/external/worker-configuration.d.ts +++ b/apps/external/worker-configuration.d.ts @@ -10,7 +10,6 @@ declare namespace Cloudflare { VITE_SUPABASE_URL: string; VITE_SUPABASE_ANON_KEY: string; VITE_SUPABASE_ID: string; - VITE_STREAM_CHAT_API_KEY: string; VITE_API_URL: string; } } diff --git a/apps/main/package.json b/apps/main/package.json index 3ffcf32..5ef1eb9 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -121,8 +121,6 @@ "react-router-dom": "^7.9.4", "react-stately": "^3.36.1", "sonner": "^2.0.7", - "stream-chat": "^9.6.1", - "stream-chat-react": "^13.1.0", "ts-pattern": "^5.6.2", "uuid": "^11.1.0", "zod": "^4.1.12", diff --git a/apps/main/src/components/ChannelPreview.test.tsx b/apps/main/src/components/ChannelPreview.test.tsx deleted file mode 100644 index db1b493..0000000 --- a/apps/main/src/components/ChannelPreview.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { Channel } from "stream-chat"; -import { describe, expect, it, vi } from "vitest"; -import { ChannelPreview } from "./ChannelPreview"; - -// Mock ChannelBadge -vi.mock("./ChannelBadge", () => ({ - ChannelBadge: ({ displayTitle, isOnline }: { displayTitle?: string; isOnline: boolean }) => ( -
- {displayTitle}-{isOnline ? "online" : "offline"} -
- ), -})); - -describe("ChannelPreview", () => { - const mockChannel = { - id: "channel-1", - data: { - created_at: new Date("2024-01-01").toISOString(), - config: { - name: "Test Channel", - }, - }, - state: { - members: {}, - }, - } as unknown as Channel; - - const mockTablo = { - id: "tablo-1", - name: "Test Tablo", - color: "bg-blue-500", - user_id: "user-id", - access_level: "admin", - is_admin: true, - created_at: "2024-01-01T00:00:00Z", - deleted_at: "2024-01-01T00:00:00Z", - position: 0, - status: "active", - image: null, - }; - - const defaultProps = { - channel: mockChannel, - tablo: mockTablo, - displayTitle: "Test Channel", - }; - - it("renders without crashing", () => { - render(); - expect(screen.getByText("Test Channel")).toBeInTheDocument(); - }); - - it("displays channel title", () => { - render(); - expect(screen.getByText("Test Channel")).toBeInTheDocument(); - }); - - it("renders ChannelBadge component", () => { - render(); - expect(screen.getByTestId("channel-badge")).toBeInTheDocument(); - }); - - it("shows unread count badge when unreadCount > 0", () => { - render(); - expect(screen.getByText("5")).toBeInTheDocument(); - }); - - it("shows 99+ for unread counts over 99", () => { - render(); - expect(screen.getByText("99+")).toBeInTheDocument(); - }); - - it("does not show unread badge when count is 0", () => { - const { container } = render(); - expect(container.querySelector(".min-w-\\[20px\\]")).not.toBeInTheDocument(); - }); - - it("calls setActiveChannel when clicked", () => { - const setActiveChannel = vi.fn(); - render(); - fireEvent.click(screen.getByText("Test Channel")); - expect(setActiveChannel).toHaveBeenCalledWith(mockChannel); - }); - - it("highlights active channel", () => { - const { container } = render(); - expect(container.querySelector(".bg-purple-50")).toBeInTheDocument(); - }); - - it("displays latest message preview", () => { - render(); - expect(screen.getByText("Hello world")).toBeInTheDocument(); - }); - - it("applies custom className", () => { - const { container } = render(); - expect(container.querySelector(".custom-class")).toBeInTheDocument(); - }); - - it("shows active indicator for active channel", () => { - const { container } = render(); - expect(container.querySelector(".absolute.left-0.top-0.bottom-0.w-1")).toBeInTheDocument(); - }); -}); diff --git a/apps/main/src/components/ChannelPreview.tsx b/apps/main/src/components/ChannelPreview.tsx deleted file mode 100644 index abe2c03..0000000 --- a/apps/main/src/components/ChannelPreview.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { ChannelBadge } from "@ui/components/ChannelBadge"; -import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import { Badge } from "@xtablo/ui/components/badge"; -import { ReactNode } from "react"; -import { Channel } from "stream-chat"; -import { twMerge } from "tailwind-merge"; - -interface ChannelPreviewProps { - channel: Channel; - tablo: UserTablo | null; - displayTitle: string | undefined; - activeChannel?: Channel; - setActiveChannel?: (channel: Channel) => void; - unreadCount?: number; - latestMessagePreview?: ReactNode; - className?: string; -} - -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(); -} - -// function getLastMessagePreview(lastMessage?: StreamMessage): string { -// if (!lastMessage) return "No messages yet"; - -// if (lastMessage.deleted_at) return "Message deleted"; - -// if (lastMessage.text) { -// return lastMessage.text.length > 50 -// ? lastMessage.text.substring(0, 50) + "..." -// : lastMessage.text; -// } - -// if (lastMessage.attachments?.length && lastMessage.attachments.length > 0) { -// const attachment = lastMessage.attachments[0]; -// if (attachment.type === "image") return "📷 Image"; -// if (attachment.type === "video") return "🎥 Video"; -// if (attachment.type === "file") return "📎 File"; -// } - -// return "Message"; -// } - -function isUserOnline(channel: Channel): boolean { - const members = Object.values(channel.state?.members || {}); - - const otherMembers = members.filter( - // @ts-expect-error TODO: fix this - (member) => member.user?.id !== channel.data?.config?.created_by?.id - ); - - return otherMembers.some((member) => member.user?.online); -} - -export function ChannelPreview({ - displayTitle, - channel, - tablo, - activeChannel, - setActiveChannel, - unreadCount = 0, - latestMessagePreview, - className, -}: ChannelPreviewProps) { - const isActive = activeChannel?.id === channel.id; - const isOnline = isUserOnline(channel); - const timestamp = channel.data?.created_at; - - const handleClick = () => { - setActiveChannel?.(channel); - }; - - return ( -
- - - {/* Channel info */} -
-
-

- {displayTitle} -

- {timestamp && ( - - {formatTimestamp(timestamp)} - - )} -
- -
-

- {latestMessagePreview} -

- - {/* Unread count badge */} - {unreadCount > 0 && ( -
- - {unreadCount > 99 ? "99+" : unreadCount} - -
- )} -
-
- - {/* Active indicator */} - {isActive && ( -
- )} -
- ); -} diff --git a/apps/main/src/components/CustomChannelHeader.test.tsx b/apps/main/src/components/CustomChannelHeader.test.tsx deleted file mode 100644 index 59146b3..0000000 --- a/apps/main/src/components/CustomChannelHeader.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { CustomChannelHeader } from "./CustomChannelHeader"; - -// Mock stream-chat-react -vi.mock("stream-chat-react", () => ({ - ChannelHeader: ({ Avatar }: { Avatar?: () => React.ReactElement }) => ( -
{Avatar && }
- ), - useChannelStateContext: () => ({ - channel: { - id: "test-channel", - data: { - config: { - name: "Test Channel", - }, - }, - }, - }), -})); - -// Mock ChannelBadge -vi.mock("./ChannelBadge", () => ({ - ChannelBadge: ({ displayTitle }: { displayTitle?: string }) => ( -
{displayTitle}
- ), -})); - -describe("CustomChannelHeader", () => { - const mockTablos = [ - { - id: "test-channel", - name: "Test Tablo", - color: "bg-blue-500", - user_id: "user-id", - access_level: "admin", - is_admin: true, - created_at: "2024-01-01T00:00:00Z", - deleted_at: "2024-01-01T00:00:00Z", - position: 0, - status: "active", - image: null, - }, - ]; - - it("renders without crashing", () => { - render(); - expect(screen.getByTestId("channel-header")).toBeInTheDocument(); - }); - - it("renders ChannelHeader component", () => { - render(); - expect(screen.getByTestId("channel-header")).toBeInTheDocument(); - }); - - it("shows toggle button when showToggleButton is true", () => { - render( - - ); - const toggleButton = screen.getByLabelText("Toggle channel list"); - expect(toggleButton).toBeInTheDocument(); - }); - - it("hides toggle button when showToggleButton is false", () => { - render( - - ); - const toggleButton = screen.queryByLabelText("Toggle channel list"); - expect(toggleButton).not.toBeInTheDocument(); - }); - - it("calls onToggleChannelList when toggle button is clicked", () => { - const onToggleChannelList = vi.fn(); - render( - - ); - const toggleButton = screen.getByLabelText("Toggle channel list"); - fireEvent.click(toggleButton); - expect(onToggleChannelList).toHaveBeenCalled(); - }); - - it("applies rotation class when isChannelListExpanded is true", () => { - const { container } = render( - - ); - const svg = container.querySelector(".rotate-180"); - expect(svg).toBeInTheDocument(); - }); - - it("renders without toggle button when onToggleChannelList is not provided", () => { - render(); - const toggleButton = screen.queryByLabelText("Toggle channel list"); - expect(toggleButton).not.toBeInTheDocument(); - }); - - it("renders ChannelBadge with correct props", () => { - render(); - expect(screen.getByTestId("channel-badge")).toBeInTheDocument(); - }); -}); diff --git a/apps/main/src/components/CustomChannelHeader.tsx b/apps/main/src/components/CustomChannelHeader.tsx deleted file mode 100644 index bc7373c..0000000 --- a/apps/main/src/components/CustomChannelHeader.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import { ChannelHeader, useChannelStateContext } from "stream-chat-react"; -import { ChannelBadge } from "./ChannelBadge"; - -interface CustomChannelHeaderProps { - tablos: UserTablo[]; - onToggleChannelList?: () => void; - isChannelListExpanded?: boolean; - showToggleButton?: boolean; -} - -export const CustomChannelHeader = ({ - tablos, - onToggleChannelList, - isChannelListExpanded = false, - showToggleButton = true, -}: CustomChannelHeaderProps) => { - const { channel } = useChannelStateContext(); - - return ( -
- {showToggleButton && onToggleChannelList && ( - - )} - { - return ( - t.id === channel?.id) ?? null} - displayTitle={channel?.data?.config?.name} - isOnline={false} - /> - ); - }} - /> -
- ); -}; diff --git a/apps/main/src/components/ProtectedRoute.test.tsx b/apps/main/src/components/ProtectedRoute.test.tsx index 457a53e..d9979ec 100644 --- a/apps/main/src/components/ProtectedRoute.test.tsx +++ b/apps/main/src/components/ProtectedRoute.test.tsx @@ -80,7 +80,6 @@ describe("ProtectedRoute", () => { name: "Test User", email: "test@example.com", avatar_url: "https://example.com/avatar.jpg", - streamToken: null, short_user_id: "123", first_name: "Test", last_name: "User", diff --git a/apps/main/src/components/SubscriptionCard.test.tsx b/apps/main/src/components/SubscriptionCard.test.tsx new file mode 100644 index 0000000..15d3c47 --- /dev/null +++ b/apps/main/src/components/SubscriptionCard.test.tsx @@ -0,0 +1,195 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider"; +import { SubscriptionCard } from "./SubscriptionCard"; + +vi.mock("../hooks/organization", () => ({ + useOrganization: vi.fn(), +})); + +vi.mock("../hooks/stripe", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSubscription: vi.fn(), + useCreateCheckoutSession: () => ({ mutate: vi.fn(), isPending: false }), + useCreatePortalSession: () => ({ mutate: vi.fn(), isPending: false }), + useCancelSubscription: () => ({ mutate: vi.fn(), isPending: false }), + useReactivateSubscription: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("../hooks/auth", () => ({ + useAuthedApi: () => ({}), +})); + +import { useOrganization } from "../hooks/organization"; +import { useSubscription } from "../hooks/stripe"; +const mockUseOrganization = vi.mocked(useOrganization); +const mockUseSubscription = vi.mocked(useSubscription); + +const baseUser: User = { + id: "user-1", + short_user_id: "u1", + name: "Test User", + first_name: "Test", + last_name: "User", + email: "test@example.com", + avatar_url: null, + is_temporary: false, + last_signed_in: null, + plan: "none", + created_at: new Date().toISOString(), +}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function renderCard( + user: User, + orgData: ReturnType["data"], + subscription: ReturnType["data"] = undefined, +) { + mockUseOrganization.mockReturnValue({ + data: orgData, + isLoading: false, + error: null, + } as ReturnType); + + mockUseSubscription.mockReturnValue({ + data: subscription, + isLoading: false, + error: null, + } as ReturnType); + + return render( + + + + + + ); +} + +const baseOrg = { + organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null }, + members: [], + invites_sent: [], + trial_starts_at: "2026-01-01", + trial_ends_at: "2026-02-01", + is_trial_expired: false, + required_plan: "solo" as const, + required_team_quantity: 1, + active_subscription_plan: null, + active_subscription_quantity: 0, + is_billing_owner: true, +}; + +describe("SubscriptionCard", () => { + it("shows 'Sans abonnement' badge when there is no subscription", () => { + renderCard(baseUser, baseOrg); + expect(screen.getByText("Sans abonnement")).toBeInTheDocument(); + }); + + it("shows Founder badge and unlimited info for annual plan", () => { + const founderOrg = { ...baseOrg, active_subscription_plan: "annual" as const }; + const founderUser = { ...baseUser, plan: "annual" as const }; + renderCard(founderUser, founderOrg); + expect(screen.getByText("Founder")).toBeInTheDocument(); + expect(screen.getByText(/Plan Founder \(annuel\)/)).toBeInTheDocument(); + }); + + it("shows recommended plan for non-paying billing owner", () => { + renderCard(baseUser, baseOrg); + expect(screen.getByText(/Plan recommandé: Solo/)).toBeInTheDocument(); + expect(screen.getByText(/Passer au plan Solo/)).toBeInTheDocument(); + expect(screen.getByText("Devenir Founder (99€/an)")).toBeInTheDocument(); + }); + + it("shows team as recommended plan when required", () => { + const teamOrg = { + ...baseOrg, + required_plan: "team" as const, + required_team_quantity: 3, + }; + renderCard(baseUser, teamOrg); + expect(screen.getByText(/Plan recommandé: Teams/)).toBeInTheDocument(); + expect(screen.getByText(/3 sièges Teams/)).toBeInTheDocument(); + }); + + it("shows billing owner restriction when user is not billing owner", () => { + const nonOwnerOrg = { ...baseOrg, is_billing_owner: false }; + renderCard(baseUser, nonOwnerOrg); + expect( + screen.getByText(/Seul le propriétaire de facturation/) + ).toBeInTheDocument(); + expect(screen.queryByText(/Passer au plan/)).not.toBeInTheDocument(); + }); + + it("shows active subscription details with manage and cancel buttons", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + active_subscription_quantity: 3, + }; + const activeSubscription = { + id: "sub_1", + status: "active", + current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30, + cancel_at_period_end: false, + }; + renderCard(baseUser, teamOrg, activeSubscription as any); + expect(screen.getByText("Actif")).toBeInTheDocument(); + expect(screen.getByText("Gérer l'abonnement")).toBeInTheDocument(); + expect(screen.getByText("Annuler")).toBeInTheDocument(); + }); + + it("shows trialing badge for trialing subscription", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + }; + const trialingSubscription = { + id: "sub_1", + status: "trialing", + current_period_end: Math.floor(Date.now() / 1000) + 86400 * 14, + cancel_at_period_end: false, + }; + renderCard(baseUser, teamOrg, trialingSubscription as any); + expect(screen.getByText("Période d'essai")).toBeInTheDocument(); + }); + + it("shows past_due badge for overdue subscription", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + }; + const pastDueSubscription = { + id: "sub_1", + status: "past_due", + current_period_end: Math.floor(Date.now() / 1000) - 86400, + cancel_at_period_end: false, + }; + renderCard(baseUser, teamOrg, pastDueSubscription as any); + expect(screen.getByText("Paiement en retard")).toBeInTheDocument(); + }); + + it("shows reactivation UI for canceled subscription", () => { + const teamOrg = { + ...baseOrg, + active_subscription_plan: "team" as const, + active_subscription_quantity: 2, + }; + const canceledSubscription = { + id: "sub_1", + status: "active", + current_period_end: Math.floor(Date.now() / 1000) + 86400 * 15, + cancel_at_period_end: true, + }; + renderCard(baseUser, teamOrg, canceledSubscription as any); + expect(screen.getByText(/Abonnement en cours d'annulation/)).toBeInTheDocument(); + expect(screen.getByText("Réactiver l'abonnement")).toBeInTheDocument(); + }); +}); diff --git a/apps/main/src/components/TabloDiscussionSection.test.tsx b/apps/main/src/components/TabloDiscussionSection.test.tsx index b288db9..634ba21 100644 --- a/apps/main/src/components/TabloDiscussionSection.test.tsx +++ b/apps/main/src/components/TabloDiscussionSection.test.tsx @@ -2,33 +2,20 @@ import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { TabloDiscussionSection } from "./TabloDiscussionSection"; -// Mock Stream Chat -vi.mock("stream-chat-react", () => ({ - Chat: ({ children }: { children: React.ReactNode }) =>
{children}
, - Channel: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Window: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - MessageList: () =>
Messages
, - MessageInput: () =>
Input
, - useChannelStateContext: () => ({ channel: null }), - useCreateChatClient: () => null, - useChatContext: () => ({ - client: null, - setActiveChannel: vi.fn(), +vi.mock("../hooks/useChat", () => ({ + useChat: () => ({ + messages: [], + sendMessage: vi.fn(), + sendTyping: vi.fn(), + isConnected: false, + typingUsers: [], + onlineUsers: [], + loadMoreMessages: vi.fn(), + hasMoreMessages: false, + markAsRead: vi.fn(), }), })); -vi.mock("../providers/ChatProvider", () => ({ - useChatContext: () => ({ - client: null, - setActiveChannel: vi.fn(), - }), - default: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - describe("TabloDiscussionSection", () => { const mockTablo = { id: "test-tablo-id", diff --git a/apps/main/src/components/TabloDiscussionSection.tsx b/apps/main/src/components/TabloDiscussionSection.tsx index 64c7ac5..a3b2881 100644 --- a/apps/main/src/components/TabloDiscussionSection.tsx +++ b/apps/main/src/components/TabloDiscussionSection.tsx @@ -1,10 +1,15 @@ -import { CustomChannelHeader } from "@ui/components/CustomChannelHeader"; +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; +import { + ChatContainer, + Message, + MessageInput, + MessageList, + TypingIndicator, +} from "@chatscope/chat-ui-kit-react"; import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import { useEffect, useState } from "react"; -import { Channel as StreamChannel } from "stream-chat"; -import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react"; -import ChatProvider from "../providers/ChatProvider"; -import { LoadingSpinner } from "./LoadingSpinner"; +import { useEffect } from "react"; +import { useChat } from "../hooks/useChat"; +import { useUser } from "../providers/UserStoreProvider"; import { TabloHeaderActions } from "./TabloHeaderActions"; interface TabloDiscussionSectionProps { @@ -12,43 +17,31 @@ interface TabloDiscussionSectionProps { isAdmin: boolean; } -const TabloChat = ({ tablo }: { tablo: UserTablo }) => { - const { client, setActiveChannel } = useChatContext(); - const [channel, setChannel] = useState(null); - - useEffect(() => { - const initChannel = async () => { - if (client && tablo.id) { - const newChannel = client.channel("messaging", tablo.id); - await newChannel.watch(); - setChannel(newChannel); - setActiveChannel(newChannel); - } - }; - - initChannel(); - }, [client, tablo.id, setActiveChannel]); - - if (!channel) { - return ( -
- -
- ); - } - - return ( - - - - - - - - ); -}; - export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectionProps) => { + const user = useUser(); + const { + messages, + sendMessage, + sendTyping, + typingUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + } = useChat(tablo.id); + + // Mark as read when opening the discussion + useEffect(() => { + if (messages.length > 0) { + markAsRead(); + } + }, [messages.length, markAsRead]); + + const handleSend = (_innerHtml: string, textContent: string) => { + const text = textContent.trim(); + if (!text) return; + sendMessage(text); + }; + return (
@@ -60,9 +53,34 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
- - - + + 0 ? ( + + ) : null + } + onYReachStart={hasMoreMessages ? loadMoreMessages : undefined} + > + {messages.map((msg) => ( + + ))} + + +
); diff --git a/apps/main/src/components/UpgradePanel.test.tsx b/apps/main/src/components/UpgradePanel.test.tsx new file mode 100644 index 0000000..a91e5de --- /dev/null +++ b/apps/main/src/components/UpgradePanel.test.tsx @@ -0,0 +1,140 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpgradeBlockProvider } from "../contexts/UpgradeBlockContext"; +import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider"; +import { UpgradePanel } from "./UpgradePanel"; + +vi.mock("../hooks/organization", () => ({ + useOrganization: vi.fn(), +})); + +vi.mock("../hooks/stripe", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCreateCheckoutSession: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("../hooks/auth", () => ({ + useLogout: () => ({ mutate: vi.fn() }), + useAuthedApi: () => ({}), +})); + +import { useOrganization } from "../hooks/organization"; +const mockUseOrganization = vi.mocked(useOrganization); + +const baseUser: User = { + id: "user-1", + short_user_id: "u1", + name: "Test User", + first_name: "Test", + last_name: "User", + email: "test@example.com", + avatar_url: null, + is_temporary: false, + last_signed_in: null, + plan: "none", + created_at: new Date().toISOString(), +}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function renderPanel(user: User, orgData: ReturnType["data"]) { + mockUseOrganization.mockReturnValue({ + data: orgData, + isLoading: false, + error: null, + } as ReturnType); + + return render( + + + + + + + + ); +} + +const noPlanOrg = { + organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null }, + members: [], + invites_sent: [], + trial_starts_at: "2026-01-01", + trial_ends_at: "2026-02-01", + is_trial_expired: false, + required_plan: "solo" as const, + required_team_quantity: 1, + active_subscription_plan: null, + active_subscription_quantity: 0, + is_billing_owner: true, +}; + +const trialExpiredOrg = { + ...noPlanOrg, + is_trial_expired: true, + required_plan: "team" as const, + required_team_quantity: 3, + active_subscription_plan: "team" as const, + active_subscription_quantity: 1, + is_billing_owner: true, +}; + +const compliantOrg = { + ...noPlanOrg, + active_subscription_plan: "team" as const, + active_subscription_quantity: 2, + is_billing_owner: true, +}; + +describe("UpgradePanel", () => { + it("renders nothing when user has a compliant subscription", () => { + const { container } = renderPanel(baseUser, compliantOrg); + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing for temporary users even with no plan", () => { + const tempUser = { ...baseUser, is_temporary: true }; + const { container } = renderPanel(tempUser, noPlanOrg); + expect(container.innerHTML).toBe(""); + }); + + it("renders the paywall for regular users with no plan", () => { + renderPanel(baseUser, noPlanOrg); + expect(screen.getByText("Choisissez un abonnement pour continuer")).toBeInTheDocument(); + }); + + it("renders trial expired message when trial is expired", () => { + renderPanel(baseUser, trialExpiredOrg); + expect(screen.getByText("Votre période d'essai est terminée")).toBeInTheDocument(); + }); + + it("shows checkout buttons for billing owner", () => { + renderPanel(baseUser, noPlanOrg); + expect(screen.getByText("Passer au plan Solo")).toBeInTheDocument(); + expect(screen.getByText(/Passer au plan Teams/)).toBeInTheDocument(); + expect(screen.getByText("Devenir Founder (99€/an)")).toBeInTheDocument(); + }); + + it("disables buttons for non-billing-owner and shows warning", () => { + const nonOwnerOrg = { ...noPlanOrg, is_billing_owner: false }; + renderPanel(baseUser, nonOwnerOrg); + + const soloButton = screen.getByText("Passer au plan Solo").closest("button"); + expect(soloButton).toBeDisabled(); + + expect( + screen.getByText(/Seul le propriétaire de facturation/) + ).toBeInTheDocument(); + }); + + it("renders nothing when org data is not yet loaded", () => { + const { container } = renderPanel(baseUser, undefined); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/apps/main/src/contexts/UpgradeBlockContext.test.tsx b/apps/main/src/contexts/UpgradeBlockContext.test.tsx new file mode 100644 index 0000000..a4df341 --- /dev/null +++ b/apps/main/src/contexts/UpgradeBlockContext.test.tsx @@ -0,0 +1,180 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider"; +import { + UpgradeBlockProvider, + useMaybeUpgradeBlock, + useUpgradeBlock, +} from "./UpgradeBlockContext"; + +// Mock the organization hook +vi.mock("../hooks/organization", () => ({ + useOrganization: vi.fn(), +})); + +import { useOrganization } from "../hooks/organization"; +const mockUseOrganization = vi.mocked(useOrganization); + +const baseUser: User = { + id: "user-1", + short_user_id: "u1", + name: "Test User", + first_name: "Test", + last_name: "User", + email: "test@example.com", + avatar_url: null, + is_temporary: false, + last_signed_in: null, + plan: "none", + created_at: new Date().toISOString(), +}; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +function TestConsumer() { + const context = useUpgradeBlock(); + return ( +
+ {String(context.isBlocked)} + {context.reason ?? "none"} +
+ ); +} + +function MaybeTestConsumer() { + const context = useMaybeUpgradeBlock(); + return ( +
+ {String(context !== null)} +
+ ); +} + +function renderWithUser(user: User | null, orgData: ReturnType["data"]) { + mockUseOrganization.mockReturnValue({ + data: orgData, + isLoading: false, + error: null, + } as ReturnType); + + return render( + + + + + + + + ); +} + +const compliantOrgData = { + organization: { id: 1, name: "Test Org", plan: "team", member_count: 2, tablo_count: 5, logo_url: null }, + members: [], + invites_sent: [], + trial_starts_at: "2026-01-01", + trial_ends_at: "2026-02-01", + is_trial_expired: false, + required_plan: "solo" as const, + required_team_quantity: 1, + active_subscription_plan: "team" as const, + active_subscription_quantity: 2, + is_billing_owner: true, +}; + +const noPlanOrgData = { + ...compliantOrgData, + active_subscription_plan: null, + active_subscription_quantity: 0, +}; + +const trialExpiredOrgData = { + ...compliantOrgData, + is_trial_expired: true, + required_plan: "team" as const, + required_team_quantity: 5, + active_subscription_plan: "team" as const, + active_subscription_quantity: 2, +}; + +describe("UpgradeBlockProvider", () => { + it("is not blocked when user and org are loading (null)", () => { + renderWithUser(null, undefined); + expect(screen.getByTestId("blocked").textContent).toBe("false"); + expect(screen.getByTestId("reason").textContent).toBe("none"); + }); + + it("is not blocked when user is loaded but org data is still loading", () => { + renderWithUser(baseUser, undefined); + expect(screen.getByTestId("blocked").textContent).toBe("false"); + }); + + it("is not blocked for temporary users regardless of org billing state", () => { + const temporaryUser = { ...baseUser, is_temporary: true }; + renderWithUser(temporaryUser, noPlanOrgData); + expect(screen.getByTestId("blocked").textContent).toBe("false"); + expect(screen.getByTestId("reason").textContent).toBe("none"); + }); + + it("is not blocked for temporary users even with expired trial", () => { + const temporaryUser = { ...baseUser, is_temporary: true }; + renderWithUser(temporaryUser, trialExpiredOrgData); + expect(screen.getByTestId("blocked").textContent).toBe("false"); + }); + + it("blocks regular users when org has no active plan", () => { + renderWithUser(baseUser, noPlanOrgData); + expect(screen.getByTestId("blocked").textContent).toBe("true"); + expect(screen.getByTestId("reason").textContent).toBe("no_plan"); + }); + + it("blocks regular users when trial expired and plan is insufficient", () => { + renderWithUser(baseUser, trialExpiredOrgData); + expect(screen.getByTestId("blocked").textContent).toBe("true"); + expect(screen.getByTestId("reason").textContent).toBe("trial_expired"); + }); + + it("is not blocked for regular users with a compliant subscription", () => { + renderWithUser(baseUser, compliantOrgData); + expect(screen.getByTestId("blocked").textContent).toBe("false"); + expect(screen.getByTestId("reason").textContent).toBe("none"); + }); + + it("is not blocked with an annual plan even if team quantity is insufficient", () => { + const annualOrgData = { + ...trialExpiredOrgData, + active_subscription_plan: "annual" as const, + }; + renderWithUser(baseUser, annualOrgData); + expect(screen.getByTestId("blocked").textContent).toBe("false"); + }); +}); + +describe("useMaybeUpgradeBlock", () => { + it("returns null when outside provider", () => { + render(); + expect(screen.getByTestId("has-context").textContent).toBe("false"); + }); + + it("returns context when inside provider", () => { + mockUseOrganization.mockReturnValue({ + data: compliantOrgData, + isLoading: false, + error: null, + } as ReturnType); + + render( + + + + + + + + ); + expect(screen.getByTestId("has-context").textContent).toBe("true"); + }); +}); diff --git a/apps/main/src/hooks/channel.ts b/apps/main/src/hooks/channel.ts deleted file mode 100644 index 3a80bf5..0000000 --- a/apps/main/src/hooks/channel.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import { Channel, StreamChat } from "stream-chat"; -import { useUser } from "../providers/UserStoreProvider"; - -export const useChannelFromUrl = (client: StreamChat) => { - const [channel, setChannel] = useState(null); - const { channelId } = useParams(); - useEffect(() => { - if (channelId) { - const channel = client.channel("messaging", channelId); - channel.watch(); - setChannel(channel); - } else { - setChannel(null); - } - }, [channelId, client]); - return { channel, isChannelInUrl: !!channelId }; -}; - -export const useTabloDiscussionUnread = (tabloId?: string) => { - const user = useUser(); - const [hasUnread, setHasUnread] = useState(false); - - useEffect(() => { - if (!tabloId || !user.id || !user.streamToken) { - setHasUnread(false); - return; - } - - const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string; - const client = StreamChat.getInstance(apiKey); - let isMounted = true; - let unsubscribe: (() => void) | undefined; - - const syncUnread = (channel: Channel) => { - if (!isMounted) return; - setHasUnread(channel.countUnread() > 0); - }; - - const init = async () => { - try { - if (!client.userID) { - await client.connectUser( - { - id: user.id, - name: user.name ?? "", - }, - user.streamToken - ); - } else if (client.userID !== user.id) { - await client.disconnectUser(); - await client.connectUser( - { - id: user.id, - name: user.name ?? "", - }, - user.streamToken - ); - } - - const channels = await client.queryChannels( - { - type: "messaging", - id: { $eq: tabloId }, - members: { $in: [user.id] }, - }, - { last_message_at: -1 }, - { watch: true, state: true, presence: false, limit: 1 } - ); - - const channel = channels[0]; - if (!channel) { - setHasUnread(false); - return; - } - - syncUnread(channel); - - const subscriptions = [ - channel.on("message.new", () => syncUnread(channel)), - channel.on("message.read", () => syncUnread(channel)), - channel.on("notification.mark_read", () => syncUnread(channel)), - channel.on("notification.message_new", () => syncUnread(channel)), - ]; - - unsubscribe = () => { - subscriptions.forEach((subscription) => { - subscription.unsubscribe(); - }); - }; - } catch (error) { - console.error("Error loading tablo unread discussion state:", error); - if (isMounted) { - setHasUnread(false); - } - } - }; - - void init(); - - return () => { - isMounted = false; - unsubscribe?.(); - }; - }, [tabloId, user.id, user.name, user.streamToken]); - - return { hasUnread }; -}; diff --git a/apps/main/src/main.tsx b/apps/main/src/main.tsx index 01d1eed..33afd40 100644 --- a/apps/main/src/main.tsx +++ b/apps/main/src/main.tsx @@ -7,7 +7,6 @@ import { App } from "./App"; import "./lib/rum"; import "./i18n"; -import "stream-chat-react/dist/css/v2/index.css"; import "@xtablo/ui/styles/globals.css"; import "./main.css"; diff --git a/apps/main/src/pages/chat.test.tsx b/apps/main/src/pages/chat.test.tsx index 7b80064..abff11f 100644 --- a/apps/main/src/pages/chat.test.tsx +++ b/apps/main/src/pages/chat.test.tsx @@ -3,12 +3,25 @@ import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { ChatPage } from "./chat"; -const mockSetActiveChannel = vi.fn(); +vi.mock("../hooks/useChat", () => ({ + useChat: () => ({ + messages: [], + sendMessage: vi.fn(), + sendTyping: vi.fn(), + isConnected: false, + typingUsers: [], + onlineUsers: [], + loadMoreMessages: vi.fn(), + hasMoreMessages: false, + markAsRead: vi.fn(), + }), +})); -vi.mock("../hooks/channel", () => ({ - useChannelFromUrl: () => ({ - channel: null, - isChannelInUrl: false, +vi.mock("../hooks/useChatUnread", () => ({ + useChatUnread: () => ({ + unreadCounts: [], + getUnreadCount: () => 0, + hasUnread: () => false, }), })); @@ -21,38 +34,6 @@ vi.mock("../hooks/tablos", () => ({ }), })); -vi.mock("../providers/ChatProvider", () => ({ - useChatClient: () => null, - useChatContext: () => ({ - client: { id: "test-client" }, - channel: null, - setActiveChannel: mockSetActiveChannel, - }), -})); - -vi.mock("stream-chat-react", () => ({ - Chat: ({ children }: { children: React.ReactNode }) =>
{children}
, - ChannelList: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Channel: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - ChannelHeader: () =>
Header
, - MessageList: () =>
Messages
, - MessageInput: () =>
Input
, - Window: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - useChannelStateContext: () => ({ channel: null }), - useCreateChatClient: () => null, - useChatContext: () => ({ - client: { id: "test-client" }, - channel: null, - setActiveChannel: mockSetActiveChannel, - }), -})); - describe("ChatPage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -63,49 +44,28 @@ describe("ChatPage", () => { expect(container).toBeInTheDocument(); }); - it("renders channel list", () => { + it("renders the discussions heading", () => { renderWithProviders(); - - expect(screen.getByTestId("channel-list")).toBeInTheDocument(); + expect(screen.getByText("Discussions")).toBeInTheDocument(); }); - it("renders channel window", () => { + it("renders channel previews for each tablo", () => { renderWithProviders(); - - expect(screen.getByTestId("channel")).toBeInTheDocument(); - expect(screen.getByTestId("window")).toBeInTheDocument(); + expect(screen.getByText("Test Tablo 1")).toBeInTheDocument(); + expect(screen.getByText("Test Tablo 2")).toBeInTheDocument(); }); - it("renders message list and input", () => { + it("shows empty state when no channel is selected", () => { renderWithProviders(); - - expect(screen.getByTestId("message-list")).toBeInTheDocument(); - expect(screen.getByTestId("message-input")).toBeInTheDocument(); - }); - - it("applies correct filters for channel list", () => { - renderWithProviders(); - - // ChannelList should be rendered with proper filters - expect(screen.getByTestId("channel-list")).toBeInTheDocument(); + expect(screen.getByText("Select a conversation to start chatting")).toBeInTheDocument(); }); }); -// Note: Testing channel from URL would require re-mocking the hook with different values -// This is better tested with integration tests or by testing the hook separately. - describe("ChatPage - Channel List Toggle", () => { it("starts with channel list expanded when no channel in URL", () => { renderWithProviders(); - - const channelListContainer = screen.getByTestId("channel-list").parentElement; - expect(channelListContainer?.className).toContain("w-80"); - }); - - it("has collapsible channel list", () => { - renderWithProviders(); - - const channelListContainer = screen.getByTestId("channel-list").parentElement; - expect(channelListContainer).toBeInTheDocument(); + // The sidebar should be visible (w-80) + const heading = screen.getByText("Discussions"); + expect(heading).toBeInTheDocument(); }); }); diff --git a/apps/main/src/pages/tablo-details.layout.test.tsx b/apps/main/src/pages/tablo-details.layout.test.tsx index 0576c99..67da71d 100644 --- a/apps/main/src/pages/tablo-details.layout.test.tsx +++ b/apps/main/src/pages/tablo-details.layout.test.tsx @@ -28,9 +28,11 @@ const layoutData: unknown = { rightZone: ["files", "info"], }; -vi.mock("../hooks/channel", () => ({ - useTabloDiscussionUnread: () => ({ - hasUnread: false, +vi.mock("../hooks/useChatUnread", () => ({ + useChatUnread: () => ({ + unreadCounts: [], + getUnreadCount: () => 0, + hasUnread: () => false, }), })); diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 34716d2..ba99556 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -46,7 +46,7 @@ import { TabloDiscussionSection } from "../components/TabloDiscussionSection"; import { TabloEventsSection } from "../components/TabloEventsSection"; import { TabloFilesSection } from "../components/TabloFilesSection"; import { TabloTasksSection } from "../components/TabloTasksSection"; -import { useTabloDiscussionUnread } from "../hooks/channel"; +import { useChatUnread } from "../hooks/useChatUnread"; import { useInviteUser } from "../hooks/invite"; import { useTabloFileNames } from "../hooks/tablo_data"; import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; @@ -173,7 +173,8 @@ export const TabloDetailsPage = () => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { data: tablos, isLoading } = useTablosList(); - const { hasUnread: hasUnreadDiscussion } = useTabloDiscussionUnread(tabloId); + const { hasUnread } = useChatUnread(); + const hasUnreadDiscussion = tabloId ? hasUnread(tabloId) : false; const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState( diff --git a/apps/main/src/providers/ChatProvider.test.tsx b/apps/main/src/providers/ChatProvider.test.tsx deleted file mode 100644 index 35667c4..0000000 --- a/apps/main/src/providers/ChatProvider.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "../utils/testHelpers"; -import ChatProvider from "./ChatProvider"; - -// Mock Stream Chat -vi.mock("stream-chat", () => ({ - StreamChat: { - getInstance: vi.fn(() => ({ - connectUser: vi.fn(), - disconnectUser: vi.fn(), - })), - }, - StateStore: vi.fn(), - FixedSizeQueueCache: vi.fn(), -})); - -vi.mock("stream-chat-react", () => ({ - Chat: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - useCreateChatClient: () => ({ id: "test-client" }), -})); - -vi.mock("@xtablo/shared/contexts/SessionContext", async () => { - const actual = await vi.importActual("@xtablo/shared/contexts/SessionContext"); - return { - ...actual, - useSession: () => ({ - session: { - access_token: "test-token", - }, - }), - }; -}); - -describe("ChatProvider", () => { - it("renders children", () => { - renderWithProviders( - -
Test Child
-
- ); - expect(screen.getByText("Test Child")).toBeInTheDocument(); - }); - - it("renders without crashing", () => { - const { container } = renderWithProviders( - -
Content
-
- ); - expect(container).toBeInTheDocument(); - }); -}); diff --git a/apps/main/src/providers/ChatProvider.tsx b/apps/main/src/providers/ChatProvider.tsx deleted file mode 100644 index 037f953..0000000 --- a/apps/main/src/providers/ChatProvider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { LoadingSpinner } from "@ui/components/LoadingSpinner"; -import { Chat, useCreateChatClient } from "stream-chat-react"; -import { useUser } from "./UserStoreProvider"; - -export default function ChatProvider({ children }: { children: React.ReactNode }) { - const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string; - const user = useUser(); - const client = useCreateChatClient({ - apiKey, - options: { timeout: 5000 }, - tokenOrProvider: user.streamToken, - userData: { - id: user.id, - name: user.name ?? "", - }, - }); - - if (!user.streamToken) { - return ( -
-
-

Chat Indisponible

-

- Impossible de se connecter au service de chat. Veuillez essayer de rafraîchir la page. -

-
-
- ); - } - - if (!client) { - return ; - } - - return ( - - {children} - - ); -} diff --git a/apps/main/src/providers/UserStoreProvider.test.tsx b/apps/main/src/providers/UserStoreProvider.test.tsx index 777a3c8..d445b17 100644 --- a/apps/main/src/providers/UserStoreProvider.test.tsx +++ b/apps/main/src/providers/UserStoreProvider.test.tsx @@ -8,7 +8,6 @@ vi.mock("@tanstack/react-query", () => ({ data: { id: "test-user-id", name: "Test User", - streamToken: null, }, isPending: false, }), @@ -29,7 +28,6 @@ vi.mock("../lib/api", () => ({ data: { id: "test-user-id", name: "Test User", - streamToken: null, }, }) ), @@ -60,7 +58,6 @@ describe("TestUserStoreProvider", () => { const mockUser = { id: "test-user-id", name: "Test User", - streamToken: null, avatar_url: null, email: null, first_name: null, diff --git a/apps/main/src/providers/UserStoreProvider.tsx b/apps/main/src/providers/UserStoreProvider.tsx index 1e88f06..e0d6e5f 100644 --- a/apps/main/src/providers/UserStoreProvider.tsx +++ b/apps/main/src/providers/UserStoreProvider.tsx @@ -7,9 +7,7 @@ import { LoadingSpinner } from "../components/LoadingSpinner"; import { api } from "../lib/api"; import datadogRum from "../lib/rum"; -export type User = Tables<"profiles"> & { - streamToken: string | null; -}; +export type User = Tables<"profiles">; const UserStoreContext = React.createContext | null>(null); diff --git a/apps/main/src/utils/testHelpers.tsx b/apps/main/src/utils/testHelpers.tsx index f9cd0ad..5a49fb2 100644 --- a/apps/main/src/utils/testHelpers.tsx +++ b/apps/main/src/utils/testHelpers.tsx @@ -17,7 +17,6 @@ const defaultUser = { last_name: "Doe", email: "john@example.com", avatar_url: "https://example.com/avatar.jpg", - streamToken: "test-stream-token", is_temporary: false, last_signed_in: null, plan: "none" as const, diff --git a/apps/main/worker-configuration.d.ts b/apps/main/worker-configuration.d.ts index 218a7ca..304cb21 100644 --- a/apps/main/worker-configuration.d.ts +++ b/apps/main/worker-configuration.d.ts @@ -10,7 +10,6 @@ declare namespace Cloudflare { VITE_SUPABASE_URL: string; VITE_SUPABASE_ANON_KEY: string; VITE_SUPABASE_ID: string; - VITE_STREAM_CHAT_API_KEY: string; VITE_API_URL: string; } } diff --git a/packages/shared/package.json b/packages/shared/package.json index f283b7a..2c58350 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -35,9 +35,7 @@ "react-dom": "19.0.0", "react-router-dom": "^7.9.4", "sonner": "^2.0.7", - "stream-chat": "^9.6.1", - "stream-chat-react": "^13.1.0", - "tailwind-merge": "^3.0.2", +"tailwind-merge": "^3.0.2", "ts-pattern": "^5.6.2", "zustand": "^5.0.5" }, diff --git a/packages/shared/src/hooks/auth.ts b/packages/shared/src/hooks/auth.ts index 389d04c..b23ac70 100644 --- a/packages/shared/src/hooks/auth.ts +++ b/packages/shared/src/hooks/auth.ts @@ -12,7 +12,6 @@ interface AuthResponse { export function useSignUpWithoutPassword(supabase: SupabaseClient, api: AxiosInstance) { const [errors, setErrors] = useState>({}); - const { signUpToStream } = useSignUpToStream(api); const { mutateAsync, isPending } = useMutation< AuthResponse, { message: string; code: string }, @@ -35,9 +34,6 @@ export function useSignUpWithoutPassword(supabase: SupabaseClient, api: AxiosIns }, }); if (error) throw error; - if (response.session?.access_token) { - await signUpToStream(response.session.access_token); - } // Mark the user as temporary if (response.session?.access_token) { @@ -81,21 +77,3 @@ export function useSignUpWithoutPassword(supabase: SupabaseClient, api: AxiosIns }); return { mutateAsync, isPending, errors }; } - -export function useSignUpToStream(api: AxiosInstance) { - const { mutate: signUpToStream } = useMutation({ - mutationFn: async (accessToken: string) => { - const { data } = await api.post<{ streamToken: string }>( - "/api/v1/users/sign-up-to-stream", - {}, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - return data; - }, - }); - return { signUpToStream }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcd9b8d..0c66487 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,9 +77,6 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 - stream-chat: - specifier: ^9.8.0 - version: 9.24.0 stripe: specifier: ^20.0.0 version: 20.0.0(@types/node@20.19.23) @@ -374,12 +371,6 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - stream-chat: - specifier: ^9.6.1 - version: 9.24.0 - stream-chat-react: - specifier: ^13.1.0 - version: 13.9.0(@emoji-mart/data@1.2.1)(@types/react@19.0.10)(emoji-mart@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.24.0)(typescript@5.9.3) ts-pattern: specifier: ^5.6.2 version: 5.8.0 @@ -585,12 +576,6 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - stream-chat: - specifier: ^9.6.1 - version: 9.24.0 - stream-chat-react: - specifier: ^13.1.0 - version: 13.9.0(@emoji-mart/data@1.2.1)(@types/react@19.0.10)(emoji-mart@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.24.0)(typescript@5.9.3) tailwind-merge: specifier: ^3.0.2 version: 3.3.1 @@ -1645,9 +1630,6 @@ packages: react: ^18.0 || ^19.0 || >= 19.0.0-rc react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc - '@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: @@ -2883,9 +2865,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@poppinss/colors@4.1.5': resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==} @@ -4431,14 +4410,6 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@stream-io/escape-string-regexp@5.0.1': - resolution: {integrity: sha512-qIaSrzJXieZqo2fZSYTdzwSbZgHHsT3tkd646vvZhh4fr+9nO4NlvqGmPF43Y+OfZiWf+zYDFgNiPGG5+iZulQ==} - engines: {node: '>=12'} - - '@stream-io/transliterate@1.5.5': - resolution: {integrity: sha512-r6Qp0HylAZhHNWHxU1nGfRI2Dtkbs1iqLCnOp1bvKhv8yj0/sEUigN0dk0LGPbE4I7zDO3tppyd7PaTPBvvJkg==} - engines: {node: '>=12'} - '@stripe/stripe-js@8.2.0': resolution: {integrity: sha512-CSfD8HO5lKCEklhkV/WjusWqiU4j8JQl7X69CfslESmkUQ+E9/clmzuUbYnEnvNaFQRbYvryfkht/SpirGb2iA==} engines: {node: '>=12.16'} @@ -4755,9 +4726,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -4791,9 +4759,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -4872,9 +4837,6 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -5052,15 +5014,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@virtuoso.dev/react-urx@0.2.13': - resolution: {integrity: sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16' - - '@virtuoso.dev/urx@0.2.13': - resolution: {integrity: sha512-iirJNv92A1ZWxoOHHDYW/1KPoi83939o83iUBQHIim0i3tMeSKEh+bxhJdTHQ86Mr4uXx9xGUTq69cp52ZP8Xw==} - '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5311,10 +5264,6 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - attr-accept@2.2.5: - resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} - engines: {node: '>=4'} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -5509,9 +5458,6 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -5712,9 +5658,6 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dayjs@1.11.18: - resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} - dc-polyfill@0.1.10: resolution: {integrity: sha512-9iSbB8XZ7aIrhUtWI5ulEOJ+IyUN+axquodHK+bZO4r7HfY/xwmo6I4fYYf+aiDom+WMcN/wnzCz+pKvHDDCug==} engines: {node: '>=12.17'} @@ -6048,9 +5991,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-util-is-identifier-name@3.0.0: - resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - estree-walker@1.0.1: resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} @@ -6158,10 +6098,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-selector@2.1.2: - resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} - engines: {node: '>= 12'} - filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -6177,9 +6113,6 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - fix-webm-duration@1.0.6: - resolution: {integrity: sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==} - flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -6429,9 +6362,6 @@ packages: hast-util-embedded@3.0.0: resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} - hast-util-find-and-replace@5.0.1: - resolution: {integrity: sha512-S12fTskO3Hf2IGCBWXs1UcXT8GEJ3jmvmPZJctkRwfl3a8jnGi8aFYT8kd2zcEH+VE0qcGgKF0ewt5BPAsfIhw==} - hast-util-format@1.1.0: resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} @@ -6465,9 +6395,6 @@ packages: hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - hast-util-to-jsx-runtime@2.3.6: - resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} - hast-util-to-mdast@10.1.2: resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==} @@ -6500,9 +6427,6 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - html-url-attributes@3.0.1: - resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -6599,9 +6523,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inline-style-parser@0.2.4: - resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} - inquirer-checkbox-plus-prompt@1.4.2: resolution: {integrity: sha512-W8/NL9x5A81Oq9ZfbYW5c1LuwtAhc/oB/u9YZZejna0pqrajj27XhnUHygJV0Vn5TvcDy1VJcD2Ld9kTk40dvg==} peerDependencies: @@ -6628,12 +6549,6 @@ packages: iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -6672,9 +6587,6 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -6704,9 +6616,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -6806,11 +6715,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-ws@5.0.0: - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -7070,10 +6974,6 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - jspdf@3.0.3: resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==} @@ -7087,15 +6987,9 @@ packages: just-extend@6.2.0: resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - jws@4.0.0: resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} @@ -7212,9 +7106,6 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} - load-script@1.0.0: - resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -7229,48 +7120,12 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.deburr@4.1.0: - resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} - - lodash.defaultsdeep@4.6.1: - resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.mergewith@4.6.2: - resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} - - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - lodash.throttle@4.1.1: - resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} - - lodash.uniqby@4.7.0: - resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -7372,15 +7227,6 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - - mdast-util-mdx-jsx@3.2.0: - resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} - - mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -7400,9 +7246,6 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7768,9 +7611,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -8210,21 +8050,12 @@ packages: peerDependencies: react: ^19.0.0 - react-dropzone@14.3.8: - resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==} - engines: {node: '>= 10.13'} - peerDependencies: - react: '>= 16.8 || 18.0.0' - react-easy-crop@5.5.3: resolution: {integrity: sha512-iKwFTnAsq+IVuyF6N0Q3zjRx9DG1NMySkwWxVfM/xAOeHYH1vhvM+V2kFiq5HOIQGWouITjfltCx54mbDpMpmA==} peerDependencies: react: '>=16.4.0' react-dom: '>=16.4.0' - react-fast-compare@3.2.2: - resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} - react-hook-form@7.65.0: resolution: {integrity: sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==} engines: {node: '>=18.0.0'} @@ -8252,11 +8083,6 @@ packages: peerDependencies: react: '*' - react-image-gallery@1.2.12: - resolution: {integrity: sha512-JIh85lh0Av/yewseGJb/ycg00Y/weQiZEC/BQueC2Z5jnYILGB6mkxnrOevNhsM2NdZJpvcDekCluhy6uzEoTA==} - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8266,30 +8092,12 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-markdown@9.1.0: - resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - react-number-format@5.4.4: resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} peerDependencies: react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-player@2.10.1: - resolution: {integrity: sha512-ova0jY1Y1lqLYxOehkzbNEju4rFXYVkr5rdGD71nsiG4UKPzRXQPTd3xjoDssheoMNjZ51mjT5ysTrdQ2tEvsg==} - peerDependencies: - react: '>=16.6.0' - - react-popper@2.3.0: - resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==} - peerDependencies: - '@popperjs/core': ^2.0.0 - react: ^16.8.0 || ^17 || ^18 - react-dom: ^16.8.0 || ^17 || ^18 - react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -8352,13 +8160,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-virtuoso@2.19.1: - resolution: {integrity: sha512-zF6MAwujNGy2nJWCx/Df92ay/RnV2Kj4glUZfdyadI4suAn0kAZHB1BeI7yPFVp2iSccLzFlszhakWyr+fJ4Dw==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16 || >=17 || >= 18' - react-dom: '>=16 || >=17 || >= 18' - react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -8770,30 +8571,6 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} - stream-chat-react@13.9.0: - resolution: {integrity: sha512-tcAp4bWjXCPaJzU3KvOBbg3R9wzXAtrAhtX1R5KMauDhe7CkDlicd3/C/nY0lDX64fQtRJt3t1WNgg9NK49Oew==} - peerDependencies: - '@breezystack/lamejs': ^1.2.7 - '@emoji-mart/data': ^1.1.0 - '@emoji-mart/react': ^1.1.0 - emoji-mart: ^5.4.0 - react: ^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0 - react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0 - stream-chat: ^9.22.0 - peerDependenciesMeta: - '@breezystack/lamejs': - optional: true - '@emoji-mart/data': - optional: true - '@emoji-mart/react': - optional: true - emoji-mart: - optional: true - - stream-chat@9.24.0: - resolution: {integrity: sha512-zLtguYRqxeEc/Cjw8Zp00u/wTrqFg4gFPKdj3mvl/Jq1Pt95mY9nMc38KW0GOu/2quIAAar0NNMq8fsXl4jupQ==} - engines: {node: '>=18'} - stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} @@ -8901,12 +8678,6 @@ packages: stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} - style-to-js@1.1.18: - resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} - - style-to-object@1.0.11: - resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -9255,9 +9026,6 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} - unist-builder@4.0.0: - resolution: {integrity: sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==} - unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -9544,9 +9312,6 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - warning@4.0.3: - resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} - wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -11296,8 +11061,6 @@ snapshots: - sugar-high - supports-color - '@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 @@ -12422,8 +12185,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@popperjs/core@2.11.8': {} - '@poppinss/colors@4.1.5': dependencies: kleur: 4.1.5 @@ -14527,15 +14288,6 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@stream-io/escape-string-regexp@5.0.1': - optional: true - - '@stream-io/transliterate@1.5.5': - dependencies: - '@stream-io/escape-string-regexp': 5.0.1 - lodash.deburr: 4.1.0 - optional: true - '@stripe/stripe-js@8.2.0': {} '@supabase/auth-js@2.76.1': @@ -14886,10 +14638,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.8 - '@types/estree@0.0.39': {} '@types/estree@1.0.8': {} @@ -14927,11 +14675,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 22.18.12 - '@types/linkify-it@5.0.0': {} '@types/long@4.0.2': {} @@ -15017,8 +14760,6 @@ snapshots: '@types/trusted-types@2.0.7': {} - '@types/unist@2.0.11': {} - '@types/unist@3.0.3': {} '@types/use-sync-external-store@0.0.6': {} @@ -15242,13 +14983,6 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@virtuoso.dev/react-urx@0.2.13(react@19.0.0)': - dependencies: - '@virtuoso.dev/urx': 0.2.13 - react: 19.0.0 - - '@virtuoso.dev/urx@0.2.13': {} - '@vitejs/plugin-react@4.7.0(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))': dependencies: '@babel/core': 7.28.4 @@ -15539,8 +15273,6 @@ snapshots: atomic-sleep@1.0.0: {} - attr-accept@2.2.5: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -15782,8 +15514,6 @@ snapshots: character-entities@2.0.2: {} - character-reference-invalid@2.0.1: {} - chardet@2.1.1: {} check-error@2.1.1: {} @@ -15966,8 +15696,6 @@ snapshots: date-fns@4.1.0: {} - dayjs@1.11.18: {} - dc-polyfill@0.1.10: {} dd-trace@5.76.0(@openfeature/core@1.9.1)(@openfeature/server-sdk@1.18.0(@openfeature/core@1.9.1)): @@ -16446,8 +16174,6 @@ snapshots: estraverse@5.3.0: {} - estree-util-is-identifier-name@3.0.0: {} - estree-walker@1.0.1: {} estree-walker@2.0.2: {} @@ -16553,10 +16279,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-selector@2.1.2: - dependencies: - tslib: 2.8.1 - filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -16575,8 +16297,6 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - fix-webm-duration@1.0.6: {} - flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -16913,13 +16633,6 @@ snapshots: '@types/hast': 3.0.4 hast-util-is-element: 3.0.0 - hast-util-find-and-replace@5.0.1: - dependencies: - '@types/hast': 3.0.4 - escape-string-regexp: 5.0.0 - hast-util-is-element: 3.0.0 - unist-util-visit-parents: 6.0.2 - hast-util-format@1.1.0: dependencies: '@types/hast': 3.0.4 @@ -17002,26 +16715,6 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: - dependencies: - '@types/estree': 1.0.8 - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.18 - unist-util-position: 5.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - hast-util-to-mdast@10.1.2: dependencies: '@types/hast': 3.0.4 @@ -17077,8 +16770,6 @@ snapshots: dependencies: void-elements: 3.1.0 - html-url-attributes@3.0.1: {} - html-void-elements@3.0.0: {} html-whitespace-sensitive-tag-names@3.0.1: {} @@ -17178,8 +16869,6 @@ snapshots: ini@1.3.8: {} - inline-style-parser@0.2.4: {} - inquirer-checkbox-plus-prompt@1.4.2(inquirer@8.2.7(@types/node@20.19.23)): dependencies: chalk: 4.1.2 @@ -17228,13 +16917,6 @@ snapshots: iron-webcrypto@1.2.1: {} - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -17279,8 +16961,6 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-decimal@2.0.1: {} - is-docker@2.2.1: {} is-extglob@2.1.1: {} @@ -17305,8 +16985,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-hexadecimal@2.0.1: {} - is-interactive@1.0.0: {} is-map@2.0.3: {} @@ -17385,10 +17063,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.18.3): - dependencies: - ws: 8.18.3 - isomorphic.js@0.2.5: {} istanbul-lib-coverage@3.2.2: {} @@ -17872,19 +17546,6 @@ snapshots: jsonpointer@5.0.1: {} - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.3 - jspdf@3.0.3: dependencies: '@babel/runtime': 7.28.4 @@ -17912,23 +17573,12 @@ snapshots: just-extend@6.2.0: {} - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - jws@4.0.0: dependencies: jwa: 2.0.1 @@ -18018,8 +17668,6 @@ snapshots: linkifyjs@4.3.2: {} - load-script@1.0.0: {} - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -18032,35 +17680,10 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.deburr@4.1.0: - optional: true - - lodash.defaultsdeep@4.6.1: {} - - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - lodash.merge@4.6.2: {} - lodash.mergewith@4.6.2: {} - - lodash.once@4.1.1: {} - lodash.sortby@4.7.0: {} - lodash.throttle@4.1.1: {} - - lodash.uniqby@4.7.0: {} - lodash@4.17.21: {} log-symbols@4.1.0: @@ -18212,45 +17835,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -18288,8 +17872,6 @@ snapshots: media-typer@0.3.0: {} - memoize-one@5.2.1: {} - merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -18761,16 +18343,6 @@ snapshots: dependencies: callsites: 3.1.0 - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.2.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - parse-json@5.2.0: dependencies: '@babel/code-frame': 7.27.1 @@ -19250,13 +18822,6 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 - react-dropzone@14.3.8(react@19.0.0): - dependencies: - attr-accept: 2.2.5 - file-selector: 2.1.2 - prop-types: 15.8.1 - react: 19.0.0 - react-easy-crop@5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: normalize-wheel: 1.0.1 @@ -19264,8 +18829,6 @@ snapshots: react-dom: 19.0.0(react@19.0.0) tslib: 2.8.1 - react-fast-compare@3.2.2: {} - react-hook-form@7.65.0(react@19.0.0): dependencies: react: 19.0.0 @@ -19285,56 +18848,17 @@ snapshots: dependencies: react: 19.0.0 - react-image-gallery@1.2.12(react@19.0.0): - dependencies: - react: 19.0.0 - react-is@16.13.1: {} react-is@17.0.2: {} react-is@18.3.1: {} - react-markdown@9.1.0(@types/react@19.0.10)(react@19.0.0): - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/react': 19.0.10 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 - react: 19.0.0 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - react-number-format@5.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - react-player@2.10.1(react@19.0.0): - dependencies: - deepmerge: 4.3.1 - load-script: 1.0.0 - memoize-one: 5.2.1 - prop-types: 15.8.1 - react: 19.0.0 - react-fast-compare: 3.2.2 - - react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - '@popperjs/core': 2.11.8 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - react-fast-compare: 3.2.2 - warning: 4.0.3 - react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.0.10)(react@19.0.0): @@ -19417,13 +18941,6 @@ snapshots: transitivePeerDependencies: - '@types/react' - react-virtuoso@2.19.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - '@virtuoso.dev/react-urx': 0.2.13(react@19.0.0) - '@virtuoso.dev/urx': 0.2.13 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - react@19.0.0: {} readable-stream@2.3.8: @@ -19955,65 +19472,6 @@ snapshots: stoppable@1.1.0: {} - stream-chat-react@13.9.0(@emoji-mart/data@1.2.1)(@types/react@19.0.10)(emoji-mart@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.24.0)(typescript@5.9.3): - dependencies: - '@braintree/sanitize-url': 6.0.4 - '@popperjs/core': 2.11.8 - '@react-aria/focus': 3.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - clsx: 2.1.1 - dayjs: 1.11.18 - emoji-regex: 9.2.2 - fix-webm-duration: 1.0.6 - hast-util-find-and-replace: 5.0.1 - i18next: 25.6.0(typescript@5.9.3) - linkifyjs: 4.3.2 - lodash.debounce: 4.0.8 - lodash.defaultsdeep: 4.6.1 - lodash.mergewith: 4.6.2 - lodash.throttle: 4.1.1 - lodash.uniqby: 4.7.0 - nanoid: 3.3.11 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - react-dropzone: 14.3.8(react@19.0.0) - react-fast-compare: 3.2.2 - react-image-gallery: 1.2.12(react@19.0.0) - react-markdown: 9.1.0(@types/react@19.0.10)(react@19.0.0) - react-player: 2.10.1(react@19.0.0) - react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - react-textarea-autosize: 8.5.9(@types/react@19.0.10)(react@19.0.0) - react-virtuoso: 2.19.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - remark-gfm: 4.0.1 - stream-chat: 9.24.0 - tslib: 2.8.1 - unist-builder: 4.0.0 - unist-util-visit: 5.0.0 - use-sync-external-store: 1.6.0(react@19.0.0) - optionalDependencies: - '@emoji-mart/data': 1.2.1 - '@stream-io/transliterate': 1.5.5 - emoji-mart: 5.6.0 - transitivePeerDependencies: - - '@types/react' - - supports-color - - typescript - - stream-chat@9.24.0: - dependencies: - '@types/jsonwebtoken': 9.0.10 - '@types/ws': 8.18.1 - axios: 1.12.2(debug@4.4.3) - base64-js: 1.5.1 - form-data: 4.0.4 - isomorphic-ws: 5.0.0(ws@8.18.3) - jsonwebtoken: 9.0.2 - linkifyjs: 4.3.2 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - debug - - utf-8-validate - stream-events@1.0.5: dependencies: stubs: 3.0.0 @@ -20140,14 +19598,6 @@ snapshots: stubs@3.0.0: {} - style-to-js@1.1.18: - dependencies: - style-to-object: 1.0.11 - - style-to-object@1.0.11: - dependencies: - inline-style-parser: 0.2.4 - supports-color@10.2.2: {} supports-color@7.2.0: @@ -20506,10 +19956,6 @@ snapshots: dependencies: crypto-random-string: 2.0.0 - unist-builder@4.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -20818,10 +20264,6 @@ snapshots: dependencies: makeerror: 1.0.12 - warning@4.0.3: - dependencies: - loose-envify: 1.4.0 - wcwidth@1.0.1: dependencies: defaults: 1.0.4 From c8db38cf1f5bdc5ad3b13b5a707d8c5bbb107023 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 13:50:29 +0200 Subject: [PATCH 14/62] chore: remove remaining Stream Chat env vars from API config and Cloud Build Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/.env.production | 1 - apps/api/cloudbuild.yaml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/.env.production b/apps/api/.env.production index f55c086..7b3f239 100644 --- a/apps/api/.env.production +++ b/apps/api/.env.production @@ -1,6 +1,5 @@ SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co -STREAM_CHAT_API_KEY=h7bwnn8ynjpx XTABLO_URL=https://app.xtablo.com diff --git a/apps/api/cloudbuild.yaml b/apps/api/cloudbuild.yaml index 9dd6343..e8a7038 100644 --- a/apps/api/cloudbuild.yaml +++ b/apps/api/cloudbuild.yaml @@ -14,7 +14,7 @@ steps: - '--region' - 'europe-west1' - '--set-env-vars' - - 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,STREAM_CHAT_API_KEY=$_STREAM_CHAT_API_KEY,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID' + - 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID' images: - 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA' From 0e8788f32b6777f5d5d4b6a6274583b68bd3c23a Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 14:13:03 +0200 Subject: [PATCH 15/62] fix: resolve remaining Stream Chat references and type errors - Remove useSignUpToStream from hooks/auth.ts and oauth-signin.tsx - Fix useRef initial values in useChat.ts - Remove unused destructured variables in chat.tsx Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/src/hooks/auth.ts | 11 ------ apps/main/src/hooks/useChat.ts | 4 +- apps/main/src/pages/chat.tsx | 5 +-- apps/main/src/pages/oauth-signin.test.tsx | 46 ----------------------- apps/main/src/pages/oauth-signin.tsx | 6 +-- 5 files changed, 4 insertions(+), 68 deletions(-) diff --git a/apps/main/src/hooks/auth.ts b/apps/main/src/hooks/auth.ts index c0fc474..5ddb847 100644 --- a/apps/main/src/hooks/auth.ts +++ b/apps/main/src/hooks/auth.ts @@ -1,7 +1,6 @@ import { Session, User as SupabaseUser } from "@supabase/supabase-js"; import { useMutation } from "@tanstack/react-query"; import { queryClient, toast, useSession } from "@xtablo/shared"; -import { useSignUpToStream } from "@xtablo/shared/hooks/auth"; import { AxiosInstance } from "axios"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -53,7 +52,6 @@ export const resolveSignupBillingIntent = ( export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { const navigate = useNavigate(); const [errors, setErrors] = useState>({}); - const { signUpToStream } = useSignUpToStream(api); const { mutate, isPending } = useMutation< AuthResponse, { message: string; code: string }, @@ -74,11 +72,6 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { }, }); if (error) throw error; - - // Only sign up to stream if user is immediately confirmed (auto-confirm enabled in Supabase) - if (response.session?.access_token) { - await signUpToStream(response.session.access_token); - } return response; }, onSuccess: async (data, variables) => { @@ -168,7 +161,6 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) { const navigate = useNavigate(); const [errors, setErrors] = useState>({}); - const { signUpToStream } = useSignUpToStream(api); const { mutate, isPending } = useMutation< AuthResponse, { message: string; code: string }, @@ -180,9 +172,6 @@ export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) { password: data.password.trim(), }); if (error) throw error; - if (response.session?.access_token) { - await signUpToStream(response.session.access_token); - } return response; }, onSuccess: (data) => { diff --git a/apps/main/src/hooks/useChat.ts b/apps/main/src/hooks/useChat.ts index 1f5e9a2..96b223c 100644 --- a/apps/main/src/hooks/useChat.ts +++ b/apps/main/src/hooks/useChat.ts @@ -32,8 +32,8 @@ export function useChat(channelId: string | undefined) { const wsRef = useRef(null); const reconnectAttemptRef = useRef(0); - const reconnectTimerRef = useRef>(); - const typingTimerRef = useRef>(); + const reconnectTimerRef = useRef>(undefined); + const typingTimerRef = useRef>(undefined); const isTypingRef = useRef(false); // Fetch message history from REST endpoint diff --git a/apps/main/src/pages/chat.tsx b/apps/main/src/pages/chat.tsx index 726b9ba..66f8708 100644 --- a/apps/main/src/pages/chat.tsx +++ b/apps/main/src/pages/chat.tsx @@ -27,11 +27,8 @@ export function ChatPage() { messages, sendMessage, sendTyping, - isConnected, typingUsers, onlineUsers, - loadMoreMessages, - hasMoreMessages, markAsRead, } = useChat(channelId); @@ -44,7 +41,7 @@ export function ChatPage() { } }, [channelId, messages.length, markAsRead]); - const handleSend = (innerHtml: string, textContent: string) => { + const handleSend = (_innerHtml: string, textContent: string) => { const text = textContent.trim(); if (!text) return; sendMessage(text); diff --git a/apps/main/src/pages/oauth-signin.test.tsx b/apps/main/src/pages/oauth-signin.test.tsx index a84a033..c3c4c4b 100644 --- a/apps/main/src/pages/oauth-signin.test.tsx +++ b/apps/main/src/pages/oauth-signin.test.tsx @@ -2,14 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { OAuthSigninPage } from "./oauth-signin"; -const mockSignUpToStream = vi.fn(); - -vi.mock("@xtablo/shared/hooks/auth", () => ({ - useSignUpToStream: () => ({ - signUpToStream: mockSignUpToStream, - }), -})); - vi.mock("../lib/api", () => ({ api: {}, })); @@ -30,44 +22,6 @@ describe("OAuthSigninPage", () => { expect(container).toBeInTheDocument(); }); - it.skip("renders empty component", () => { - const { container } = renderWithProviders(); - expect(container.firstChild).toBeEmptyDOMElement(); - }); - - it.skip("navigates to home when session exists without redirectUrl", () => { - renderWithProviders(); - - vi.advanceTimersByTime(150); - - expect(mockSignUpToStream).toHaveBeenCalled(); - }); - - it.skip("navigates to redirectUrl when session exists with redirectUrl", () => { - localStorage.setItem("redirectUrl", "/dashboard"); - renderWithProviders(); - - vi.advanceTimersByTime(150); - - expect(mockSignUpToStream).toHaveBeenCalled(); - expect(localStorage.getItem("redirectUrl")).toBeNull(); - }); - - it.skip("decodes redirectUrl before navigation", () => { - localStorage.setItem("redirectUrl", "%2Fdashboard%2Ftest"); - renderWithProviders(); - - vi.advanceTimersByTime(150); - }); - - it("signs up to stream with access token", () => { - renderWithProviders(); - - vi.advanceTimersByTime(150); - - expect(mockSignUpToStream).toHaveBeenCalled(); - }); - it("clears interval on unmount", () => { const { unmount } = renderWithProviders(); diff --git a/apps/main/src/pages/oauth-signin.tsx b/apps/main/src/pages/oauth-signin.tsx index fd4d0ef..a64c749 100644 --- a/apps/main/src/pages/oauth-signin.tsx +++ b/apps/main/src/pages/oauth-signin.tsx @@ -1,18 +1,14 @@ import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import { useSignUpToStream } from "@xtablo/shared/hooks/auth"; import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { api } from "../lib/api"; export const OAuthSigninPage = () => { const navigate = useNavigate(); const { session } = useSession(); - const { signUpToStream } = useSignUpToStream(api); const redirectUrl = localStorage.getItem("redirectUrl"); useEffect(() => { const interval = setInterval(() => { if (session) { - signUpToStream(session.access_token); if (redirectUrl) { localStorage.removeItem("redirectUrl"); navigate(decodeURIComponent(redirectUrl)); @@ -22,6 +18,6 @@ export const OAuthSigninPage = () => { } }, 100); return () => clearInterval(interval); - }, [navigate, session, signUpToStream]); + }, [navigate, session]); return <>; }; From f182fff68e2bb7f6686728a40779ed924157cca3 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 15:41:52 +0200 Subject: [PATCH 16/62] fix: use text type for channel_id to match tablos.id column type Co-Authored-By: Claude Opus 4.6 (1M context) --- supabase/migrations/20260411_create_chat_tables.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/migrations/20260411_create_chat_tables.sql b/supabase/migrations/20260411_create_chat_tables.sql index 63eee12..cb092ce 100644 --- a/supabase/migrations/20260411_create_chat_tables.sql +++ b/supabase/migrations/20260411_create_chat_tables.sql @@ -3,7 +3,7 @@ -- Messages table CREATE TABLE IF NOT EXISTS messages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + channel_id text NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES auth.users(id), text text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), @@ -16,7 +16,7 @@ CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, -- Read state table CREATE TABLE IF NOT EXISTS channel_read_state ( user_id uuid NOT NULL REFERENCES auth.users(id), - channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + channel_id text NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, last_read_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, channel_id) ); From be7c6d48891b96d1a4aca6af6a928bb379678657 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 16:01:57 +0200 Subject: [PATCH 17/62] chore: use single chat worker domain for staging and production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single DB means single chat worker — both frontends connect to chat.xtablo.com. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/chat-worker/package.json | 2 -- apps/chat-worker/wrangler.toml | 4 ---- apps/main/.env.staging | 4 ++-- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/chat-worker/package.json b/apps/chat-worker/package.json index 9230b22..d0fefce 100644 --- a/apps/chat-worker/package.json +++ b/apps/chat-worker/package.json @@ -6,8 +6,6 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", - "deploy:staging": "wrangler deploy --env staging", - "deploy:prod": "wrangler deploy --env production", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/chat-worker/wrangler.toml b/apps/chat-worker/wrangler.toml index ee06486..5f244bc 100644 --- a/apps/chat-worker/wrangler.toml +++ b/apps/chat-worker/wrangler.toml @@ -21,8 +21,4 @@ SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co" # SUPABASE_SERVICE_ROLE_KEY # JWT_SECRET -[env.staging] -route = { pattern = "chat-staging.xtablo.com", custom_domain = true } - -[env.production] route = { pattern = "chat.xtablo.com", custom_domain = true } diff --git a/apps/main/.env.staging b/apps/main/.env.staging index 57c4288..a6bb6b9 100644 --- a/apps/main/.env.staging +++ b/apps/main/.env.staging @@ -4,8 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd -VITE_CHAT_WS_URL=wss://chat-staging.xtablo.com -VITE_CHAT_API_URL=https://chat-staging.xtablo.com +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0 VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva From 6ea66c451bdcc1251de03eada0d98b7362e71c3f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 16:06:34 +0200 Subject: [PATCH 18/62] fix: use new_sqlite_classes for Durable Objects (required on free plan) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/chat-worker/wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/chat-worker/wrangler.toml b/apps/chat-worker/wrangler.toml index 5f244bc..522aa9d 100644 --- a/apps/chat-worker/wrangler.toml +++ b/apps/chat-worker/wrangler.toml @@ -9,7 +9,7 @@ bindings = [ [[migrations]] tag = "v1" -new_classes = ["ChatRoom"] +new_sqlite_classes = ["ChatRoom"] [observability] enabled = true From daa549a036054df99dd73ea6aa39643b0001e6e0 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 16:12:46 +0200 Subject: [PATCH 19/62] fix: use [[routes]] array syntax for custom domain in wrangler.toml Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/chat-worker/wrangler.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/chat-worker/wrangler.toml b/apps/chat-worker/wrangler.toml index 522aa9d..8e63f10 100644 --- a/apps/chat-worker/wrangler.toml +++ b/apps/chat-worker/wrangler.toml @@ -21,4 +21,6 @@ SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co" # SUPABASE_SERVICE_ROLE_KEY # JWT_SECRET -route = { pattern = "chat.xtablo.com", custom_domain = true } +[[routes]] +pattern = "chat.xtablo.com" +custom_domain = true From bb9550dd398f862496127e381a535d92d984ecc2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 16:25:37 +0200 Subject: [PATCH 20/62] fix: resolve pre-existing type errors in test files blocking staging build Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/src/components/SubscriptionCard.test.tsx | 2 +- apps/main/src/contexts/UpgradeBlockContext.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/main/src/components/SubscriptionCard.test.tsx b/apps/main/src/components/SubscriptionCard.test.tsx index 15d3c47..5cbc02b 100644 --- a/apps/main/src/components/SubscriptionCard.test.tsx +++ b/apps/main/src/components/SubscriptionCard.test.tsx @@ -95,7 +95,7 @@ describe("SubscriptionCard", () => { it("shows Founder badge and unlimited info for annual plan", () => { const founderOrg = { ...baseOrg, active_subscription_plan: "annual" as const }; - const founderUser = { ...baseUser, plan: "annual" as const }; + const founderUser = { ...baseUser, plan: "standard" as const }; renderCard(founderUser, founderOrg); expect(screen.getByText("Founder")).toBeInTheDocument(); expect(screen.getByText(/Plan Founder \(annuel\)/)).toBeInTheDocument(); diff --git a/apps/main/src/contexts/UpgradeBlockContext.test.tsx b/apps/main/src/contexts/UpgradeBlockContext.test.tsx index a4df341..aa90574 100644 --- a/apps/main/src/contexts/UpgradeBlockContext.test.tsx +++ b/apps/main/src/contexts/UpgradeBlockContext.test.tsx @@ -58,7 +58,7 @@ function renderWithUser(user: User | null, orgData: ReturnType); + } as unknown as ReturnType); return render( @@ -164,7 +164,7 @@ describe("useMaybeUpgradeBlock", () => { data: compliantOrgData, isLoading: false, error: null, - } as ReturnType); + } as unknown as ReturnType); render( From 28adda9710ee27cadda58a07928f95b98663717f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 16:31:03 +0200 Subject: [PATCH 21/62] chore: add deploy:chat script to root package.json Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e181a97..50b96ef 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev:api": "turbo dev --filter=@xtablo/api", "deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main", "deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main", + "deploy:chat": "turbo deploy --filter=@xtablo/chat-worker", "deploy:external": "turbo deploy --filter=@xtablo/external", "lint": "turbo lint", "lint:fix": "turbo lint:fix", From 513aa0a3169fc73fb2a5141c4172e926469538e2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 16:58:31 +0200 Subject: [PATCH 22/62] fix: use fetch() instead of RPC for DO WebSocket upgrades DO RPC doesn't support WebSocket upgrade requests. Forward the request via stub.fetch() and pass userId/channelId via custom headers. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/chat-worker/src/durable-objects/ChatRoom.ts | 13 ++++++++++++- apps/chat-worker/src/index.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/chat-worker/src/durable-objects/ChatRoom.ts b/apps/chat-worker/src/durable-objects/ChatRoom.ts index 8c1982c..95e4e7f 100644 --- a/apps/chat-worker/src/durable-objects/ChatRoom.ts +++ b/apps/chat-worker/src/durable-objects/ChatRoom.ts @@ -12,7 +12,18 @@ export class ChatRoom extends DurableObject { return this.postgrest; } - async handleWebSocket(request: Request, userId: string, channelId: string): Promise { + /** + * Handle incoming fetch requests — WebSocket upgrades are forwarded here by the Worker. + * userId and channelId are passed via custom headers set by the Worker. + */ + async fetch(request: Request): Promise { + const userId = request.headers.get("X-User-Id"); + const channelId = request.headers.get("X-Channel-Id"); + + if (!userId || !channelId) { + return new Response("Missing user or channel identity", { status: 400 }); + } + const pair = new WebSocketPair(); const [client, server] = [pair[0], pair[1]]; diff --git a/apps/chat-worker/src/index.ts b/apps/chat-worker/src/index.ts index ec03c56..5eb2a65 100644 --- a/apps/chat-worker/src/index.ts +++ b/apps/chat-worker/src/index.ts @@ -70,7 +70,14 @@ app.get("/chat/ws/:channelId", async (c) => { const id = c.env.CHAT_ROOM.idFromName(channelId); const stub = c.env.CHAT_ROOM.get(id); - return (stub as any).handleWebSocket(c.req.raw, userId, channelId); + + // Forward the WebSocket upgrade via fetch — DO RPC doesn't support WebSocket upgrades. + // Pass userId and channelId via headers so the DO can read them. + const url = new URL(c.req.url); + const doRequest = new Request(url.toString(), c.req.raw); + doRequest.headers.set("X-User-Id", userId); + doRequest.headers.set("X-Channel-Id", channelId); + return stub.fetch(doRequest); }); // GET message history — paginated From 76d4db1ea1f3acd0695e88a466c7738e16178836 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 17:01:00 +0200 Subject: [PATCH 23/62] chore: add turbo.json for chat-worker deploy task Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/chat-worker/turbo.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/chat-worker/turbo.json diff --git a/apps/chat-worker/turbo.json b/apps/chat-worker/turbo.json new file mode 100644 index 0000000..1188f54 --- /dev/null +++ b/apps/chat-worker/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "deploy": { + "passThroughEnv": [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_TOKEN" + ], + "cache": false, + "outputLogs": "new-only" + } + } +} From 3b1d8bd2e574560ceb5762fe6c898a45e97991e6 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 17:16:54 +0200 Subject: [PATCH 24/62] fix: make chat discussion fill full viewport height in tablo detail view Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/src/components/TabloDiscussionSection.tsx | 2 +- apps/main/src/pages/tablo-details.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/main/src/components/TabloDiscussionSection.tsx b/apps/main/src/components/TabloDiscussionSection.tsx index a3b2881..70a1d1b 100644 --- a/apps/main/src/components/TabloDiscussionSection.tsx +++ b/apps/main/src/components/TabloDiscussionSection.tsx @@ -43,7 +43,7 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio }; return ( -
+

Discussion

diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index ba99556..eb1121f 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -380,7 +380,7 @@ export const TabloDetailsPage = () => { }; return ( -
+
{/* ── Header ──────────────────────────────────────────────────────── */}
@@ -500,7 +500,7 @@ export const TabloDetailsPage = () => {
{/* ── Tab content ─────────────────────────────────────────────────── */} -
+
{activeSection === "overview" && (() => { const overviewBlocks: Record = { @@ -786,7 +786,9 @@ export const TabloDetailsPage = () => { {activeSection === "tasks" && } {activeSection === "files" && } {activeSection === "discussion" && ( - +
+ +
)} {activeSection === "events" && } From fe001b7fc2bd8b8f1810e26e57fabf2a5e28dadd Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Apr 2026 17:32:13 +0200 Subject: [PATCH 25/62] feat(chat): improve chat UI with date separators, sender names, and message alignment - Add shared ChatMessages component with date separators (Aujourd'hui, Hier, etc.) - Show sender name and avatar on incoming messages - Own messages aligned to the right, others to the left - Show message timestamps on each message - Typing indicator shows member names - Optimistic messages shown with reduced opacity Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/src/components/ChatMessages.tsx | 183 ++++++++++++++++++ .../src/components/TabloDiscussionSection.tsx | 58 ++---- apps/main/src/pages/chat.test.tsx | 1 + apps/main/src/pages/chat.tsx | 59 ++---- 4 files changed, 215 insertions(+), 86 deletions(-) create mode 100644 apps/main/src/components/ChatMessages.tsx diff --git a/apps/main/src/components/ChatMessages.tsx b/apps/main/src/components/ChatMessages.tsx new file mode 100644 index 0000000..69fb22c --- /dev/null +++ b/apps/main/src/components/ChatMessages.tsx @@ -0,0 +1,183 @@ +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; +import { + Avatar, + ChatContainer, + Message, + MessageInput, + MessageList, + MessageSeparator, + TypingIndicator, +} from "@chatscope/chat-ui-kit-react"; +import { useMemo } from "react"; + +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + optimistic?: boolean; +} + +interface Member { + id: string; + name: string; + avatar_url: string | null; +} + +interface ChatMessagesProps { + messages: ChatMessage[]; + currentUserId: string; + members: Member[]; + typingUsers: string[]; + hasMoreMessages: boolean; + onLoadMore?: () => void; + onSend: (text: string) => void; + onTyping: () => void; + placeholder?: string; +} + +function formatDateSeparator(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = date.toDateString() === yesterday.toDateString(); + + if (isToday) return "Aujourd'hui"; + if (isYesterday) return "Hier"; + + return date.toLocaleDateString("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); +} + +function formatTime(dateStr: string): string { + return new Date(dateStr).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + }); +} + +function getInitials(name: string): string { + return name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + +export function ChatMessages({ + messages, + currentUserId, + members, + typingUsers, + hasMoreMessages, + onLoadMore, + onSend, + onTyping, + placeholder = "Envoyer un message...", +}: ChatMessagesProps) { + const membersById = useMemo(() => { + const map = new Map(); + for (const m of members) { + map.set(m.id, m); + } + return map; + }, [members]); + + const getMemberName = (userId: string) => + membersById.get(userId)?.name ?? "Utilisateur"; + + const typingContent = useMemo(() => { + if (typingUsers.length === 0) return null; + const names = typingUsers.map(getMemberName); + if (names.length === 1) return `${names[0]} écrit...`; + return `${names.join(", ")} écrivent...`; + }, [typingUsers, membersById]); + + // Build messages with date separators + const elements = useMemo(() => { + const result: React.ReactNode[] = []; + let lastDate = ""; + + for (const msg of messages) { + const msgDate = new Date(msg.createdAt).toDateString(); + if (msgDate !== lastDate) { + lastDate = msgDate; + result.push( + + {formatDateSeparator(msg.createdAt)} + + ); + } + + const isOwn = msg.userId === currentUserId; + const sender = getMemberName(msg.userId); + const member = membersById.get(msg.userId); + + result.push( + + {!isOwn && ( + + {!member?.avatar_url && ( +
+ {getInitials(sender)} +
+ )} +
+ )} + +
+ ); + } + + return result; + }, [messages, currentUserId, membersById]); + + const handleSend = (_innerHtml: string, textContent: string) => { + const text = textContent.trim(); + if (!text) return; + onSend(text); + }; + + return ( + + : undefined + } + onYReachStart={hasMoreMessages ? onLoadMore : undefined} + > + {elements} + + + + ); +} diff --git a/apps/main/src/components/TabloDiscussionSection.tsx b/apps/main/src/components/TabloDiscussionSection.tsx index 70a1d1b..c191485 100644 --- a/apps/main/src/components/TabloDiscussionSection.tsx +++ b/apps/main/src/components/TabloDiscussionSection.tsx @@ -1,15 +1,9 @@ -import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; -import { - ChatContainer, - Message, - MessageInput, - MessageList, - TypingIndicator, -} from "@chatscope/chat-ui-kit-react"; import { UserTablo } from "@xtablo/shared/types/tablos.types"; import { useEffect } from "react"; import { useChat } from "../hooks/useChat"; +import { useTabloMembers } from "../hooks/tablos"; import { useUser } from "../providers/UserStoreProvider"; +import { ChatMessages } from "./ChatMessages"; import { TabloHeaderActions } from "./TabloHeaderActions"; interface TabloDiscussionSectionProps { @@ -29,6 +23,8 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio markAsRead, } = useChat(tablo.id); + const { data: members = [] } = useTabloMembers(tablo.id); + // Mark as read when opening the discussion useEffect(() => { if (messages.length > 0) { @@ -36,15 +32,9 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio } }, [messages.length, markAsRead]); - const handleSend = (_innerHtml: string, textContent: string) => { - const text = textContent.trim(); - if (!text) return; - sendMessage(text); - }; - return (
-
+

Discussion

Conversations liées à ce tablo

@@ -53,34 +43,16 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
- - 0 ? ( - - ) : null - } - onYReachStart={hasMoreMessages ? loadMoreMessages : undefined} - > - {messages.map((msg) => ( - - ))} - - - +
); diff --git a/apps/main/src/pages/chat.test.tsx b/apps/main/src/pages/chat.test.tsx index abff11f..dbc8780 100644 --- a/apps/main/src/pages/chat.test.tsx +++ b/apps/main/src/pages/chat.test.tsx @@ -32,6 +32,7 @@ vi.mock("../hooks/tablos", () => ({ { id: "tablo-2", name: "Test Tablo 2" }, ], }), + useTabloMembers: () => ({ data: [] }), })); describe("ChatPage", () => { diff --git a/apps/main/src/pages/chat.tsx b/apps/main/src/pages/chat.tsx index 66f8708..ea61187 100644 --- a/apps/main/src/pages/chat.tsx +++ b/apps/main/src/pages/chat.tsx @@ -1,18 +1,11 @@ -import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; -import { - ChatContainer, - MessageList, - 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 { ChatMessages } from "../components/ChatMessages"; import { useChat } from "../hooks/useChat"; import { useChatUnread } from "../hooks/useChatUnread"; -import { useTablosList } from "../hooks/tablos"; +import { useTablosList, useTabloMembers } from "../hooks/tablos"; import { useUser } from "../providers/UserStoreProvider"; export function ChatPage() { @@ -29,9 +22,12 @@ export function ChatPage() { sendTyping, typingUsers, onlineUsers, + loadMoreMessages, + hasMoreMessages, markAsRead, } = useChat(channelId); + const { data: members = [] } = useTabloMembers(channelId ?? ""); const activeTablo = tablos?.find((t) => t.id === channelId) ?? null; // Mark as read when channel is focused @@ -41,12 +37,6 @@ export function ChatPage() { } }, [channelId, messages.length, markAsRead]); - const handleSend = (_innerHtml: string, textContent: string) => { - const text = textContent.trim(); - if (!text) return; - sendMessage(text); - }; - const handleChannelSelect = (tabloId: string) => { navigate(`/chat/${tabloId}`); }; @@ -88,34 +78,17 @@ export function ChatPage() { onlineUsers={onlineUsers} />
- - 0 ? ( - - ) : undefined - } - > - {messages.map((msg) => ( - - ))} - - sendTyping()} - attachButton={false} - /> - +
) : ( From 517526ef99898a7b78897522788608d0fb2765c1 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Apr 2026 12:06:52 +0200 Subject: [PATCH 26/62] docs: add chatcn integration design spec and implementation plan Spec and plan for integrating chatcn as @xtablo/chat-ui package, replacing chatscope for the chat UI rendering layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-12-chatcn-integration.md | 946 ++++++++++++++++++ .../2026-04-12-chatcn-integration-design.md | 187 ++++ 2 files changed, 1133 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-chatcn-integration.md create mode 100644 docs/superpowers/specs/2026-04-12-chatcn-integration-design.md diff --git a/docs/superpowers/plans/2026-04-12-chatcn-integration.md b/docs/superpowers/plans/2026-04-12-chatcn-integration.md new file mode 100644 index 0000000..53fd544 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-chatcn-integration.md @@ -0,0 +1,946 @@ +# chatcn Integration as @xtablo/chat-ui Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace chatscope with chatcn as a new `@xtablo/chat-ui` workspace package, adapting theming to xtablo's design tokens. + +**Architecture:** Copy chatcn's 7 source files into `packages/chat-ui/src/`, remap imports to use `@xtablo/shared` and `@xtablo/ui`, replace all `var(--chat-*)` CSS variables with Tailwind utility classes mapped to xtablo's existing design tokens. Then rewrite `ChatMessages.tsx` in the main app to consume the new package. + +**Tech Stack:** React 19, Tailwind CSS 4, TypeScript 5, Turborepo/pnpm workspaces + +--- + +## File Map + +**New files (packages/chat-ui/):** +- `packages/chat-ui/package.json` — Package manifest +- `packages/chat-ui/tsconfig.json` — TypeScript config +- `packages/chat-ui/src/index.ts` — Barrel export (copied from chatcn, unchanged) +- `packages/chat-ui/src/types.ts` — Type definitions (copied from chatcn, `ChatTheme` removed) +- `packages/chat-ui/src/hooks.ts` — Hooks (copied from chatcn, import remapped) +- `packages/chat-ui/src/security.ts` — Security utils (copied from chatcn, unchanged) +- `packages/chat-ui/src/components/chat.tsx` — Core components (copied, imports + theme adapted) +- `packages/chat-ui/src/components/features.tsx` — Feature components (copied, imports + theme adapted) +- `packages/chat-ui/src/components/layouts.tsx` — Layout components (copied, imports + theme adapted) +- `packages/chat-ui/src/chat-ui.css` — Minimal CSS for animations and utility classes + +**Modified files:** +- `apps/main/package.json` — Remove chatscope deps, add `@xtablo/chat-ui` +- `apps/main/src/components/ChatMessages.tsx` — Rewrite to use `@xtablo/chat-ui` + +--- + +## Complete Token Mapping Reference + +Every `var(--chat-*)` occurrence in chatcn is replaced with a Tailwind class. This table is the single source of truth for all theme adaptation across Tasks 4-6. + +| chatcn CSS variable | Tailwind class (as bg-) | Tailwind class (as text-) | Tailwind class (as border-) | Notes | +|---|---|---|---|---| +| `--chat-bg-app` | `bg-background` | — | — | | +| `--chat-bg-main` | `bg-background` | — | — | | +| `--chat-bg-sidebar` | `bg-card` | — | — | Using `card` for secondary surfaces | +| `--chat-bg-header` | `bg-card` | — | — | With `backdrop-blur` kept | +| `--chat-bg-composer` | `bg-card` | — | — | With `backdrop-blur` kept | +| `--chat-bg-code` | `bg-muted` | — | — | Code block backgrounds | +| `--chat-bg-hover` | `bg-accent` | — | — | Hover states | +| `--chat-bg-content-card` | `bg-card` | — | — | Embedded content cards | +| `--chat-bubble-outgoing` | `bg-primary` | — | — | | +| `--chat-bubble-outgoing-text` | — | `text-primary-foreground` | — | | +| `--chat-bubble-incoming` | `bg-muted` | — | — | | +| `--chat-bubble-incoming-text` | — | `text-foreground` | — | | +| `--chat-accent` | `bg-primary` | `text-primary` | — | chatcn accent = xtablo primary | +| `--chat-accent-soft` | `bg-accent` | — | — | xtablo `accent` is the soft/subtle bg | +| `--chat-text-primary` | — | `text-foreground` | — | | +| `--chat-text-secondary` | — | `text-muted-foreground` | — | | +| `--chat-text-tertiary` | — | `text-muted-foreground/60` | — | Slightly more faded | +| `--chat-border` | — | — | `border-border` | | +| `--chat-border-strong` | — | — | `border-border` | Same token, stronger is unnecessary | +| `--chat-red` | — | `text-destructive` | — | | +| `--chat-orange` | — | `text-orange-500` | — | No xtablo token; use Tailwind orange | +| `--chat-green` | — | `text-green-500` | — | Presence indicator; use Tailwind green | +| `--chat-font-mono` | `font-mono` | — | — | Tailwind built-in | +| `--chat-shadow-lg` | `shadow-lg` | — | — | Tailwind built-in | +| `--chat-shadow-md` | `shadow-md` | — | — | Tailwind built-in | +| `--chat-shadow-toolbar` | `shadow-md` | — | — | | + +--- + +### Task 1: Create package scaffold + +**Files:** +- Create: `packages/chat-ui/package.json` +- Create: `packages/chat-ui/tsconfig.json` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@xtablo/chat-ui", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks": "./src/hooks.ts", + "./security": "./src/security.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "dependencies": { + "@xtablo/shared": "workspace:*", + "@xtablo/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "lucide-react": "*", + "date-fns": "*" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Copy verbatim from `packages/ui/tsconfig.json`: + +```json +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": ["es2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Run pnpm install to register the new workspace package** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm install` +Expected: lockfile updated, `@xtablo/chat-ui` recognized as workspace package + +- [ ] **Step 4: Commit** + +```bash +git add packages/chat-ui/package.json packages/chat-ui/tsconfig.json pnpm-lock.yaml +git commit -m "feat(chat-ui): scaffold @xtablo/chat-ui package" +``` + +--- + +### Task 2: Copy non-component source files (types, hooks, security) + +**Files:** +- Create: `packages/chat-ui/src/types.ts` +- Create: `packages/chat-ui/src/hooks.ts` +- Create: `packages/chat-ui/src/security.ts` +- Source: `chatcn/src/components/ui/chat/types.ts`, `hooks.ts`, `security.ts` + +- [ ] **Step 1: Copy types.ts from chatcn and remove ChatTheme** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/types.ts` to `packages/chat-ui/src/types.ts`. + +Remove the last line: +```typescript +export type ChatTheme = "lunar" | "aurora" | "ember" | "midnight" +``` + +No other changes needed — this file has no imports. + +- [ ] **Step 2: Copy security.ts from chatcn** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/security.ts` to `packages/chat-ui/src/security.ts`. + +No changes needed — this file has no imports from chatcn internals. + +- [ ] **Step 3: Copy hooks.ts from chatcn and fix the "use client" directive** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/hooks.ts` to `packages/chat-ui/src/hooks.ts`. + +Remove the `"use client"` directive at the top (not needed in a Vite app — it's a Next.js RSC directive). + +The import `from "./types"` stays — it's a relative import within the same package. + +- [ ] **Step 4: Verify types compile** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/chat-ui typecheck` +Expected: no type errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/chat-ui/src/types.ts packages/chat-ui/src/hooks.ts packages/chat-ui/src/security.ts +git commit -m "feat(chat-ui): add types, hooks, and security utilities from chatcn" +``` + +--- + +### Task 3: Create chat-ui.css with animations and utility classes + +**Files:** +- Create: `packages/chat-ui/src/chat-ui.css` + +chatcn uses several CSS classes for animations and utility styles that aren't Tailwind classes. These need a small CSS file. + +- [ ] **Step 1: Create chat-ui.css** + +```css +/* @xtablo/chat-ui — Animations and utility classes */ + +/* ─── Message entry ─────────────────────────────────────────────── */ +@keyframes chat-message-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ─── Toolbar entrance ──────────────────────────────────────────── */ +@keyframes chat-toolbar-enter { + from { opacity: 0; transform: scale(0.95) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* ─── Reaction pop ──────────────────────────────────────────────── */ +@keyframes chat-reaction-pop { + 0% { transform: scale(0); opacity: 0; } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } +} + +/* ─── Typing indicator dots ─────────────────────────────────────── */ +@keyframes chat-typing-pulse { + 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-4px); } +} + +/* ─── Cursor blink (streaming) ──────────────────────────────────── */ +@keyframes chat-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ─── Read receipt status color transition ───────────────────────── */ +@keyframes chat-status-read-in { + from { color: var(--color-muted-foreground); } + to { color: var(--color-primary); } +} + +/* ─── Utility classes ───────────────────────────────────────────── */ +@layer base { + .chat-message { + animation: chat-message-enter 250ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-typing-dot { + animation: chat-typing-pulse 1.4s ease-in-out infinite; + } + + .chat-toolbar-enter { + transform-origin: bottom center; + } + + .group\/message:hover .chat-toolbar-enter { + animation: chat-toolbar-enter 150ms ease-out; + } + + .chat-reaction-pop { + animation: chat-reaction-pop 200ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-status-read { + animation: chat-status-read-in 400ms ease-out; + } + + .chat-streaming-cursor { + animation: chat-cursor-blink 1s step-end infinite; + } + + .chat-content-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; + } + + .chat-drop-overlay { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in oklch, var(--color-background) 80%, transparent); + border: 2px dashed var(--color-primary); + border-radius: 12px; + backdrop-filter: blur(4px); + } +} + +/* ─── Reduced motion ────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .chat-message, + .chat-typing-dot, + .chat-toolbar-enter, + .chat-reaction-pop, + .chat-status-read { + animation: none; + } +} +``` + +Note: This CSS uses `var(--color-*)` (Tailwind's theme inline variables, e.g., `--color-primary` maps to xtablo's `--primary`). This is how you reference design tokens from CSS in Tailwind v4. + +- [ ] **Step 2: Commit** + +```bash +git add packages/chat-ui/src/chat-ui.css +git commit -m "feat(chat-ui): add animation and utility CSS" +``` + +--- + +### Task 4: Copy and adapt chat.tsx (core components) + +**Files:** +- Create: `packages/chat-ui/src/components/chat.tsx` +- Source: `chatcn/src/components/ui/chat/chat.tsx` (1415 lines) + +This is the largest file. Apply three systematic transformations: + +1. **Remove `"use client"` directive** (line 0) +2. **Remap imports** (lines 2-42) +3. **Replace all `var(--chat-*)` with Tailwind classes** (throughout) +4. **Remove theme prop from ChatProvider** and `data-chat-theme` attribute + +- [ ] **Step 1: Copy the file** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/chat.tsx` to `packages/chat-ui/src/components/chat.tsx`. + +- [ ] **Step 2: Remove "use client" directive** + +Delete the first line `"use client"`. + +- [ ] **Step 3: Remap imports** + +Replace: +```typescript +import { cn } from "@/lib/utils" +``` +With: +```typescript +import { cn } from "@xtablo/shared/lib/cn" +``` + +The `import type { ..., ChatTheme } from "./types"` — remove `ChatTheme` from this import. + +All other imports (React, lucide-react, createPortal, local ./types, ./hooks) stay as-is since they're either external packages or relative imports within the package. + +- [ ] **Step 4: Remove theme from ChatProvider** + +In `ChatProviderProps` interface (~line 57-71): remove `theme?: ChatTheme` prop. + +In `ChatProvider` function (~line 73-110): +- Remove `theme = "lunar"` from destructured params +- Remove `data-chat-theme={theme}` from the wrapping div — change to just `
` + +- [ ] **Step 5: Replace all var(--chat-*) occurrences with Tailwind classes** + +This is the bulk of the work. Apply the token mapping table from above systematically throughout the file. The pattern is to replace inline `var(--chat-*)` within className strings. + +Examples of transformations: + +``` +// BEFORE +bg-[var(--chat-bg-sidebar)] +// AFTER +bg-card + +// BEFORE +text-[var(--chat-text-primary)] +// AFTER +text-foreground + +// BEFORE +text-[var(--chat-text-secondary)] +// AFTER +text-muted-foreground + +// BEFORE +text-[var(--chat-text-tertiary)] +// AFTER +text-muted-foreground/60 + +// BEFORE +border-[var(--chat-border)] +// AFTER +border-border + +// BEFORE +border-[var(--chat-border-strong)] +// AFTER +border-border + +// BEFORE +bg-[var(--chat-accent-soft)] +// AFTER +bg-accent + +// BEFORE +text-[var(--chat-accent)] +// AFTER +text-primary + +// BEFORE +bg-[var(--chat-accent)] +// AFTER +bg-primary + +// BEFORE +bg-[var(--chat-bubble-outgoing)] +// AFTER +bg-primary + +// BEFORE +text-[var(--chat-bubble-outgoing-text)] +// AFTER +text-primary-foreground + +// BEFORE +bg-[var(--chat-bubble-incoming)] +// AFTER +bg-muted + +// BEFORE +text-[var(--chat-bubble-incoming-text)] +// AFTER +text-foreground + +// BEFORE +bg-[var(--chat-bg-main)] +// AFTER +bg-background + +// BEFORE +bg-[var(--chat-bg-composer)] +// AFTER +bg-card + +// BEFORE +shadow-[var(--chat-shadow-toolbar)] +// AFTER +shadow-md + +// BEFORE +shadow-[var(--chat-shadow-lg)] +// AFTER +shadow-lg + +// BEFORE +shadow-[var(--chat-shadow-md)] +// AFTER +shadow-md + +// BEFORE +text-[var(--chat-red)] +// AFTER +text-destructive + +// BEFORE +bg-[var(--chat-bg-code)] +// AFTER +bg-muted + +// BEFORE +style={{ fontFamily: "var(--chat-font-mono)" }} +// AFTER (use className instead) +className="font-mono ..." +``` + +For inline `style` attributes that reference `var(--chat-*)`: +- `style={{ background: "var(--chat-accent)" }}` → replace with `className` using `bg-primary` +- In `ChatVoiceMessage`, the waveform bars use inline `style` with `var(--chat-accent)` — replace with `var(--color-primary)` (Tailwind v4 resolved token) +- In `ChatVoiceMessage`, `style={{ color: "white" }}` stays (it's already a concrete value) + +Special cases in `ChatMessageReply`: +- `border-[var(--chat-bubble-outgoing-text)]/30` → `border-primary-foreground/30` +- `bg-[var(--chat-bubble-outgoing-text)]/10` → `bg-primary-foreground/10` + +Special cases in `ChatMessageReactions`: +- `border-[var(--chat-accent)]/30` → `border-primary/30` + +- [ ] **Step 6: Verify types compile** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/chat-ui typecheck` +Expected: no type errors (or only errors from missing features.tsx/layouts.tsx imports, which is fine for now) + +- [ ] **Step 7: Commit** + +```bash +git add packages/chat-ui/src/components/chat.tsx +git commit -m "feat(chat-ui): add core chat components with xtablo theming" +``` + +--- + +### Task 5: Copy and adapt features.tsx + +**Files:** +- Create: `packages/chat-ui/src/components/features.tsx` +- Source: `chatcn/src/components/ui/chat/features.tsx` (510 lines) + +- [ ] **Step 1: Copy the file** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/features.tsx` to `packages/chat-ui/src/components/features.tsx`. + +- [ ] **Step 2: Remove "use client" and remap imports** + +Remove `"use client"` directive. + +Replace: +```typescript +import { cn } from "@/lib/utils" +``` +With: +```typescript +import { cn } from "@xtablo/shared/lib/cn" +``` + +Other imports (`lucide-react`, `./types`, `./hooks`) stay as-is. + +- [ ] **Step 3: Replace all var(--chat-*) with Tailwind classes** + +Apply the same token mapping as Task 4. This file uses these tokens: + +- `var(--chat-border-strong)` → remove bracket, use `border-border` +- `var(--chat-bg-sidebar)` → `bg-card` +- `var(--chat-shadow-lg)` → `shadow-lg` +- `var(--chat-shadow-toolbar)` → `shadow-md` +- `var(--chat-border)` → `border-border` +- `var(--chat-text-primary)` → `text-foreground` +- `var(--chat-text-secondary)` → `text-muted-foreground` +- `var(--chat-text-tertiary)` → `text-muted-foreground/60` +- `var(--chat-bg-main)` → `bg-background` +- `var(--chat-accent-soft)` → `bg-accent` +- `var(--chat-bubble-incoming)` → `bg-muted` +- `var(--chat-accent)` → `text-primary` or `bg-primary` depending on context +- `var(--chat-red)` → `text-destructive` +- `var(--chat-orange)` → `text-orange-500` +- `var(--chat-bg-composer)` → `bg-card` + +- [ ] **Step 4: Commit** + +```bash +git add packages/chat-ui/src/components/features.tsx +git commit -m "feat(chat-ui): add feature components with xtablo theming" +``` + +--- + +### Task 6: Copy and adapt layouts.tsx + +**Files:** +- Create: `packages/chat-ui/src/components/layouts.tsx` +- Source: `chatcn/src/components/ui/chat/layouts.tsx` (822 lines) + +- [ ] **Step 1: Copy the file** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/layouts.tsx` to `packages/chat-ui/src/components/layouts.tsx`. + +- [ ] **Step 2: Remove "use client" and remap imports** + +Remove `"use client"` directive. + +Replace: +```typescript +import { cn } from "@/lib/utils" +``` +With: +```typescript +import { cn } from "@xtablo/shared/lib/cn" +``` + +Remove `ChatTheme` from the type imports: +```typescript +import type { ChatMessageData, ChatUser, TypingUser } from "./types" +``` + +The imports from `"./chat"` stay as relative: +```typescript +import { ChatProvider, ChatMessages, ChatComposer } from "./chat" +``` + +- [ ] **Step 3: Remove theme prop from all layout components** + +Every layout component (`FullMessenger`, `ChatWidget`, `InlineChat`, `ChatBoard`, `LiveChat`) has a `theme?: ChatTheme` prop passed to ``. Remove it from: +1. Each component's Props interface — remove `theme?: ChatTheme` +2. Each component's destructured params — remove `theme = "lunar"` +3. Each `` usage — remove `theme={theme}` + +- [ ] **Step 4: Replace all var(--chat-*) with Tailwind classes** + +Same mapping as previous tasks. This file uses the same set of tokens plus: +- `var(--chat-bg-app)` → `bg-background` +- `var(--chat-bg-header)` → `bg-card` +- `var(--chat-green)` → `text-green-500` / `bg-green-500` + +- [ ] **Step 5: Commit** + +```bash +git add packages/chat-ui/src/components/layouts.tsx +git commit -m "feat(chat-ui): add layout components with xtablo theming" +``` + +--- + +### Task 7: Create barrel export (index.ts) + +**Files:** +- Create: `packages/chat-ui/src/index.ts` +- Source: `chatcn/src/components/ui/chat/index.ts` + +- [ ] **Step 1: Copy index.ts from chatcn** + +Copy `/Users/arthur.belleville/Documents/perso/projects/chatcn/src/components/ui/chat/index.ts` to `packages/chat-ui/src/index.ts`. + +- [ ] **Step 2: Update import paths** + +The chatcn index.ts uses `"./chat"`, `"./features"`, `"./layouts"`, `"./security"`, `"./types"`, `"./hooks"`. Update the component imports to point into the `components/` subdirectory: + +Replace: +```typescript +} from "./chat" +``` +With: +```typescript +} from "./components/chat" +``` + +Replace: +```typescript +} from "./features" +``` +With: +```typescript +} from "./components/features" +``` + +Replace: +```typescript +} from "./layouts" +``` +With: +```typescript +} from "./components/layouts" +``` + +The `"./security"`, `"./types"`, and `"./hooks"` imports stay as-is (they're at the src root). + +Also remove `ChatTheme` from the types export section: +```typescript +// Remove this line from the type exports: +// ChatTheme, +``` + +- [ ] **Step 3: Add CSS export** + +Add at the top of the file: +```typescript +import "./chat-ui.css" +``` + +This ensures the CSS is included when the package is consumed. + +- [ ] **Step 4: Verify full package compiles** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/chat-ui typecheck` +Expected: PASS with no errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/chat-ui/src/index.ts +git commit -m "feat(chat-ui): add barrel export and wire up CSS" +``` + +--- + +### Task 8: Update main app dependencies + +**Files:** +- Modify: `apps/main/package.json` + +- [ ] **Step 1: Add @xtablo/chat-ui dependency** + +In `apps/main/package.json`, add to the `dependencies` section: +```json +"@xtablo/chat-ui": "workspace:*", +``` + +- [ ] **Step 2: Remove chatscope dependencies** + +Remove these two lines from `dependencies`: +```json +"@chatscope/chat-ui-kit-react": "^2.1.1", +"@chatscope/chat-ui-kit-styles": "^1.4.0", +``` + +- [ ] **Step 3: Run pnpm install** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm install` +Expected: lockfile updated, chatscope removed, @xtablo/chat-ui linked + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/package.json pnpm-lock.yaml +git commit -m "feat(main): switch from chatscope to @xtablo/chat-ui" +``` + +--- + +### Task 9: Rewrite ChatMessages.tsx to use @xtablo/chat-ui + +**Files:** +- Modify: `apps/main/src/components/ChatMessages.tsx` + +This is the core integration point. The current file is 184 lines using chatscope components. Replace it entirely with a new implementation using `@xtablo/chat-ui`. + +- [ ] **Step 1: Rewrite ChatMessages.tsx** + +Replace the entire contents of `apps/main/src/components/ChatMessages.tsx` with: + +```tsx +import { useMemo } from "react"; +import { + ChatProvider, + ChatMessages as ChatMessageList, + ChatComposer, + ChatTypingIndicator, +} from "@xtablo/chat-ui"; +import type { ChatMessageData, ChatUser, TypingUser } from "@xtablo/chat-ui"; + +interface ChatMessage { + id: string; + userId: string; + text: string; + createdAt: string; + clientId: string; + optimistic?: boolean; +} + +interface Member { + id: string; + name: string; + avatar_url: string | null; +} + +interface ChatMessagesProps { + messages: ChatMessage[]; + currentUserId: string; + members: Member[]; + typingUsers: string[]; + hasMoreMessages: boolean; + onLoadMore?: () => void; + onSend: (text: string) => void; + onTyping: () => void; + placeholder?: string; +} + +export function ChatMessages({ + messages, + currentUserId, + members, + typingUsers, + hasMoreMessages, + onLoadMore, + onSend, + onTyping, + placeholder = "Envoyer un message...", +}: ChatMessagesProps) { + const membersById = useMemo(() => { + const map = new Map(); + for (const m of members) { + map.set(m.id, m); + } + return map; + }, [members]); + + const currentUser = useMemo( + () => ({ + id: currentUserId, + name: membersById.get(currentUserId)?.name ?? "Moi", + avatar: membersById.get(currentUserId)?.avatar_url ?? undefined, + }), + [currentUserId, membersById], + ); + + const chatMessages = useMemo( + () => + messages.map((msg) => { + const member = membersById.get(msg.userId); + return { + id: msg.id, + senderId: msg.userId, + senderName: member?.name ?? "Utilisateur", + senderAvatar: member?.avatar_url ?? undefined, + text: msg.text, + timestamp: new Date(msg.createdAt), + status: msg.optimistic ? "sending" : undefined, + }; + }), + [messages, membersById], + ); + + const chatTypingUsers = useMemo( + () => + typingUsers.map((userId) => ({ + id: userId, + name: membersById.get(userId)?.name ?? "Utilisateur", + avatar: membersById.get(userId)?.avatar_url ?? undefined, + })), + [typingUsers, membersById], + ); + + return ( + + + { + if (_isTyping) onTyping(); + }} + placeholder={placeholder} + /> + + ); +} +``` + +Key decisions: +- **Props interface stays identical** — `ChatPage` and `TabloDiscussionSection` don't need changes. +- **Data transformation** happens in `useMemo` — maps xtablo's flat message/member model to chatcn's richer types. +- **Typing callback bridge** — chatcn's `onTyping` fires `(isTyping: boolean)` while xtablo's `onTyping` / `sendTyping` is fire-and-forget. We only call `onTyping()` when typing starts. +- **Localization** — French strings ("Utilisateur", "Moi", "Envoyer un message...") are kept at the app level as before. The chatcn `formatDateLabel` returns English labels ("Today", "Yesterday") — this is acceptable for now and can be overridden later with i18next. + +- [ ] **Step 2: Import the CSS in the main app** + +In `apps/main/src/main.css`, add the chat-ui CSS import at the top (after tailwindcss): + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@xtablo/chat-ui/src/chat-ui.css"; +``` + +Alternatively, if the `import "./chat-ui.css"` in `index.ts` works via Vite's CSS handling (it should, since Vite processes CSS imports in JS), this explicit import may not be needed. Test by checking if animations work in the browser. If they do, skip this step. + +- [ ] **Step 3: Verify types compile** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm --filter @xtablo/main typecheck` +Expected: PASS with no errors + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/src/components/ChatMessages.tsx +git commit -m "feat(main): rewrite ChatMessages to use @xtablo/chat-ui" +``` + +--- + +### Task 10: Visual testing and cleanup + +**Files:** +- Possibly modify: `apps/main/src/main.css` (CSS import if needed) +- Possibly modify: `packages/chat-ui/src/components/chat.tsx` (style fixes) + +- [ ] **Step 1: Start the dev server** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm dev:main` +Expected: Main app starts on port 5173 + +- [ ] **Step 2: Test the chat page** + +Open http://localhost:5173 in a browser. Navigate to a chat channel. Verify: + +1. Messages render correctly (incoming on left, outgoing on right) +2. Avatars show for incoming messages (initials fallback if no avatar URL) +3. Message grouping works (consecutive messages from same sender are grouped) +4. Date separators appear between days +5. Typing indicator shows when someone is typing +6. Composer works — type a message and send with Enter +7. Auto-scroll to bottom on new messages +8. Scroll-to-bottom button appears when scrolled up +9. Optimistic messages show with sending status (clock icon) +10. Dark mode looks correct (toggle dark mode if available) + +- [ ] **Step 3: Test the tablo discussion section** + +Navigate to a tablo detail page that has a discussion section. Verify the chat works there too — same component, same behavior. + +- [ ] **Step 4: Fix any visual issues** + +If any theme tokens don't look right (e.g., contrast issues, colors too similar), adjust the mapping in the affected component file. Common issues: +- If `text-muted-foreground/60` is too faint, try `text-muted-foreground/70` +- If outgoing bubble color (bg-primary) doesn't feel "chat-like", consider adding a chat-specific CSS variable in `main.css` + +- [ ] **Step 5: Remove chatscope CSS import if still present anywhere** + +Search for any remaining chatscope references: + +Run: `grep -r "chatscope" apps/main/src/` +Expected: no results + +- [ ] **Step 6: Final typecheck and lint** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm typecheck && pnpm lint` +Expected: PASS + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A +git commit -m "fix(chat-ui): visual polish and cleanup after integration" +``` + +--- + +## Post-Integration Notes + +After all tasks are complete: + +- **chatscope** is fully removed — no dependencies, no CSS import, no component usage +- **@xtablo/chat-ui** is a self-contained workspace package with all chatcn components +- **ChatMessages.tsx** keeps its original props interface — no changes needed in consuming components +- **Future features** (reactions, threads, search, pinned messages) are available in the package and can be wired up incrementally +- **Localization** — date labels are in English. To switch to French, create a custom `formatDateLabel` using i18next and pass translated labels, or override the function in the package diff --git a/docs/superpowers/specs/2026-04-12-chatcn-integration-design.md b/docs/superpowers/specs/2026-04-12-chatcn-integration-design.md new file mode 100644 index 0000000..e873db9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-chatcn-integration-design.md @@ -0,0 +1,187 @@ +# Integrate chatcn as @xtablo/chat-ui Package + +## Overview + +Replace the chatscope chat UI library in xtablo-source with chatcn, integrated as a new shared workspace package `@xtablo/chat-ui`. This replaces the rendering layer only — the chat data layer (`useChat` hook, WebSocket, REST) is unchanged. + +## Context + +- **chatcn** is a React 19 / Tailwind CSS 4 chat component library with messages, composer, reactions, threads, typing indicators, 5 layouts, security utilities, and custom hooks. +- **xtablo-source** is a Turborepo monorepo with pnpm workspaces. Chat UI currently uses `@chatscope/chat-ui-kit-react` in a single component (`apps/main/src/components/ChatMessages.tsx`, 184 lines). +- The chat data layer (`useChat` hook with WebSocket + REST to the chat-worker service) is cleanly separated from the UI and stays unchanged. + +## Approach + +Copy-and-adapt: copy chatcn's chat component source files into a new `packages/chat-ui/` workspace package, adapting imports and theming to match xtablo's existing patterns. + +## Package Structure + +``` +packages/chat-ui/ +├── package.json # @xtablo/chat-ui +├── tsconfig.json +└── src/ + ├── index.ts # Barrel export + ├── components/ + │ ├── chat.tsx # Core: ChatProvider, ChatMessage, ChatMessages, ChatComposer, etc. + │ ├── features.tsx # ChatForwardDialog, ChatEditComposer, ChatSearch, etc. + │ └── layouts.tsx # FullMessenger, ChatWidget, InlineChat, ChatBoard, LiveChat, etc. + ├── hooks.ts # groupMessages, useAutoScroll, useAutoResize, useTypingIndicator, etc. + ├── security.ts # sanitizeUrl, validateFile, sanitizeFileName, etc. + └── types.ts # ChatUser, ChatMessageData, ChatConfig, MessageGroup, etc. +``` + +Source-only package (no build step), same pattern as `@xtablo/shared` and `@xtablo/ui`. + +### package.json exports + +```json +{ + "name": "@xtablo/chat-ui", + "version": "0.0.1", + "private": true, + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks": "./src/hooks.ts", + "./security": "./src/security.ts", + "./types": "./src/types.ts" + }, + "dependencies": { + "@xtablo/shared": "workspace:*", + "@xtablo/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "lucide-react": "*", + "date-fns": "*" + } +} +``` + +### tsconfig.json + +Mirrors `packages/ui/tsconfig.json`: target ES2022, module ESNext, jsx react-jsx, strict true, moduleResolution bundler, declaration true. + +## Dependencies + +All dependencies are already present in the monorepo — no new packages to install: + +| Dependency | Source | +|---|---| +| `@xtablo/shared` | Workspace (for `cn()` utility) | +| `@xtablo/ui` | Workspace (for Button, Dialog, Avatar primitives) | +| `lucide-react` | Already in monorepo | +| `date-fns` | Already in monorepo | +| `clsx` | Via `@xtablo/shared` | +| `tailwind-merge` | Via `@xtablo/shared` | + +**Dropped:** `@base-ui/react` (only used in chatcn's scaffolding `button.tsx`, not in any chat component). + +## Import Remapping + +When copying chatcn source files into the package, apply these systematic import changes: + +| chatcn import | @xtablo/chat-ui import | +|---|---| +| `import { cn } from "@/lib/utils"` | `import { cn } from "@xtablo/shared/lib/cn"` | +| `import { Button } from "@/components/ui/button"` | `import { Button } from "@xtablo/ui/components/button"` | +| Other `@/components/ui/*` imports (Dialog, Avatar, etc.) | Corresponding `@xtablo/ui/components/*` import | + +## Theming + +Strip chatcn's custom theming system entirely and remap to xtablo's existing Tailwind design tokens. + +### What gets removed + +- `ChatTheme` type (`"lunar" | "aurora" | "ember" | "midnight"`) +- `data-chat-theme` attribute on components +- `ChatProvider`'s theme prop +- All `var(--chat-*)` CSS custom property references +- chatcn's globals.css theme definitions (not copied) + +### Token mapping + +All `var(--chat-*)` references in component files are replaced with Tailwind utility classes: + +| chatcn token | Tailwind class | +|---|---| +| `--chat-bg-app`, `--chat-bg-main` | `bg-background` | +| `--chat-bg-sidebar` | `bg-sidebar` | +| `--chat-bubble-outgoing` | `bg-primary` | +| `--chat-bubble-incoming` | `bg-muted` | +| `--chat-text-outgoing` | `text-primary-foreground` | +| `--chat-text-incoming` | `text-foreground` | +| `--chat-accent` | `bg-accent` / `text-accent-foreground` | +| `--chat-border` | `border-border` | +| `--chat-text-primary` | `text-foreground` | +| `--chat-text-secondary` | `text-muted-foreground` | + +This ensures automatic light/dark mode support via xtablo's existing `.dark` class. + +## Component Adaptation + +### What stays unchanged + +- All chatcn types (`ChatUser`, `ChatMessageData`, `ChatConfig`, `MessageGroup`, `MessageListItem`, `TypingUser`) +- All hooks (`groupMessages`, `useAutoScroll`, `useAutoResize`, `useTypingIndicator`, `formatTimestamp`, `formatDateLabel`) +- All security utilities (`sanitizeUrl`, `validateFile`, `sanitizeFileName`, etc.) +- All component props and APIs + +### What changes + +- Imports remapped (see above) +- Theme references replaced with Tailwind classes (see above) +- `ChatTheme` type removed from `types.ts` +- Theme prop removed from `ChatProvider` + +## Replacing ChatMessages.tsx + +The current `apps/main/src/components/ChatMessages.tsx` is rewritten to use `@xtablo/chat-ui`. + +### Data transformation + +The app-level component transforms xtablo's data model to chatcn's types: + +| Current (xtablo) | chatcn equivalent | +|---|---| +| `ChatMessage.userId` + `Member.name` | `ChatMessageData.senderId` + `senderName` | +| `ChatMessage.text` | `ChatMessageData.text` | +| `ChatMessage.createdAt` (ISO string) | `ChatMessageData.timestamp` (Date) | +| `ChatMessage.optimistic` | `ChatMessageData.status: "sending"` | +| `Member.avatar_url` | `ChatUser.avatar` / `ChatMessageData.senderAvatar` | +| `typingUsers: string[]` | `TypingUser[]` (with id + name resolved from members) | + +### New component structure + +```tsx + + + + +``` + +### Localization + +The package stays i18n-agnostic. French strings currently hardcoded in `ChatMessages.tsx` ("Aujourd'hui", "Hier", "écrit...") are handled at the app level: +- Date labels: override chatcn's `formatDateLabel` with a custom function using i18next +- Typing indicator text: pass translated string as a prop +- Placeholder text: pass via `ChatComposer` placeholder prop + +## Cleanup + +After the integration: +- Remove `@chatscope/chat-ui-kit-react` from `apps/main/package.json` +- Remove `@chatscope/chat-ui-kit-styles` from `apps/main/package.json` +- Remove the chatscope CSS import from `ChatMessages.tsx` +- Add `@xtablo/chat-ui` to `apps/main/package.json` as a workspace dependency + +## Out of Scope + +- **Mobile app:** `xtablo-expo` uses `stream-chat-expo` — unchanged +- **Chat data layer:** `useChat` hook, WebSocket connections, chat-worker service — unchanged +- **ChatChannelPreview and ChatHeader:** Already pure Tailwind, no chatscope — unchanged +- **TabloDiscussionSection:** Consumes `ChatMessages` with the same props interface — unchanged +- **New feature adoption:** Reactions, threads, search, pinned messages, etc. are available in the package but not wired up. Future work. +- **Stream Chat CSS:** `.str-chat` overrides in `main.css` — unrelated, unchanged From 34fe75cd1254d4c95fa17e91ceb7dd2d10accd34 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Apr 2026 12:54:19 +0200 Subject: [PATCH 27/62] feat(chat-ui): scaffold @xtablo/chat-ui package Co-Authored-By: Claude Sonnet 4.6 --- packages/chat-ui/package.json | 35 ++++++++++++++++++++++++++++++++++ packages/chat-ui/tsconfig.json | 21 ++++++++++++++++++++ pnpm-lock.yaml | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 packages/chat-ui/package.json create mode 100644 packages/chat-ui/tsconfig.json diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json new file mode 100644 index 0000000..1107ed2 --- /dev/null +++ b/packages/chat-ui/package.json @@ -0,0 +1,35 @@ +{ + "name": "@xtablo/chat-ui", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks": "./src/hooks.ts", + "./security": "./src/security.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "dependencies": { + "@xtablo/shared": "workspace:*", + "@xtablo/ui": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "lucide-react": "*", + "date-fns": "*" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0" + } +} diff --git a/packages/chat-ui/tsconfig.json b/packages/chat-ui/tsconfig.json new file mode 100644 index 0000000..2b876e2 --- /dev/null +++ b/packages/chat-ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": ["es2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c66487..d1b36cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,6 +529,40 @@ importers: specifier: ^4.24.3 version: 4.44.0(@cloudflare/workers-types@4.20260411.1) + packages/chat-ui: + dependencies: + '@xtablo/shared': + specifier: workspace:* + version: link:../shared + '@xtablo/ui': + specifier: workspace:* + version: link:../ui + date-fns: + specifier: '*' + version: 4.1.0 + lucide-react: + specifier: '*' + version: 0.460.0(react@19.0.0) + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + devDependencies: + '@biomejs/biome': + specifier: 2.2.5 + version: 2.2.5 + '@types/react': + specifier: 19.0.10 + version: 19.0.10 + '@types/react-dom': + specifier: 19.0.4 + version: 19.0.4(@types/react@19.0.10) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/shared: dependencies: '@datadog/browser-rum': From a0bbbe15cae446bcb4a161a4bf43b6b5b6dc8feb Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Apr 2026 13:46:35 +0200 Subject: [PATCH 28/62] feat(chat-ui): add types, hooks, and security utilities from chatcn Co-Authored-By: Claude Sonnet 4.6 --- packages/chat-ui/src/hooks.ts | 231 +++++++++++++++++++++++++++++++ packages/chat-ui/src/security.ts | 116 ++++++++++++++++ packages/chat-ui/src/types.ts | 75 ++++++++++ 3 files changed, 422 insertions(+) create mode 100644 packages/chat-ui/src/hooks.ts create mode 100644 packages/chat-ui/src/security.ts create mode 100644 packages/chat-ui/src/types.ts diff --git a/packages/chat-ui/src/hooks.ts b/packages/chat-ui/src/hooks.ts new file mode 100644 index 0000000..467abc4 --- /dev/null +++ b/packages/chat-ui/src/hooks.ts @@ -0,0 +1,231 @@ +import { + useRef, + useEffect, + useCallback, + useState, +} from "react" +import { + isToday, + isYesterday, + format, + isSameDay, + differenceInSeconds, +} from "date-fns" +import type { ChatMessageData, MessageListItem, MessageGroup } from "./types" + +// ─── Date formatting ────────────────────────────────────────────────────────── + +export function formatDateLabel(date: Date): string { + if (isToday(date)) return "Today" + if (isYesterday(date)) return "Yesterday" + const now = new Date() + const diffDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + ) + if (diffDays < 7) return format(date, "EEEE") // "Tuesday" + if (date.getFullYear() === now.getFullYear()) + return format(date, "MMMM d") // "March 18" + return format(date, "MMMM d, yyyy") // "March 18, 2026" +} + +export function formatTimestamp(date: Date): string { + return format(date, "h:mm a") // "10:42 AM" +} + +// ─── Message grouping ───────────────────────────────────────────────────────── + +export function groupMessages( + messages: ChatMessageData[], + currentUserId: string, + intervalSeconds: number = 120 +): MessageListItem[] { + if (messages.length === 0) return [] + + const items: MessageListItem[] = [] + let currentGroup: MessageGroup | null = null + let lastDate: Date | null = null + + for (const msg of messages) { + const msgDate = new Date(msg.timestamp) + + // System messages break groups + if (msg.isSystem) { + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + currentGroup = null + } + + // Insert date separator if needed + if (!lastDate || !isSameDay(lastDate, msgDate)) { + items.push({ type: "date", date: msgDate, label: formatDateLabel(msgDate) }) + lastDate = msgDate + } + + items.push({ type: "system", message: msg }) + continue + } + + // Insert date separator if needed + if (!lastDate || !isSameDay(lastDate, msgDate)) { + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + currentGroup = null + } + items.push({ type: "date", date: msgDate, label: formatDateLabel(msgDate) }) + lastDate = msgDate + } + + // Check if message should continue the current group + const shouldGroup = + currentGroup && + currentGroup.senderId === msg.senderId && + currentGroup.messages.length > 0 && + differenceInSeconds( + msgDate, + new Date( + currentGroup.messages[currentGroup.messages.length - 1]!.timestamp + ) + ) <= intervalSeconds + + if (shouldGroup && currentGroup) { + currentGroup.messages.push(msg) + } else { + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + } + currentGroup = { + senderId: msg.senderId, + senderName: msg.senderName, + senderAvatar: msg.senderAvatar, + messages: [msg], + isOutgoing: msg.senderId === currentUserId, + } + } + } + + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + } + + return items +} + +// ─── Auto-scroll hook ───────────────────────────────────────────────────────── + +export function useAutoScroll( + messages: ChatMessageData[], + opts?: { threshold?: number } +) { + const containerRef = useRef(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const [unseenCount, setUnseenCount] = useState(0) + const prevLengthRef = useRef(messages.length) + const threshold = opts?.threshold ?? 100 + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = "smooth") => { + const el = containerRef.current + if (!el) return + el.scrollTo({ top: el.scrollHeight, behavior }) + setUnseenCount(0) + }, + [] + ) + + // Track scroll position + useEffect(() => { + const el = containerRef.current + if (!el) return + + const handleScroll = () => { + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight + const atBottom = distanceFromBottom <= threshold + setIsAtBottom(atBottom) + if (atBottom) setUnseenCount(0) + } + + el.addEventListener("scroll", handleScroll, { passive: true }) + return () => el.removeEventListener("scroll", handleScroll) + }, [threshold]) + + // Auto-scroll when new messages arrive and user is at bottom + useEffect(() => { + const newCount = messages.length - prevLengthRef.current + prevLengthRef.current = messages.length + + if (newCount <= 0) return + + if (isAtBottom) { + scrollToBottom("smooth") + } else { + setUnseenCount((c) => c + newCount) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) + + // Scroll to bottom on mount + useEffect(() => { + scrollToBottom("instant") + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { containerRef, scrollToBottom, isAtBottom, unseenCount } as const +} + +// ─── Auto-resize textarea hook ──────────────────────────────────────────────── + +export function useAutoResize(opts?: { maxRows?: number }) { + const textareaRef = useRef(null) + const maxRows = opts?.maxRows ?? 6 + + const resize = useCallback(() => { + const el = textareaRef.current + if (!el) return + el.style.height = "auto" + const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 22 + const maxHeight = lineHeight * maxRows + el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px` + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden" + }, [maxRows]) + + return { textareaRef, resize } as const +} + +// ─── Typing indicator hook ──────────────────────────────────────────────────── + +export function useTypingIndicator(opts?: { + onTypingChange?: (isTyping: boolean) => void + debounceMs?: number +}) { + const [isTyping, setIsTyping] = useState(false) + const timeoutRef = useRef | null>(null) + const debounceMs = opts?.debounceMs ?? 2000 + + const handleKeyDown = useCallback(() => { + if (!isTyping) { + setIsTyping(true) + opts?.onTypingChange?.(true) + } + + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setIsTyping(false) + opts?.onTypingChange?.(false) + }, debounceMs) + }, [isTyping, debounceMs, opts]) + + const stopTyping = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + setIsTyping(false) + opts?.onTypingChange?.(false) + }, [opts]) + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + return { isTyping, handleKeyDown, stopTyping } as const +} diff --git a/packages/chat-ui/src/security.ts b/packages/chat-ui/src/security.ts new file mode 100644 index 0000000..40bab72 --- /dev/null +++ b/packages/chat-ui/src/security.ts @@ -0,0 +1,116 @@ +/** + * chatcn — Security Utilities + * XSS prevention, URL sanitization, file validation, bidi stripping, emoji validation + */ + +// ─── URL Sanitization ───────────────────────────────────────────────────────── + +const ALLOWED_URL_PROTOCOL = /^(https?:\/\/|mailto:)/i + +/** Sanitize a URL — only allow http, https, and mailto. Blocks javascript:, data:, etc. */ +export function sanitizeUrl(url: string | undefined | null): string { + if (!url) return "#" + const trimmed = url.trim() + if (!ALLOWED_URL_PROTOCOL.test(trimmed)) return "#" + return trimmed +} + +/** Extract hostname from a URL for display (prevents misleading long URLs) */ +export function displayHostname(url: string): string { + try { + return new URL(url).hostname + } catch { + return url + } +} + +// ─── Text Sanitization ─────────────────────────────────────────────────────── + +/** Strip bidi override characters that can disguise malicious content (RLO attacks) */ +export function stripBidiOverrides(text: string): string { + // Remove LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI + return text.replace(/[\u202A-\u202E\u2066-\u2069]/g, "") +} + +/** Truncate message text to prevent rendering DoS */ +export function truncateMessage( + text: string, + maxLength: number = 10_000 +): { text: string; truncated: boolean } { + if (text.length <= maxLength) return { text, truncated: false } + return { text: text.slice(0, maxLength) + "\u2026", truncated: true } +} + +/** Sanitize sender name — strip bidi, truncate */ +export function sanitizeSenderName(name: string): string { + return stripBidiOverrides(name).slice(0, 100) +} + +// ─── File Validation ────────────────────────────────────────────────────────── + +const BLOCKED_EXTENSIONS = new Set([ + ".exe", ".bat", ".cmd", ".scr", ".pif", ".com", + ".js", ".jsx", ".ts", ".tsx", ".mjs", + ".html", ".htm", ".xhtml", + ".svg", + ".xml", ".xsl", ".xslt", + ".hta", ".vbs", ".vbe", ".wsf", ".wsh", + ".ps1", ".psm1", + ".sh", ".bash", +]) + +const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB + +export interface FileValidationResult { + valid: boolean + error?: string +} + +export function validateFile( + file: File, + opts?: { maxSize?: number } +): FileValidationResult { + const maxSize = opts?.maxSize ?? DEFAULT_MAX_FILE_SIZE + + // Check extension + const parts = file.name.split(".") + const ext = parts.length > 1 ? "." + parts[parts.length - 1]!.toLowerCase() : "" + if (BLOCKED_EXTENSIONS.has(ext)) { + return { valid: false, error: `File type ${ext} is not allowed` } + } + + // Check size + if (file.size > maxSize) { + const mbLimit = Math.round(maxSize / (1024 * 1024)) + return { valid: false, error: `File exceeds maximum size of ${mbLimit}MB` } + } + + return { valid: true } +} + +/** Sanitize a file name for display */ +export function sanitizeFileName(name: string): string { + let clean = name.replace(/[/\\]/g, "_") // Remove path traversal + clean = clean.replace(/\0/g, "") // Remove null bytes + clean = stripBidiOverrides(clean) // Remove bidi overrides + if (clean.length > 100) clean = clean.slice(0, 97) + "..." + return clean +} + +// ─── Emoji Validation ───────────────────────────────────────────────────────── + +const EMOJI_REGEX = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*$/u + +/** Validate that a string is a valid emoji (not arbitrary text) */ +export function isValidEmoji(str: string): boolean { + return EMOJI_REGEX.test(str) && str.length <= 20 +} + +// ─── Reaction Count Safety ──────────────────────────────────────────────────── + +/** Format reaction count for display (cap at 999+, floor at 0) */ +export function formatReactionCount(count: number): string { + if (count <= 0) return "0" + if (count > 999) return "999+" + return String(count) +} diff --git a/packages/chat-ui/src/types.ts b/packages/chat-ui/src/types.ts new file mode 100644 index 0000000..f933a99 --- /dev/null +++ b/packages/chat-ui/src/types.ts @@ -0,0 +1,75 @@ +export interface ChatUser { + id: string + name: string + avatar?: string + status?: "online" | "away" | "dnd" | "offline" +} + +export interface ChatMessageData { + id: string + senderId: string + senderName: string + senderAvatar?: string + + // Content + text?: string + images?: { url: string; width: number; height: number; alt?: string }[] + files?: { name: string; size: number; type: string; url: string }[] + voice?: { url: string; duration: number; waveform: number[] } + linkPreview?: { + url: string + title: string + description: string + image?: string + } + code?: { language: string; code: string } + + // Metadata + timestamp: Date | number + status?: "sending" | "sent" | "delivered" | "read" | "failed" + replyTo?: { id: string; senderName: string; text: string } + reactions?: { emoji: string; userIds: string[]; count: number }[] + isEdited?: boolean + isPinned?: boolean + isSystem?: boolean + systemEvent?: string + + // Read receipts (group chat) — list of users who have read up to this message + readBy?: { userId: string; name: string; avatar?: string }[] +} + +export interface ChatConfig { + currentUser: ChatUser + dateFormat?: "relative" | "absolute" | "time-only" + messageGroupingInterval?: number // seconds, default 120 + + // Callbacks + onReactionAdd?: (messageId: string, emoji: string) => void + onReactionRemove?: (messageId: string, emoji: string) => void + onReply?: (message: ChatMessageData) => void + onEdit?: (message: ChatMessageData) => void + onDelete?: (messageId: string) => void + onPin?: (messageId: string) => void +} + +/** A group of consecutive messages from the same sender within the grouping interval */ +export interface MessageGroup { + senderId: string + senderName: string + senderAvatar?: string + messages: ChatMessageData[] + isOutgoing: boolean +} + +/** Items that can appear in the message list */ +export type MessageListItem = + | { type: "group"; group: MessageGroup } + | { type: "date"; date: Date; label: string } + | { type: "system"; message: ChatMessageData } + +/** Typing state for one or more users */ +export interface TypingUser { + id: string + name: string + avatar?: string +} From e80a1018ae3417c2a64c1650e9d64c13839b8708 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Apr 2026 13:48:23 +0200 Subject: [PATCH 29/62] feat(chat-ui): add animation and utility CSS Co-Authored-By: Claude Sonnet 4.6 --- packages/chat-ui/src/chat-ui.css | 100 +++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/chat-ui/src/chat-ui.css diff --git a/packages/chat-ui/src/chat-ui.css b/packages/chat-ui/src/chat-ui.css new file mode 100644 index 0000000..0398317 --- /dev/null +++ b/packages/chat-ui/src/chat-ui.css @@ -0,0 +1,100 @@ +/* @xtablo/chat-ui — Animations and utility classes */ + +/* ─── Message entry ─────────────────────────────────────────────── */ +@keyframes chat-message-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ─── Toolbar entrance ──────────────────────────────────────────── */ +@keyframes chat-toolbar-enter { + from { opacity: 0; transform: scale(0.95) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* ─── Reaction pop ──────────────────────────────────────────────── */ +@keyframes chat-reaction-pop { + 0% { transform: scale(0); opacity: 0; } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } +} + +/* ─── Typing indicator dots ─────────────────────────────────────── */ +@keyframes chat-typing-pulse { + 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-4px); } +} + +/* ─── Cursor blink (streaming) ──────────────────────────────────── */ +@keyframes chat-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ─── Read receipt status color transition ───────────────────────── */ +@keyframes chat-status-read-in { + from { color: var(--color-muted-foreground); } + to { color: var(--color-primary); } +} + +/* ─── Utility classes ───────────────────────────────────────────── */ +@layer base { + .chat-message { + animation: chat-message-enter 250ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-typing-dot { + animation: chat-typing-pulse 1.4s ease-in-out infinite; + } + + .chat-toolbar-enter { + transform-origin: bottom center; + } + + .group\/message:hover .chat-toolbar-enter { + animation: chat-toolbar-enter 150ms ease-out; + } + + .chat-reaction-pop { + animation: chat-reaction-pop 200ms cubic-bezier(0.25, 0.1, 0.25, 1.0); + } + + .chat-status-read { + animation: chat-status-read-in 400ms ease-out; + } + + .chat-streaming-cursor { + animation: chat-cursor-blink 1s step-end infinite; + } + + .chat-content-card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; + } + + .chat-drop-overlay { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in oklch, var(--color-background) 80%, transparent); + border: 2px dashed var(--color-primary); + border-radius: 12px; + backdrop-filter: blur(4px); + } +} + +/* ─── Reduced motion ────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .chat-message, + .chat-typing-dot, + .chat-toolbar-enter, + .chat-reaction-pop, + .chat-status-read { + animation: none; + } +} From 26db9b1adfd7e860c4bf87bc4234d093250d3579 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Apr 2026 14:00:38 +0200 Subject: [PATCH 30/62] feat(chat-ui): add core chat components with xtablo theming Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chat-ui/src/components/chat.tsx | 1410 ++++++++++++++++++++++ 1 file changed, 1410 insertions(+) create mode 100644 packages/chat-ui/src/components/chat.tsx diff --git a/packages/chat-ui/src/components/chat.tsx b/packages/chat-ui/src/components/chat.tsx new file mode 100644 index 0000000..ef96759 --- /dev/null +++ b/packages/chat-ui/src/components/chat.tsx @@ -0,0 +1,1410 @@ +import * as React from "react" +import { cn } from "@xtablo/shared" +import { + Check, + CheckCheck, + ArrowUp, + ChevronDown, + Clock, + AlertCircle, + Reply, + SmilePlus, + MoreHorizontal, + Pin, + Pencil, + Trash2, + X, + Paperclip, + Image as ImageIcon, + Smile, + Upload, + Plus, + Play, + Pause, + Mic, +} from "lucide-react" +import { createPortal } from "react-dom" +import type { + ChatUser, + ChatConfig, + ChatMessageData, + MessageGroup, + TypingUser, +} from "../types" +import { + groupMessages, + useAutoScroll, + useAutoResize, + useTypingIndicator, + formatTimestamp, +} from "../hooks" + +// ─── Context ────────────────────────────────────────────────────────────────── + +const ChatContext = React.createContext(null) + +function useChatContext() { + const ctx = React.useContext(ChatContext) + if (!ctx) + throw new Error("Chat components must be wrapped in ") + return ctx +} + +// ─── ChatProvider ───────────────────────────────────────────────────────────── + +interface ChatProviderProps { + currentUser: ChatUser + dateFormat?: "relative" | "absolute" | "time-only" + messageGroupingInterval?: number + onReactionAdd?: (messageId: string, emoji: string) => void + onReactionRemove?: (messageId: string, emoji: string) => void + onReply?: (message: ChatMessageData) => void + onEdit?: (message: ChatMessageData) => void + onDelete?: (messageId: string) => void + onPin?: (messageId: string) => void + children: React.ReactNode + style?: React.CSSProperties + className?: string +} + +function ChatProvider({ + currentUser, + dateFormat = "relative", + messageGroupingInterval = 120, + onReactionAdd, + onReactionRemove, + onReply, + onEdit, + onDelete, + onPin, + children, + style, + className, +}: ChatProviderProps) { + const config = React.useMemo( + () => ({ + currentUser, + dateFormat, + messageGroupingInterval, + onReactionAdd, + onReactionRemove, + onReply, + onEdit, + onDelete, + onPin, + }), + [currentUser, dateFormat, messageGroupingInterval, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin] + ) + + return ( + +
+ {children} +
+
+ ) +} + +// ─── Quick emoji picker (6 common reactions) ────────────────────────────────── + +const QUICK_REACTIONS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F62E}", "\u{1F64F}", "\u{1F525}"] + +function QuickReactionPicker({ + onSelect, + onClose, +}: { + onSelect: (emoji: string) => void + onClose: () => void +}) { + return ( +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} +
+ ) +} + +// ─── ChatMessageActions (hover toolbar) ─────────────────────────────────────── + +interface ChatMessageActionsProps { + message: ChatMessageData + isOutgoing: boolean +} + +function ChatMessageActions({ message, isOutgoing }: ChatMessageActionsProps) { + const { onReply, onReactionAdd, onEdit, onDelete, onPin } = useChatContext() + const [showReactions, setShowReactions] = React.useState(false) + const [showMore, setShowMore] = React.useState(false) + + return ( +
+ {/* Reply */} + + + {/* React — opens quick picker */} +
+ + {showReactions && ( +
+ + onReactionAdd?.(message.id, emoji) + } + onClose={() => setShowReactions(false)} + /> +
+ )} +
+ + {/* More — dropdown */} +
+ + {showMore && ( +
setShowMore(false)} + > + {isOutgoing && ( + + )} + + {isOutgoing && ( + + )} +
+ )} +
+
+ ) +} + +// ─── ChatMessageReply (quoted reply inside bubble) ──────────────────────────── + +function ChatMessageReply({ + replyTo, + isOutgoing, +}: { + replyTo: NonNullable + isOutgoing: boolean +}) { + // Outgoing bubbles set text color via --chat-bubble-outgoing-text which may + // be white (Lunar, Midnight) or dark (Aurora, Ember). Using `text-inherit` + // + opacity lets the reply quote inherit that color and stay visible against + // the bubble background regardless of theme. + return ( +
+
+ + {replyTo.senderName} + + + {replyTo.text} + +
+
+ ) +} + +// ─── ChatMessage ────────────────────────────────────────────────────────────── + +interface ChatMessageProps { + message: ChatMessageData + isOutgoing: boolean + position: "solo" | "first" | "middle" | "last" + showSender?: boolean + showAvatar?: boolean + className?: string +} + +// ─── Voice Message ───────────────────────────────────────────────────────── + +function ChatVoiceMessage({ voice, isOutgoing }: { voice: NonNullable; isOutgoing: boolean }) { + const [playing, setPlaying] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const progressRef = React.useRef(0) + + React.useEffect(() => { + progressRef.current = progress + }, [progress]) + + const totalMins = Math.floor(voice.duration / 60) + const totalSecs = Math.floor(voice.duration % 60) + const elapsed = progress * voice.duration + const elapsedMins = Math.floor(elapsed / 60) + const elapsedSecs = Math.floor(elapsed % 60) + const timeLabel = playing || progress > 0 + ? `${elapsedMins}:${elapsedSecs.toString().padStart(2, "0")}` + : `${totalMins}:${totalSecs.toString().padStart(2, "0")}` + + const progressIndex = Math.floor(progress * voice.waveform.length) + + React.useEffect(() => { + if (!playing) return + const fps = 20 + const step = 1 / (voice.duration * fps) + const id = setInterval(() => { + const next = progressRef.current + step + if (next >= 1) { + setProgress(0) + setPlaying(false) + clearInterval(id) + } else { + setProgress(next) + } + }, 1000 / fps) + return () => clearInterval(id) + }, [playing, voice.duration]) + + const toggle = () => { + if (!playing && progress === 0) setProgress(0) + setPlaying((p) => !p) + } + + return ( +
+ +
+ {voice.waveform.map((v, i) => { + const played = i < progressIndex + return ( +
+ ) + })} +
+ {timeLabel} +
+ ) +} + +function ChatMessage({ + message, + isOutgoing, + position, + showSender = false, + showAvatar = false, + className, +}: ChatMessageProps) { + const timestamp = new Date(message.timestamp) + const { currentUser } = useChatContext() + const radiusClass = getBubbleRadius(isOutgoing, position) + const [lightboxImage, setLightboxImage] = React.useState(null) + + return ( +
+ {/* Avatar slot — 32px, only for incoming, only on last/solo */} + {!isOutgoing ? ( +
+ {showAvatar && message.senderAvatar ? ( + {message.senderName} + ) : showAvatar ? ( +
+ {message.senderName.charAt(0).toUpperCase()} +
+ ) : null} +
+ ) : null} + + {/* Bubble + reactions column */} +
+ {/* Sender name — only first in group, incoming */} + {showSender && !isOutgoing && ( + + {message.senderName} + + )} + + {/* Bubble — relative for hover toolbar positioning */} +
+ {/* Hover actions toolbar */} + + +
+ {/* Quoted reply */} + {message.replyTo && ( + + )} + + {/* Text content */} + {message.text && ( +

+ {message.text} +

+ )} + + {/* Images */} + {message.images && message.images.length > 0 && ( +
+ {message.images.map((img, idx) => ( + + ))} +
+ )} + + {/* Code block */} + {message.code && ( +
+
+ {message.code.language} +
+
+                  
+                    {message.code.code}
+                  
+                
+
+ )} + + {/* File attachments */} + {message.files && message.files.length > 0 && ( +
+ {message.files.map((file, idx) => ( +
+
+ +
+
+

{file.name}

+

{file.size < 1024 ? `${file.size} B` : file.size < 1048576 ? `${(file.size / 1024).toFixed(0)} KB` : `${(file.size / 1048576).toFixed(1)} MB`}

+
+
+ ))} +
+ )} + + {/* Link preview */} + {message.linkPreview && ( + + {message.linkPreview.image && ( + + )} +
+

{message.linkPreview.title}

+

{message.linkPreview.description}

+

{message.linkPreview.url}

+
+
+ )} + + {/* Voice message */} + {message.voice && ( + + )} + + {/* Inline timestamp + status + edited label */} +
+ {message.isEdited && ( + edited + )} + + {isOutgoing && message.status && ( + + )} +
+
+ + {/* Pin indicator */} + {message.isPinned && ( +
+ +
+ )} +
+ + {/* Reactions bar */} + {message.reactions && message.reactions.length > 0 && ( + + )} + + {/* Read receipts (group chat) — small stacked avatars */} + {message.readBy && message.readBy.length > 0 && ( + + )} +
+ + {/* Image lightbox */} + {lightboxImage && typeof document !== "undefined" && createPortal( +
setLightboxImage(null)} + > + + e.stopPropagation()} + /> +
, + document.body + )} +
+ ) +} + +// ─── Bubble radius helper ───────────────────────────────────────────────────── + +function getBubbleRadius( + isOutgoing: boolean, + position: "solo" | "first" | "middle" | "last" +): string { + if (isOutgoing) { + switch (position) { + case "solo": + return "rounded-[18px_18px_4px_18px]" + case "first": + return "rounded-[18px_18px_4px_18px]" + case "middle": + return "rounded-[18px_4px_4px_18px]" + case "last": + return "rounded-[18px_4px_18px_18px]" + } + } else { + switch (position) { + case "solo": + return "rounded-[18px_18px_18px_4px]" + case "first": + return "rounded-[18px_18px_18px_4px]" + case "middle": + return "rounded-[4px_18px_18px_4px]" + case "last": + return "rounded-[4px_18px_18px_18px]" + } + } +} + +// ─── ChatMessageStatus ──────────────────────────────────────────────────────── + +function ChatMessageStatus({ + status, +}: { + status: NonNullable +}) { + switch (status) { + case "sending": + return + case "sent": + return + case "delivered": + return + case "read": + return ( + + ) + case "failed": + return ( + + ) + } +} + +// ─── ChatMessageReactions (interactive) ─────────────────────────────────────── + +function ChatMessageReactions({ + messageId, + reactions, + isOutgoing, + currentUserId, +}: { + messageId: string + reactions: NonNullable + isOutgoing: boolean + currentUserId: string +}) { + const { onReactionAdd, onReactionRemove } = useChatContext() + + return ( +
+ {reactions.map((r) => { + const hasReacted = r.userIds.includes(currentUserId) + return ( + + ) + })} + {/* Add reaction button — visible on hover */} + +
+ ) +} + +// ─── ChatReadReceipts (group chat — stacked mini avatars) ───────────────────── + +function ChatReadReceipts({ + readBy, + isOutgoing, +}: { + readBy: NonNullable + isOutgoing: boolean +}) { + const maxVisible = 3 + const visible = readBy.slice(0, maxVisible) + const overflow = readBy.length - maxVisible + + return ( +
+
+ {visible.map((user) => ( +
+ {user.avatar ? ( + {user.name} + ) : ( + user.name.charAt(0).toUpperCase() + )} +
+ ))} +
+ {overflow > 0 && ( + + +{overflow} + + )} +
+ ) +} + +// ─── ChatMessageGroup ───────────────────────────────────────────────────────── + +interface ChatMessageGroupProps { + group: MessageGroup + className?: string +} + +function ChatMessageGroup({ group, className }: ChatMessageGroupProps) { + const len = group.messages.length + + return ( +
+ {group.messages.map((msg, i) => { + const position: "solo" | "first" | "middle" | "last" = + len === 1 + ? "solo" + : i === 0 + ? "first" + : i === len - 1 + ? "last" + : "middle" + + return ( + + ) + })} +
+ ) +} + +// ─── ChatDateSeparator ──────────────────────────────────────────────────────── + +interface ChatDateSeparatorProps { + label: string + className?: string +} + +function ChatDateSeparator({ label, className }: ChatDateSeparatorProps) { + return ( +
+
+ + {label} + +
+
+ ) +} + +// ─── ChatSystemMessage ──────────────────────────────────────────────────────── + +interface ChatSystemMessageProps { + message: ChatMessageData + className?: string +} + +function ChatSystemMessage({ message, className }: ChatSystemMessageProps) { + return ( +
+ + {message.text || message.systemEvent} + +
+ ) +} + +// ─── ChatTypingIndicator ────────────────────────────────────────────────────── + +interface ChatTypingIndicatorProps { + users: TypingUser[] + className?: string +} + +function ChatTypingIndicator({ users, className }: ChatTypingIndicatorProps) { + if (users.length === 0) return null + + const label = + users.length === 1 + ? `${users[0]!.name} is typing` + : users.length === 2 + ? `${users[0]!.name} and ${users[1]!.name} are typing` + : "Several people are typing" + + return ( +
+ {/* Avatar */} +
+ {users[0]!.avatar ? ( + {users[0]!.name} + ) : ( + users[0]!.name.charAt(0).toUpperCase() + )} +
+ +
+ {/* Label */} + + {label} + + + {/* Dots bubble */} +
+ + + +
+
+
+ ) +} + +// ─── ChatReplyPreview (bar above composer) ──────────────────────────────────── + +interface ChatReplyPreviewProps { + replyingTo: ChatMessageData + onCancel: () => void + className?: string +} + +function ChatReplyPreview({ + replyingTo, + onCancel, + className, +}: ChatReplyPreviewProps) { + return ( +
+
+
+ + {replyingTo.senderName} + + + {replyingTo.text} + +
+ +
+ ) +} + +// ─── ChatMessages (scroll container) ────────────────────────────────────────── + +interface ChatMessagesProps { + messages: ChatMessageData[] + typingUsers?: TypingUser[] + className?: string + onLoadMore?: () => Promise + hasMore?: boolean +} + +function ChatMessages({ + messages, + typingUsers = [], + className, +}: ChatMessagesProps) { + const { currentUser, messageGroupingInterval } = useChatContext() + const { containerRef, scrollToBottom, isAtBottom, unseenCount } = + useAutoScroll(messages) + + const items = React.useMemo( + () => groupMessages(messages, currentUser.id, messageGroupingInterval), + [messages, currentUser.id, messageGroupingInterval] + ) + + return ( +
+ {/* Scrollable area */} +
+
+ {items.map((item, i) => { + switch (item.type) { + case "date": + return ( + + ) + case "system": + return ( + + ) + case "group": + return ( + + ) + } + })} + + {/* Typing indicator at the bottom */} + {typingUsers.length > 0 && ( + + )} +
+
+ + {/* Scroll-to-bottom FAB with unread badge */} + +
+ ) +} + +// ─── File preview item ──────────────────────────────────────────────────────── + +interface FilePreviewItem { + file: File + id: string + preview?: string // data URL for images + progress?: number // 0-100 +} + +function ChatFilePreview({ + item, + onRemove, +}: { + item: FilePreviewItem + onRemove: () => void +}) { + const isImage = item.file.type.startsWith("image/") + + return ( +
+ {isImage && item.preview ? ( +
+ {item.file.name} +
+ ) : ( +
+
+ +
+
+

{item.file.name}

+

{(item.file.size / 1024).toFixed(0)} KB

+
+
+ )} + {/* Progress bar */} + {item.progress !== undefined && item.progress < 100 && ( +
+
+
+ )} + {/* Remove button */} + +
+ ) +} + +// ─── ChatComposer ───────────────────────────────────────────────────────────── + +interface ChatComposerProps { + onSend?: (text: string) => void + onTyping?: (isTyping: boolean) => void + onFileUpload?: (files: File[]) => void + onVoiceRecord?: () => void + placeholder?: string + disabled?: boolean + replyingTo?: ChatMessageData | null + onCancelReply?: () => void + className?: string +} + +function ChatComposer({ + onSend, + onTyping, + onFileUpload, + onVoiceRecord, + placeholder = "Message", + disabled = false, + replyingTo, + onCancelReply, + className, +}: ChatComposerProps) { + const [value, setValue] = React.useState("") + const [files, setFiles] = React.useState([]) + const [isDragging, setIsDragging] = React.useState(false) + const [showAttachMenu, setShowAttachMenu] = React.useState(false) + const { textareaRef, resize } = useAutoResize({ maxRows: 6 }) + const { handleKeyDown: handleTypingKeyDown, stopTyping } = + useTypingIndicator({ onTypingChange: onTyping }) + const fileInputRef = React.useRef(null) + const imageInputRef = React.useRef(null) + const hasContent = value.trim().length > 0 || files.length > 0 + + const addFiles = React.useCallback((newFiles: FileList | File[]) => { + const arr = Array.from(newFiles) + const items: FilePreviewItem[] = arr.map((f) => ({ + file: f, + id: `${f.name}-${Date.now()}-${Math.random()}`, + progress: undefined, + })) + + // Generate image previews + items.forEach((item) => { + if (item.file.type.startsWith("image/")) { + const reader = new FileReader() + reader.onload = (e) => { + setFiles((prev) => + prev.map((f) => f.id === item.id ? { ...f, preview: e.target?.result as string } : f) + ) + } + reader.readAsDataURL(item.file) + } + }) + + setFiles((prev) => [...prev, ...items]) + onFileUpload?.(arr) + }, [onFileUpload]) + + const removeFile = React.useCallback((id: string) => { + setFiles((prev) => prev.filter((f) => f.id !== id)) + }, []) + + const handleSend = React.useCallback(() => { + const trimmed = value.trim() + if ((!trimmed && files.length === 0) || disabled) return + if (trimmed) onSend?.(trimmed) + setValue("") + setFiles([]) + stopTyping() + if (textareaRef.current) textareaRef.current.style.height = "auto" + }, [value, files, disabled, onSend, textareaRef, stopTyping]) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + handleTypingKeyDown() + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend() } + if (e.key === "Escape" && replyingTo) onCancelReply?.() + }, + [handleSend, handleTypingKeyDown, replyingTo, onCancelReply] + ) + + // Paste upload + const handlePaste = React.useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + const imageFiles: File[] = [] + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile() + if (file) imageFiles.push(file) + } + } + if (imageFiles.length > 0) { + addFiles(imageFiles) + setShowAttachMenu(false) + } + }, + [addFiles] + ) + + // Drag-and-drop handlers (on the composer container) + const handleDragOver = React.useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + const handleDragLeave = React.useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + const handleDrop = React.useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + if (e.dataTransfer.files.length > 0) { + addFiles(e.dataTransfer.files) + setShowAttachMenu(false) + } + }, + [addFiles] + ) + + return ( +
+ {/* Drop overlay */} + {isDragging && ( +
+
+ + Drop files to upload +
+
+ )} + + {/* Reply preview bar */} + {replyingTo && ( + onCancelReply?.()} /> + )} + + {/* File preview strip */} + {files.length > 0 && ( +
+ {files.map((f) => ( + removeFile(f.id)} /> + ))} +
+ )} + + {/* Composer body — frosted glass */} +
+
+ {/* Input row */} +
+ {/* + button with attachment popout */} +
+ + + {/* Popout menu */} + {showAttachMenu && ( +
+ + + +
+ )} +
+ + {/* Hidden file inputs */} + { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} /> + { if (e.target.files) addFiles(e.target.files); e.target.value = "" }} /> + +
+