Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 55 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,91 +1,78 @@
# React Developer Assignment: Cross-Tab Collaboration Dashboard
# Cross-Tab Collaboration Dashboard

## Time Limit: 3 hours
A real-time collaboration dashboard built in React that synchronizes user activity, chat, and a shared counter across multiple browser tabs using [react-broadcast-sync](https://www.npmjs.com/package/react-broadcast-sync).
Reconding is inside the main route of the project

You are expected to focus on the core requirements. Bonus features are optional and may be partially implemented if time allows.
---

## Features Implemented

## Overview
### 1. Custom Hook: `useCollaborativeSession`

Build a real-time collaboration dashboard that synchronizes user activity across multiple browser tabs using the
<a href="https://www.npmjs.com/package/react-broadcast-sync" target="_blank" rel="noopener noreferrer">react-broadcast-sync</a> library.
- Manages all collaboration state and logic.
- Tracks:
- `users`: currently active users across all tabs.
- `messages`: chat messages synchronized across tabs.
- `counter`: shared counter value.
- `typingUsers`: users currently typing.
- Exposes actions:
- `sendMessage(text, expireInMs)`: sends a chat message, optionally expiring after a set time.
- `deleteMessage(id)`: deletes **your own** messages across tabs.
- `updateCounter(delta)`: increments or decrements the shared counter.
- `startTyping()` / `stopTyping()`: broadcast typing indicators.
- `joinSession()` / `leaveSession()`: handle user presence.
- Handles **multi-tab duplicate prevention** and **message expiration**.

---

## Setup Instructions
### 2. User Presence System

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.)
- Detects and displays active users per tab.
- Shows username and last activity timestamp.
- Detects join/leave events and broadcasts updates.
- Prevents duplicate entries using `USER_SYNC` and `USER_REQUEST_SYNC`.

---

## Requirements - Mandatory
### 3. Shared Counter

### 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
- Fully synchronized across all tabs.
- Any user can increment or decrement the value.
- Displays who last updated the counter.
- Broadcasted updates trigger system messages across tabs.
- Multi-tab safe using `processedCounterMsgIds`.

### 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)
### 4. Real-Time Chat

- Messages include `userName`, `userId`, and `timestamp`.
- Typing indicators for other users.
- Messages can **expire automatically** (e.g., 5-second self-destruct).
- Users can delete **their own messages**, synced across tabs.
- Prevents infinite loops or duplicates across 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
### 5. UI Features

- Messages have **avatars** color-coded by user ID.
- Own messages show a **delete button**.
- Smooth scroll to latest message.
- Typing indicators displayed at the bottom.
- Responsive message bubbles for "me" vs others.

---

## Deliverables
1. Complete source code
2. README with setup instructions and implementation notes
3. Working demo (open multiple tabs to test)
### 6. Technical Standards

- Written in **TypeScript**.
- Logic separated into **custom hook** and **components** (`MessageContainer`, etc.).

---

## 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
## How to Run the Project

pnpm dev

### 1. Clone or create the project:
60 changes: 60 additions & 0 deletions app/components/ChatInputBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import { ChatInputBarProps } from "../types/inputbar";

export default function ChatInputBar({
text,
setText,
expireTime,
setExpireTime,
handleSend,
startTyping,
stopTyping,
}: ChatInputBarProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};

return (
<div className="flex w-full items-center gap-2 relative">
<select
value={expireTime ?? ""}
onChange={(e) =>
setExpireTime(e.target.value ? Number(e.target.value) : undefined)
}
className="px-3 py-1 rounded-full border border-gray-300 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-600 shadow-sm transition"
>
<option value="">No expiration</option>
<option value={5000}>5 seconds</option>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
</select>

{/* Input field */}
<div className="relative flex-1">
<input
value={text}
onChange={(e) => {
setText(e.target.value);
e.target.value.length > 0 ? startTyping() : stopTyping();
}}
onBlur={stopTyping}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="w-full pr-16 pl-4 py-2 rounded-full border border-gray-300 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-600 shadow-sm transition placeholder-gray-400"
/>

{/* Send button inside input */}
<button
onClick={handleSend}
className="absolute right-1 top-1/2 transform -translate-y-1/2 px-4 py-1 rounded-full bg-blue-500 hover:bg-blue-600 text-white font-semibold shadow-md transition"
>
Send
</button>
</div>
</div>
);
}
27 changes: 27 additions & 0 deletions app/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { CounterProps } from "../types/counter";

export default function Counter({ counter, updateCounter }: CounterProps) {
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded shadow flex items-center gap-4 justify-between">
<h2 className="font-semibold text-lg">Counter: {counter}</h2>

<div className="flex gap-2">
<button
onClick={() => updateCounter(+1)}
className="px-3 py-1 rounded text-white bg-green-500 hover:bg-green-600 transition"
>
+1
</button>

<button
onClick={() => updateCounter(-1)}
className="px-3 py-1 rounded text-white bg-red-500 hover:bg-red-600 transition"
>
-1
</button>
</div>
</div>
);
}
23 changes: 23 additions & 0 deletions app/components/DarkModeToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { FaMoon, FaSun } from "react-icons/fa";
import { DarkModeProps } from "../types/darkmode";

export default function DarkModeToggleButton({
darkMode,
setDarkMode,
}: DarkModeProps) {
return (
<button
onClick={() => setDarkMode(!darkMode)}
className="absolute top-4 right-4 flex items-center gap-2 px-4 py-2 rounded-full
bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600
transition shadow"
>
{darkMode ? <FaSun /> : <FaMoon />}
<span className="hidden sm:inline text-sm font-medium text-gray-800 dark:text-gray-200">
{darkMode ? "Light Mode" : "Dark Mode"}
</span>
</button>
);
}
116 changes: 116 additions & 0 deletions app/components/MessageContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

import { useEffect, useRef } from "react";
import { MessageContainerProps } from "../types/messageContainer";

// Avatar colors
const avatarColors = [
"bg-red-500",
"bg-green-500",
"bg-blue-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
];

function getAvatarColor(userId: string) {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarColors[Math.abs(hash) % avatarColors.length];
}

export default function MessageContainer({
messages,
currentUser,
deleteMessage,
typingUsers,
}: MessageContainerProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);

return (
<div className="flex flex-col flex-1">
<ul className="flex-1 max-h-96 overflow-y-auto space-y-4 p-2">
{messages.map((msg) => {
const isMe = msg.userId === currentUser?.id;
console.log("Rendering message:", msg);
const avatarColor = getAvatarColor(msg.userId);

return (
<li
key={msg.id}
className={`flex items-start gap-2 ${
isMe ? "justify-end" : "justify-start"
}`}
>
{!isMe && (
<div className="flex flex-col items-center gap-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${avatarColor}`}
>
{msg.userName[0].toUpperCase()}
</div>
</div>
)}

<div
className={`flex flex-col max-w-xs sm:max-w-md px-4 py-2 rounded-2xl break-words ${
isMe
? "bg-blue-500 text-white rounded-br-none"
: "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-bl-none"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm">
{isMe ? "YOU: " : msg.userName + ": "}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>

<p className="text-sm leading-snug">{msg.text}</p>
</div>

{/* Avatar + delete button on the right for own messages */}
{isMe && (
<div className="flex flex-col items-center gap-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${avatarColor}`}
>
{msg.userName[0].toUpperCase()}
</div>
<button
onClick={() => deleteMessage(msg.id)}
className="text-red-600 hover:text-red-800 text-sm"
title="Delete message"
>
🗑
</button>
</div>
)}
</li>
);
})}
<div ref={messagesEndRef} />
</ul>

{typingUsers.length > 0 && (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1 ml-2">
{`${typingUsers.map((u) => u.name).join(", ")} ${
typingUsers.length === 1 ? "is" : "are"
} typing...`}
</div>
)}
</div>
);
}
Loading