diff --git a/.gitignore b/.gitignore index 5ef6a52..e25f3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +.idea # debug npm-debug.log* diff --git a/README.md b/README.md index 3774656..ec84385 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,64 @@ -# React Developer Assignment: Cross-Tab Collaboration Dashboard +# Cross‑Tab Collaboration Dashboard -## Time Limit: 3 hours +Simple Next.js demo that syncs presence, a shared counter, chat (typing + delete‑own + expiration), and theme across browser tabs using `react-broadcast-sync`. -You are expected to focus on the core requirements. Bonus features are optional and may be partially implemented if time allows. - -## Overview - -Build a real-time collaboration dashboard that synchronizes user activity across multiple browser tabs using the -react-broadcast-sync library. +Open this page in multiple tabs to try it. --- -## Setup Instructions +## Quick start -1. Create a new React project using any setup you prefer (e.g., Vite, CRA, Next.js) -2. Install the required package: - ```bash - npm install react-broadcast-sync - ``` -3. Use any styling approach (CSS, Tailwind, MUI, etc.) +1) Install and run +```bash +pnpm install +pnpm dev +# then open http://localhost:3000 +``` + +2) Optional: install the library directly (already listed in dependencies) +```bash +pnpm add react-broadcast-sync +``` --- -## Requirements - Mandatory - -### 1. Custom Hook -Create a custom hook called `useCollaborativeSession` that: -- Sets up the broadcast channel -- Manages internal state for users, chat, counter -- Exposes state and actions: - - `users`, `messages`, `counter` - - `sendMessage()`, `updateCounter()`, `markTyping()`, etc. -- Internally uses `react-broadcast-sync` to handle cross-tab communication - -### 2. User Presence System -- Detect and display active users (based on tabs) -- Show a user list with: - - Username or ID (you can generate random names if needed) - - Last activity timestamp -- Detect and visually indicate when a user joins or leaves - -### 3. Shared Counter -- Counter that stays synchronized across all tabs -- Any user can increment/decrement the value -- Show which user performed the last action -- Display timestamp of last action - -### 4. Real-time Chat -- Text area for message writing -- Show typing indicators when users are actively typing -- Synchronize conversation content across all tabs -- For each message display which user sent it and its timestamp -- Allow users to delete **their own** messages from the chat with syncing across tabs -- Allow users to send a messages with expiration - -### 5. Technical Standards -- Use proper error handling and cleanup -- Use TypeScript or well-typed PropTypes -- Abstract logic into reusable components/hooks -- Keep the code modular, readable, and clean -- Synchronize existing state on page load (rehydrate from current messages/users) +## What’s inside ---- +- Custom hook `useCollaborativeSession` + - Uses `useBroadcastChannel('collab-dashboard')` from `react-broadcast-sync` + - Presence: per‑tab join/leave + lastActive + - Chat: send, delete own, timestamps, optional expiration, typing indicators (debounced) + - Counter: synced value + last actor and time + - Rehydration: new tab requests snapshot, existing tabs respond; merge is deterministic + - Theme sync (bonus): toggled theme broadcasts to all tabs -## Bonus Features -- Theme sync across tabs (light/dark mode) -- Include debouncing for frequent updates -- Implement loading states -- Add responsive layout -- Activity feed showing recent actions -- User avatar system -- Focus/cursor position indicators +- Components + - `PresenceList` – active users with last seen + - `SharedCounter` – inc/dec + last action + - `ChatPanel` – messages, typing, delete‑own, expiration input --- -## Deliverables -1. Complete source code -2. README with setup instructions and implementation notes -3. Working demo (open multiple tabs to test) +## How to test + +1. Open two tabs at `http://localhost:3000`. +2. Type in one tab – the other shows a typing indicator. +3. Send a message, try deleting your own; add an expiration (ms) and watch it disappear. +4. Increment/decrement the counter – value and last actor/time stay in sync. +5. Toggle the theme – other tabs follow instantly. --- -## Evaluation Criteria -- Proper use and integration of `react-broadcast-sync` -- Correct and clean custom hook abstraction -- Working real-time sync across tabs -- Well-structured and maintainable code -- Functional and user-friendly UI -- Handling of edge cases (e.g., expired messages, tab close) -- Bonus points for creative features, polish, or great UX +## Notes + +- User identity (`userId`) now persists per browser profile using `localStorage` (`collab_user_id`); opening new tabs shares the same user while each tab still has a unique `tabId` for presence granularity. +- The hook clears processed messages via `clearReceivedMessages` to avoid reprocessing. +- Expired messages are pruned every 2s. +- Presence pings every 5s to refresh `lastActive`. +- Identity (username) is deterministic from the stable `userId` via a hash (see `randomUsername`). +- Stale presence cleanup: tabs inactive >30s are pruned (unless it's your own user). +- Deterministic avatar colors derived from `userId` (see `avatarColorFromUserId`). +- Focus updates throttled (120ms) to reduce broadcast spam. +- Feed now includes expiration events (`chat:expire`). + +--- diff --git a/app/layout.tsx b/app/layout.tsx index 42fc323..f177b61 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import { CollaborativeSessionProvider } from "@/lib/context/CollaborativeSessionContext"; +import "../globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Collaboration Dashboard", + description: "Simple cross‑tab presence, chat, and counter demo", }; export default function RootLayout({ @@ -25,7 +26,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); diff --git a/app/page.tsx b/app/page.tsx index e974b4c..866c255 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,97 +1,100 @@ -import Image from "next/image"; -import styles from "./page.module.css"; +"use client"; +import React from "react"; +import { useCollaborativeSessionContext } from "@/lib/context/CollaborativeSessionContext"; +import PresenceList from "@/components/PresenceList"; +import SharedCounter from "@/components/SharedCounter"; +import ChatPanel from "@/components/ChatPanel"; +import { Button } from "@/components/ui/button"; +import { Sun, Moon } from "lucide-react"; +import ActivityFeed from "@/components/ActivityFeed"; export default function Home() { - return ( -
-

Home

- -
- Next.js logo -
    -
  1. - Get started by editing app/page.tsx. -
  2. -
  3. Save and see your changes instantly.
  4. -
+ const { + loading, + users, + messages, + counter, + typingUsers, + theme, + currentUser, + sendMessage, + deleteMessage, + updateCounter, + markTyping, + toggleTheme, + feed, + focus, + updateFocus, + } = useCollaborativeSessionContext(); -
- - Vercel logomark - Deploy now - - +
+
+
+

Collaboration Dashboard

+

Real-time collaboration across browser tabs

+
+
-
- + {theme === "light" ? : } + {theme === "light" ? "Light" : "Dark"} + Toggle theme + + + + {loading ? ( +
+
+
+

Loading session…

+
+
+ ) : ( +
+
+ + +
+
+ +
+ +
+

Focus Indicators

+
    + {Object.entries(focus).filter(([uid]) => uid !== currentUser.userId).map(([uid, f]) => ( +
  • + + {users.find(u => u.userId === uid)?.username ?? uid} focusing {f.element}{typeof f.cursorPos === 'number' ? ` @${f.cursorPos}` : ''} +
  • + ))} + {Object.entries(focus).filter(([uid]) => uid !== currentUser.userId).length === 0 && ( +
  • No other user focus
  • + )} +
+
+
+
+
+ )} +
); } diff --git a/components/ActivityFeed.tsx b/components/ActivityFeed.tsx new file mode 100644 index 0000000..2753456 --- /dev/null +++ b/components/ActivityFeed.tsx @@ -0,0 +1,58 @@ +"use client"; +import React from "react"; +import type { FeedItem } from "@/lib/types/session"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { History } from "lucide-react"; + +const KIND_LABEL: Record = { + "presence:join": "Joined", + "presence:leave": "Left", + "chat:send": "Message", + "chat:delete": "Delete", + "counter:update": "Counter", + "theme": "Theme", + "rehydrate": "Sync", +}; + +const kindColor = (kind: string) => { + if (kind.startsWith("presence")) return "bg-sky-500"; + if (kind.startsWith("chat")) return "bg-purple-500"; + if (kind.startsWith("counter")) return "bg-amber-500"; + if (kind === "theme") return "bg-rose-500"; + if (kind === "rehydrate") return "bg-teal-500"; + return "bg-muted"; +}; + +export const ActivityFeed = ({ items }: { items: FeedItem[] }) => { + return ( + + + + Activity + + + + {items.length === 0 ? ( +

No activity yet

+ ) : ( +
    + {items.map(i => ( +
  • + +
    +
    + {KIND_LABEL[i.kind] ?? i.kind} + {new Date(i.ts).toLocaleTimeString()} +
    +
    {i.text}{i.actorName ? ({i.actorName}) : null}
    +
    +
  • + ))} +
+ )} +
+
+ ); +}; + +export default ActivityFeed; diff --git a/components/ChatPanel.tsx b/components/ChatPanel.tsx new file mode 100644 index 0000000..6541809 --- /dev/null +++ b/components/ChatPanel.tsx @@ -0,0 +1,159 @@ +"use client"; +import React, { useMemo } from "react"; +import type { ChatMessage } from "@/lib/types/session"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { MessageSquare, Trash2, Clock, Send } from "lucide-react"; + +type Props = { + messages: ChatMessage[]; + currentUserId: string; + typingUsers: { userId: string; username: string }[]; + onSendAction: (text: string, opts?: { expiresInMs?: number }) => void; + onDeleteAction: (id: string) => void; + onTypingAction: () => void; + onFocusUpdateAction?: (element: string, cursorPos?: number) => void; +}; + +export function ChatPanel({ messages, currentUserId, typingUsers, onSendAction, onDeleteAction, onTypingAction, onFocusUpdateAction }: Props) { + const { register, handleSubmit, reset, watch } = useForm<{ text: string; expires: number | string }>({ + defaultValues: { text: "", expires: "0" }, + }); + + const submit = handleSubmit(({ text, expires }) => { + const content = (text ?? "").trim(); + if (!content) return; + const expiresInMs = parseInt(String(expires || 0), 10) || 0; + onSendAction(content, { expiresInMs: expiresInMs > 0 ? expiresInMs : undefined }); + reset({ text: "", expires }); + }); + + const visibleMessages = useMemo(() => messages.filter((m) => !m.deleted), [messages]); + const textValue = watch("text") ?? ""; + + return ( + + + + + Chat + {visibleMessages.length > 0 && ( + + {visibleMessages.length} message{visibleMessages.length !== 1 ? "s" : ""} + + )} + + + +
+ {visibleMessages.length === 0 ? ( +
+
+ +

No messages yet

+

Say hello to get started!

+
+
+ ) : ( + visibleMessages.map((m) => { + const isSelf = m.userId === currentUserId; + const time = new Date(m.timestamp).toLocaleTimeString(); + const expiresIn = m.expiresAt ? m.expiresAt - Date.now() : 0; + return ( +
+
+
+
+ {m.username.slice(0, 2).toUpperCase()} +
+
+
+ {m.username} + {isSelf && ( + + You + + )} +
+ {time} +
+
+ {isSelf && ( + + )} +
+
{m.text}
+ {m.expiresAt && ( +
+ + Expires {expiresIn > 0 ? `in ${Math.ceil(expiresIn / 1000)}s` : "soon"} +
+ )} +
+ ); + }) + )} +
+ {typingUsers.length > 0 && ( +
+
+ + + +
+ {typingUsers.map((t) => t.username).join(", ")} typing... +
+ )} +
+