diff --git a/;q b/;q deleted file mode 100644 index b64fc6c..0000000 --- a/;q +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export async function middleware(request: NextRequest) { - const token = request.cookies.get("token")?.value; - - if (!token) { - return NextResponse.redirect(new URL("/auth", request.url)); - } - - try { - const res = await fetch("https://api.openchat.qzz.io/auth/me", { - headers: { - cookie: `token=${token}`, - }, - }); - - if (!res.ok) { - return NextResponse.redirect(new URL("/auth", request.url)); - } - - return NextResponse.next(); - } catch { - return NextResponse.redirect(new URL("/auth", request.url)); - } -} - -export const config = { - matcher: ["/zone/:path*"], -}; diff --git a/README.md b/README.md index 06bbf3a..5060b7d 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,138 @@ -# OpenChat (monorepo) - -This repository contains a small monorepo with a frontend (Next + React), a backend (Express + Socket.io,Prisma), and shared packages (`@openchat/lib`, `@openchat/components`). - -This README explains how to get the project running locally and developer recommendations. - -Requirements - -# Node.js: >= 20.19 (recommended 20.x). A `.nvmrc` file is included for convenience. -# OpenChat (monorepo) - -This repository contains a monorepo for OpenChat: - -- `apps/frontend` — Next + React client -- `apps/backend` — Express + Socket.io server (with Prisma schema) -- `packages/lib` — shared utilities (helpers, socket client) -- `packages/components` — shared UI components - -This README covers how to set up the project locally, common workflows for developing across the workspace, and troubleshooting tips. - -## Requirements - -- Node.js: >= 20.x (20.x recommended). Use `nvm` to manage Node versions — a `.nvmrc` is included. -- pnpm: v7+ (workspace-aware). Install with `npm i -g pnpm` if needed. - -## Quick setup - -1. Use the recommended Node version: - -```bash -nvm install 20 -nvm use 20 -node -v # should be >= 20.x (20.x recommended) -``` - -2. Install dependencies (from repo root): - -```bash -pnpm install -``` - -3. Run development servers: - -```bash -pnpm run dev # runs frontend + backend concurrently (defined in root package.json) -# or run individually -pnpm dev:frontend -pnpm dev:backend -``` - -Open the frontend URL printed by Vite (typically http://localhost:3000). The backend listens on port 4000 by default. - -## Environment variables - -- `NEXT_SOCKET_URL` — frontend socket URL (default: `http://localhost:4000`). Use this in `.env` at the frontend root if needed. -- `PORT` or `SOCKET_PORT` — backend port (default: `4000`). - -Create an `.env` file in `apps/frontend` or `apps/backend` for local overrides when needed. - -## Building - -To build all packages and apps in the workspace: - -```bash -pnpm build +# 🚀 OpenChat + +**The Ultimate Real-Time Chat & Voice Platform.** +A high-performance monorepo featuring non-blocking voice calls, persistent sessions, and a premium messaging experience. + +--- + +## ✨ Key Features + +- **🛡️ Production-Ready Voice Calling**: + - Robust WebRTC & Socket.io signaling. + - **Non-Blocking Floating UI**: Keep chatting while on a call. + - **Server-Side Persistence**: Refresh the page? No problem. The call automatically reconnects. + - **Graceful Disconnects**: 10-second grace period for network drops. +- **💬 Real-Time Messaging**: Instant delivery via Socket.io with typing indicators and read receipts. +- **📁 Zone-Based Communities**: Organize chats into Discord-style "Zones" with dedicated text and voice channels. +- **🔐 Secure Authentication**: + - JWT + HTTP-only Cookie authentication. + - Google OAuth integration. + - Email verification system. +- **🎨 Premium UI/UX**: + - Dark-mode first, glassmorphic design. + - Smooth micro-animations powered by Framer Motion. + - Responsive, compact floating call overlays. +- **⚙️ Integrated Settings**: + - Profile management (Avatar upload/removal, bio, username). + - Account security and notifications. + +--- + +## 🛠️ Technology Stack + +| **Component** | **Technologies** | +| :--- | :--- | +| **Frontend** | [Next.js 15](https://nextjs.org/) (Turbopack), [React 19](https://react.dev/), [Tailwind CSS](https://tailwindcss.com/), [Zustand](https://zustand-demo.pmnd.rs/), [Framer Motion](https://www.framer.com/motion/) | +| **Backend** | [Node.js](https://nodejs.org/), [Express](https://expressjs.com/), [Socket.io](https://socket.io/), [Prisma ORM](https://www.prisma.io/) | +| **Database** | [PostgreSQL](https://www.postgresql.org/) | +| **Real-Time** | WebRTC (PeerConnection API), Socket.io | +| **Monorepo** | [pnpm Workspaces](https://pnpm.io/workspaces) | + +--- + +## 🚀 Getting Started + +### Prerequisites + +- **Node.js** >= 20.x +- **pnpm** >= 9.x +- **PostgreSQL** instance + +### Setup + +1. **Clone & Install**: + ```bash + git clone https://github.com/your-username/openchat.git + cd openchat + pnpm install + ``` + +2. **Environment Configuration**: + Create `.env.local` files in both `apps/frontend` and `apps/backend` (or use the root `.env.local` for shared values). + + **Backend (`apps/backend/.env`):** + ```env + DATABASE_URL="postgresql://user:password@localhost:5432/openchat" + JWT_SECRET="your_secret_key" + CLIENT_URL="http://localhost:3000" + BASE_URL="http://localhost:4000" + ``` + + **Frontend (`apps/frontend/.env.local`):** + ```env + NEXT_PUBLIC_API_URL="http://localhost:4000" + NEXT_PUBLIC_GOOGLE_CLIENT_ID="your_google_id" + ``` + +3. **Database Migration**: + ```bash + cd apps/backend + pnpm prisma generate + pnpm prisma migrate dev + ``` + +4. **Run Development Mode**: + From the project root: + ```bash + pnpm dev + ``` + - Frontend: `http://localhost:3000` + - Backend: `http://localhost:4000` + +--- + +## 🏗️ Project Structure + +```text +├── apps/ +│ ├── frontend/ # Next.js Application (Client) +│ └── backend/ # Express & Socket.io Server +├── packages/ +│ ├── components/ # Shared shadcn/ui components +│ ├── lib/ # Shared utilities & API clients +│ └── types/ # Shared TypeScript interfaces +├── pnpm-workspace.yaml # Workspace configuration +└── README.md # You are here! ``` -To build a single package/app (example frontend): - -```bash -pnpm --filter frontend build -``` - -## Prisma (backend) - -If you change the Prisma schema (`apps/backend/prisma/schema.prisma`) apply migrations locally with: - -```bash -cd apps/backend -npx prisma migrate dev -``` - -Or generate clients only: - -```bash -npx prisma generate -``` - -## Working with shared packages (developer workflow) - -- Import shared code using the workspace package names, e.g.: - -```ts -import { cn, socket } from '@openchat/lib' -import { Button } from '@openchat/components' -``` - -- During development, the Vite config and TypeScript path mappings resolve those imports to the local `src/` folders so you can edit packages in place. - -- When editing a package (`packages/lib` or `packages/components`), run that package's build (or run the workspace build) so consuming apps get the latest `dist/` outputs when necessary: - -```bash -pnpm --filter @openchat/lib build -pnpm build -``` - -## Clean generated sources - -- Avoid committing generated JS inside `src/` of packages. Only `dist/` should contain build artifacts. -- The repo includes clean scripts in packages to remove stray `.js` in `src` before building. To run all clean scripts: - -```bash -pnpm run clean -``` - -## Common tasks & useful commands - -- Install dependencies: `pnpm install` -- Start frontend dev: `pnpm --filter frontend dev` -- Start backend dev: `pnpm --filter backend dev` -- Start both: `pnpm run dev` -- Build everything: `pnpm build` -- Run workspace tests (if any): `pnpm test` - -## Troubleshooting - --- If Vite fails with Node crypto errors, you're likely on an unsupported Node version. Switch to Node 20.x: - -```bash -nvm install 20 -nvm use 20 -``` - -- If shared imports resolve incorrectly, verify `tsconfig.json` `paths` and `apps/frontend/next.config.js` aliases are present. They map `@openchat/*` to the packages' `src` folders. - -- If Tailwind styles don't appear in a consuming app, check PostCSS configuration and ensure `@tailwind` and `@import` ordering is correct in the app's `globals.css`. +--- -## Publishing packages +## 📡 Voice Call Architecture -- If you plan to publish packages, add an `exports` field to each package's `package.json` and produce both ESM and CJS outputs in the build. For internal development the TypeScript path mappings and Vite aliases are sufficient. +OpenChat uses a custom-built WebRTC signaling state machine: -## How to add a new package/app +1. **Initiation**: Client A emits `call:user` via Sockets. +2. **Tracking**: Backend stores call metadata in an `activeCalls` Map for persistence. +3. **Offer/Answer**: WebRTC `RTCPeerConnection` handshake occurs through Socket.io. +4. **ICE Candidates**: Network candidates are queued and drained only after the remote description is set to ensure 100% connection success. +5. **Reconnection**: If Client B refreshes, they emit `call:check`. The server provides the current call state, allowing Client B to re-mount the `GlobalCallProvider` and re-negotiate the stream. -1. Create a new folder under `apps/` or `packages/`. -2. Add a `package.json` with the workspace name (e.g. `@openchat/yourpkg`). -3. Add TypeScript sources under `src/` and update root `pnpm build` if needed. -4. Add path mappings in the root `tsconfig.json` if you want to import it by package name during dev. +--- -## CI +## 🤝 Contributing -This repository does not include any CI/workflow configuration by default. If you want CI, I can add a GitHub Actions workflow that uses Node 20 and runs builds, linting, and tests. +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request -## Need help? +--- -If you'd like, I can: +## 📄 License -- Remove remaining generated JS files under `src/` across the repo and add `prebuild` scripts to enforce a clean source tree. -- Add `exports` fields to all `package.json` files and produce ESM+CJS builds. -- Integrate Tailwind + shadcn into `apps/frontend` (or add it to other apps) and wire workspace imports for UI components. +Distributed under the **MIT License**. See `LICENSE` for more information. -Tell me which of the above you'd like next and I will implement it. +--- -## License -MIT License © 2025 OpenChat -Original author and project owner. +**Built with ❤️ by the OpenChat Team.** diff --git a/apps/backend/prisma/migrations/20260313061451_add_discord_zones/migration.sql b/apps/backend/prisma/migrations/20260313061451_add_discord_zones/migration.sql new file mode 100644 index 0000000..508563c --- /dev/null +++ b/apps/backend/prisma/migrations/20260313061451_add_discord_zones/migration.sql @@ -0,0 +1,45 @@ +-- AlterTable +ALTER TABLE "Chat" ADD COLUMN "zoneId" INTEGER; + +-- CreateTable +CREATE TABLE "Zone" ( + "id" SERIAL NOT NULL, + "publicId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "avatar" TEXT, + "description" TEXT, + "ownerId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ZoneMember" ( + "id" SERIAL NOT NULL, + "zoneId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "role" "ZoneRole" NOT NULL DEFAULT 'MEMBER', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ZoneMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_publicId_key" ON "Zone"("publicId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ZoneMember_zoneId_userId_key" ON "ZoneMember"("zoneId", "userId"); + +-- AddForeignKey +ALTER TABLE "Chat" ADD CONSTRAINT "Chat_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Zone" ADD CONSTRAINT "Zone_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ZoneMember" ADD CONSTRAINT "ZoneMember_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ZoneMember" ADD CONSTRAINT "ZoneMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260313062252_add_channels/migration.sql b/apps/backend/prisma/migrations/20260313062252_add_channels/migration.sql new file mode 100644 index 0000000..f600216 --- /dev/null +++ b/apps/backend/prisma/migrations/20260313062252_add_channels/migration.sql @@ -0,0 +1,59 @@ +/* + Warnings: + + - You are about to drop the column `zoneId` on the `Chat` table. All the data in the column will be lost. + - You are about to drop the `Zone` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `ZoneMember` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "ChannelType" AS ENUM ('TEXT', 'VOICE'); + +-- DropForeignKey +ALTER TABLE "Chat" DROP CONSTRAINT "Chat_zoneId_fkey"; + +-- DropForeignKey +ALTER TABLE "Zone" DROP CONSTRAINT "Zone_ownerId_fkey"; + +-- DropForeignKey +ALTER TABLE "ZoneMember" DROP CONSTRAINT "ZoneMember_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ZoneMember" DROP CONSTRAINT "ZoneMember_zoneId_fkey"; + +-- AlterTable +ALTER TABLE "Chat" DROP COLUMN "zoneId"; + +-- AlterTable +ALTER TABLE "Message" ADD COLUMN "channelId" INTEGER; + +-- DropTable +DROP TABLE "Zone"; + +-- DropTable +DROP TABLE "ZoneMember"; + +-- CreateTable +CREATE TABLE "Channel" ( + "id" SERIAL NOT NULL, + "publicId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "ChannelType" NOT NULL DEFAULT 'TEXT', + "chatId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Channel_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Channel_publicId_key" ON "Channel"("publicId"); + +-- CreateIndex +CREATE INDEX "Channel_chatId_idx" ON "Channel"("chatId"); + +-- AddForeignKey +ALTER TABLE "Message" ADD CONSTRAINT "Message_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Channel" ADD CONSTRAINT "Channel_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index fd89840..2928263 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -124,6 +124,9 @@ model Message { chat Chat @relation(fields: [chatId], references: [id]) sender User @relation("UserSentMessages", fields: [senderId], references: [id]) + + channelId Int? + channel Channel? @relation(fields: [channelId], references: [id]) } model MessageReaction { @@ -159,6 +162,29 @@ model Chat { participants ChatParticipant[] messages Message[] + channels Channel[] +} + +enum ChannelType { + TEXT + VOICE +} + +model Channel { + id Int @id @default(autoincrement()) + publicId String @unique + name String + type ChannelType @default(TEXT) + + chatId Int + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + + messages Message[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([chatId]) } enum ZoneRole { diff --git a/apps/backend/src/controllers/chat.controller.ts b/apps/backend/src/controllers/chat.controller.ts index fddfc52..aeaf234 100644 --- a/apps/backend/src/controllers/chat.controller.ts +++ b/apps/backend/src/controllers/chat.controller.ts @@ -57,10 +57,22 @@ export const getChatMessages = async (req: Request, res: Response) => { return res.status(403).json({ message: "Forbidden" }); } - const { cursor } = req.query + const { cursor, channelPublicId } = req.query as { cursor?: string; channelPublicId?: string } + + let channelId: number | undefined + if (channelPublicId) { + const channel = await prisma.channel.findUnique({ + where: { publicId: channelPublicId }, + select: { id: true } + }) + channelId = channel?.id + } const messages = await prisma.message.findMany({ - where: { chatId: chat.id }, + where: { + chatId: chat.id, + ...(channelId !== undefined ? { channelId } : { channelId: null }) + }, take: 50, ...(cursor && { cursor: { id: Number(cursor) }, @@ -143,6 +155,59 @@ export const getChats = async (req: Request, res: Response) => { } }; +/** + * GET /chats/:chatPublicId + */ +export const getChat = async (req: Request, res: Response) => { + try { + const userId = req.user?.id; + const { chatPublicId } = req.params; + + if (!userId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const chat = await prisma.chat.findUnique({ + where: { publicId: chatPublicId }, + include: { + participants: { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }, + }, + }, + }); + + if (!chat) { + return res.status(404).json({ message: "Chat not found" }); + } + + const isMember = chat.participants.some(p => p.userId === userId); + if (!isMember) { + return res.status(403).json({ message: "Forbidden" }); + } + + res.json({ + chat: { + chatPublicId: chat.publicId, + type: chat.type, + name: chat.name, + avatar: chat.avatar ?? null, + participants: chat.participants.map((p: any) => p.user), + } + }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Internal server error" }); + } +}; + /** * POST /chats/start */ diff --git a/apps/backend/src/controllers/user.controller.ts b/apps/backend/src/controllers/user.controller.ts index 3d41889..bacc40b 100644 --- a/apps/backend/src/controllers/user.controller.ts +++ b/apps/backend/src/controllers/user.controller.ts @@ -40,3 +40,18 @@ export const updateAvatar = async (req: Request, res: Response) => { res.status(400).json({ message: err.message }) } } + +export const removeAvatar = async (req: Request, res: Response) => { + try { + const userId = req.user?.id + if (!userId) { + return res.status(401).json({ message: 'Not authenticated' }) + } + + const user = await UserService.removeAvatar(userId) + + res.json({ user }) + } catch (err: any) { + res.status(400).json({ message: err.message }) + } +} diff --git a/apps/backend/src/controllers/zones.controller.ts b/apps/backend/src/controllers/zones.controller.ts index c0d557b..73e204c 100644 --- a/apps/backend/src/controllers/zones.controller.ts +++ b/apps/backend/src/controllers/zones.controller.ts @@ -103,9 +103,14 @@ export const createGroup = [ { userId: creatorId, role: ZoneRole.OWNER }, ...validUsers.map(u => ({ userId: u.id, role: ZoneRole.MEMBER })) ] + }, + channels: { + create: [ + { publicId: crypto.randomUUID(), name: "general", type: "TEXT" } + ] } }, - include: { participants: true } + include: { participants: true, channels: true } }) res.json({ zone: { @@ -197,3 +202,69 @@ export const leaveZone = async (req: Request, res: Response) => { res.status(500).json({ message: "Failed to leave zone" }) } } + +export const getZoneChannels = async (req: Request, res: Response) => { + try { + const userId = req.user?.id + const { chatPublicId } = req.params + if (!userId) return res.status(401).json({ message: "Unauthorized" }) + + const chat = await prisma.chat.findUnique({ + where: { publicId: chatPublicId }, + include: { + participants: { where: { userId } }, + channels: true + } + }) + + if (!chat || chat.participants.length === 0) { + return res.status(403).json({ message: "Forbidden" }) + } + + res.json({ channels: chat.channels }) + } catch (err) { + console.error(err) + res.status(500).json({ message: "Failed to fetch channels" }) + } +} + +export const createChannel = async (req: Request, res: Response) => { + try { + const userId = req.user?.id + const { chatPublicId } = req.params + const { name, type } = req.body as { name: string; type?: "TEXT" | "VOICE" } + + if (!userId) return res.status(401).json({ message: "Unauthorized" }) + if (!name?.trim()) return res.status(400).json({ message: "Channel name required" }) + + const chat = await prisma.chat.findUnique({ + where: { publicId: chatPublicId }, + include: { + participants: { where: { userId } } + } + }) + + if (!chat || chat.participants.length === 0) { + return res.status(403).json({ message: "Forbidden" }) + } + + const participant = chat.participants[0] + if (participant.role !== ZoneRole.OWNER && participant.role !== ZoneRole.ADMIN) { + return res.status(403).json({ message: "Only managers can create channels" }) + } + + const channel = await prisma.channel.create({ + data: { + publicId: crypto.randomUUID(), + name: name.toLowerCase().replace(/\s+/g, '-'), + type: type || "TEXT", + chatId: chat.id + } + }) + + res.json({ channel }) + } catch (err) { + console.error(err) + res.status(500).json({ message: "Failed to create channel" }) + } +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 8fbbfe8..57a25b4 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,3 +1,4 @@ +import "dotenv/config" import http from 'http' import { Server } from 'socket.io' import { app } from './app.js' @@ -6,6 +7,7 @@ import { isAllowedOrigin } from './config/origin.js' import { socketAuth } from './socket/auth.js' import { prisma } from './config/prisma.js' import { callHandler } from "./socket/callHandler.js" +import { channelCallHandler } from "./socket/channelCallHandler.js" const port = process.env.PORT || 4000 @@ -34,6 +36,36 @@ io.on('connection', async (socket) => { socket.join(`user:${userId}`) + // Update online status and notify friends + await prisma.user.update({ + where: { id: userId }, + data: { isOnline: true }, + }) + + const friendsList = await prisma.friend.findMany({ + where: { + OR: [{ user1Id: userId }, { user2Id: userId }], + }, + }) + + const friendIds = friendsList.map((f) => + f.user1Id === userId ? f.user2Id : f.user1Id + ) + + friendIds.forEach((id) => { + io.to(`user:${id}`).emit("user:online", { userId }) + }) + + // Send current online friends to the user + const onlineFriends = await prisma.user.findMany({ + where: { + id: { in: friendIds }, + isOnline: true, + }, + select: { id: true }, + }) + socket.emit("friends:online", onlineFriends.map(f => f.id)) + const chats = await prisma.chat.findMany({ where: { participants: { @@ -51,9 +83,19 @@ io.on('connection', async (socket) => { privateChatHandler(io, socket) callHandler(io, socket) + channelCallHandler(io, socket) - socket.on('disconnect', () => { + socket.on('disconnect', async () => { console.log(`Socket disconnected: ${socket.id} (user ${userId})`) + + await prisma.user.update({ + where: { id: userId }, + data: { isOnline: false }, + }) + + friendIds.forEach((id) => { + io.to(`user:${id}`).emit("user:offline", { userId }) + }) }) }) diff --git a/apps/backend/src/routes/chat.routes.ts b/apps/backend/src/routes/chat.routes.ts index f90159b..4c2ba86 100644 --- a/apps/backend/src/routes/chat.routes.ts +++ b/apps/backend/src/routes/chat.routes.ts @@ -3,6 +3,7 @@ import { authMiddleware } from "../middlewares/auth.middleware.js"; import { getChatMessages, getChats, + getChat, startChat, editMessage, deleteMessage, @@ -12,6 +13,7 @@ import { const router = Router(); router.get("/", authMiddleware, getChats) +router.get("/:chatPublicId", authMiddleware, getChat) router.post("/start", authMiddleware, startChat) router.get( @@ -31,4 +33,3 @@ router.patch("/messages/:id", authMiddleware, editMessage) router.delete("/messages/:id", authMiddleware, deleteMessage) export default router; - diff --git a/apps/backend/src/routes/user.routes.ts b/apps/backend/src/routes/user.routes.ts index 9dd3f58..3cb8f69 100644 --- a/apps/backend/src/routes/user.routes.ts +++ b/apps/backend/src/routes/user.routes.ts @@ -2,10 +2,11 @@ import { Router } from "express"; import { prisma } from "../config/prisma.js"; import { authMiddleware } from '../middlewares/auth.middleware.js' import { upload } from "../middlewares/upload.middleware.js"; -import { updateAvatar, updateProfile } from "../controllers/user.controller.js"; +import { updateAvatar, updateProfile, removeAvatar } from "../controllers/user.controller.js"; const router = Router(); +router.delete('/avatar', authMiddleware, removeAvatar) router.patch('/profile', authMiddleware, updateProfile) router.get("/search", authMiddleware, async (req, res) => { diff --git a/apps/backend/src/routes/zones.routes.ts b/apps/backend/src/routes/zones.routes.ts index 7e3a480..f552e5d 100644 --- a/apps/backend/src/routes/zones.routes.ts +++ b/apps/backend/src/routes/zones.routes.ts @@ -4,14 +4,17 @@ import { addUserToGroup, createGroup, getZoneMembers, - getZones, leaveZone, removeUserFromGroup + getZones, leaveZone, removeUserFromGroup, + getZoneChannels, createChannel } from "../controllers/zones.controller.js"; const router = Router(); router.get("/", authMiddleware, getZones); router.get("/:chatPublicId/members", authMiddleware, getZoneMembers); +router.get("/:chatPublicId/channels", authMiddleware, getZoneChannels); router.post("/", authMiddleware, createGroup); +router.post("/:chatPublicId/channels", authMiddleware, createChannel); router.post("/:chatPublicId/members", authMiddleware, addUserToGroup); router.post("/:chatPublicId/leave", authMiddleware, leaveZone); router.delete("/:chatPublicId/members/:userId", authMiddleware, removeUserFromGroup); diff --git a/apps/backend/src/scripts/init-channels.ts b/apps/backend/src/scripts/init-channels.ts new file mode 100644 index 0000000..c4dea59 --- /dev/null +++ b/apps/backend/src/scripts/init-channels.ts @@ -0,0 +1,38 @@ +import { PrismaClient } from '@prisma/client' +import crypto from 'crypto' + +const prisma = new PrismaClient() + +async function main() { + const zones = await prisma.chat.findMany({ + where: { + type: 'ZONE', + channels: { + none: {} + } + } + }) + + console.log(`Found ${zones.length} zones without channels.`) + + for (const zone of zones) { + await prisma.channel.create({ + data: { + publicId: crypto.randomUUID(), + name: 'general', + type: 'TEXT', + chatId: zone.id + } + }) + console.log(`Created 'general' channel for zone: ${zone.name}`) + } +} + +main() + .catch(e => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/apps/backend/src/services/user.service.ts b/apps/backend/src/services/user.service.ts index d3f02b6..06508c8 100644 --- a/apps/backend/src/services/user.service.ts +++ b/apps/backend/src/services/user.service.ts @@ -88,4 +88,35 @@ export class UserService { }, }) } + + static async removeAvatar(userId: number) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }) + + if (!user) { + throw new Error('User not found') + } + + if (user.avatar) { + const oldPath = path.join('uploads', user.avatar) + try { + await fs.promises.unlink(oldPath) + } catch { } + } + + return prisma.user.update({ + where: { id: userId }, + data: { avatar: null }, + select: { + id: true, + name: true, + username: true, + email: true, + avatar: true, + bio: true, + emailVerified: true, + }, + }) + } } diff --git a/apps/backend/src/socket/callHandler.ts b/apps/backend/src/socket/callHandler.ts index ba31a6c..4b964bf 100644 --- a/apps/backend/src/socket/callHandler.ts +++ b/apps/backend/src/socket/callHandler.ts @@ -1,7 +1,25 @@ import { Server, Socket } from "socket.io" import { prisma } from "../config/prisma.js" -const activeCalls = new Map() +type CallStatus = "ringing" | "active" + +interface CallParticipant { + userId: number + socketId: string | null + isCaller: boolean + lastSeen: number +} + +interface ActiveCall { + chatPublicId: string + status: CallStatus + participants: Map + startTime?: number + cleanupTimers: Map +} + +const activeCalls = new Map() +const userToCall = new Map() interface AuthenticatedSocket extends Socket { data: { @@ -9,57 +27,110 @@ interface AuthenticatedSocket extends Socket { } } -interface CallPayload { - toUserId: number - chatPublicId: string -} +const DISCONNECT_TIMEOUT = 10000 // 10 seconds -export function callHandler( - io: Server, - socket: AuthenticatedSocket -) { +export function callHandler(io: Server, socket: AuthenticatedSocket) { const userId = socket.data.userId if (!userId) return /* ========================= - CALL USER + PERSISTENCE / RECONNECT + ========================== */ + socket.on("call:check", async () => { + const callId = userToCall.get(userId) + if (!callId) { + socket.emit("call:status", { status: "idle" }) + return + } + + const call = activeCalls.get(callId) + if (!call) { + userToCall.delete(userId) + socket.emit("call:status", { status: "idle" }) + return + } + + // Update participant + const participant = call.participants.get(userId) + if (participant) { + participant.socketId = socket.id + participant.lastSeen = Date.now() + } + + // Clear specific cleanup timer for this user + const userTimer = call.cleanupTimers.get(userId) + if (userTimer) { + clearTimeout(userTimer) + call.cleanupTimers.delete(userId) + } + + // Join room + socket.join(`chat:${call.chatPublicId}`) + + // Notify others + socket.to(`chat:${call.chatPublicId}`).emit("call:rejoined", { userId }) + + const partnerId = Array.from(call.participants.keys()).find(id => id !== userId) + let partnerInfo = null + if (partnerId) { + const p = await prisma.user.findUnique({ + where: { id: partnerId }, + select: { id: true, username: true, avatar: true } + }) + partnerInfo = p ? { + id: p.id, + name: p.username, + image: p.avatar ? `${process.env.BASE_URL}/uploads/${p.avatar}` : null + } : null + } + + socket.emit("call:status", { + status: call.status === "ringing" ? (participant?.isCaller ? "calling" : "incoming") : "connected", + chatPublicId: call.chatPublicId, + user: partnerInfo, + isCaller: participant?.isCaller, + startTime: call.startTime + }) + }) + + /* ========================= + START CALL ========================== */ - socket.on("call:user", async ({ toUserId, chatPublicId }: CallPayload) => { + socket.on("call:user", async ({ toUserId, chatPublicId }: { toUserId: number; chatPublicId: string }) => { try { - if (!toUserId) return - if (toUserId === userId) return - - const existingPartner = activeCalls.get(userId) - if (existingPartner) { - io.to(existingPartner.toString()).emit("call:ended") - activeCalls.delete(existingPartner) - activeCalls.delete(userId) - } + if (!toUserId || toUserId === userId) return + if (userToCall.has(userId) || userToCall.has(toUserId)) return - // هات بيانات المتصل من DB const caller = await prisma.user.findUnique({ where: { id: userId }, - select: { - id: true, - username: true, - avatar: true, - }, + select: { id: true, username: true, avatar: true } }) - if (!caller) return - activeCalls.set(userId, toUserId) - activeCalls.set(toUserId, userId) + const participants = new Map() + participants.set(userId, { userId, socketId: socket.id, isCaller: true, lastSeen: Date.now() }) + participants.set(toUserId, { userId: toUserId, socketId: null, isCaller: false, lastSeen: Date.now() }) - io.to(toUserId.toString()).emit("incoming:call", { + const newCall: ActiveCall = { + chatPublicId, + status: "ringing", + participants, + cleanupTimers: new Map() + } + + activeCalls.set(chatPublicId, newCall) + userToCall.set(userId, chatPublicId) + userToCall.set(toUserId, chatPublicId) + + socket.join(`chat:${chatPublicId}`) + + io.to(`user:${toUserId}`).emit("incoming:call", { chatPublicId, user: { id: caller.id, name: caller.username, - image: caller.avatar - ? `${process.env.BASE_URL}/uploads/${caller.avatar}` - : null, - }, + image: caller.avatar ? `${process.env.BASE_URL}/uploads/${caller.avatar}` : null + } }) } catch (err) { console.error("CALL USER ERROR:", err) @@ -67,48 +138,97 @@ export function callHandler( }) /* ========================= - ACCEPT + ACCEPT / REJECT / END ========================== */ - socket.on("call:accept", ({ toUserId, chatPublicId }: CallPayload) => { - io.to(toUserId.toString()).emit("call:accepted", { - chatPublicId, - }) + socket.on("call:accept", ({ chatPublicId }: { chatPublicId: string }) => { + const call = activeCalls.get(chatPublicId) + if (!call) return + + call.status = "active" + call.startTime = Date.now() + socket.to(`chat:${chatPublicId}`).emit("call:accepted", { chatPublicId }) + }) + + socket.on("call:reject", ({ chatPublicId }: { chatPublicId: string }) => { + endCall(chatPublicId, "rejected") + }) + + socket.on("call:end", ({ chatPublicId }: { chatPublicId: string }) => { + endCall(chatPublicId, "ended") }) /* ========================= - REJECT + SIGNALING ========================== */ - socket.on("call:reject", ({ toUserId, chatPublicId }: CallPayload) => { - activeCalls.delete(userId) - activeCalls.delete(toUserId) + socket.on("call:offer", ({ chatPublicId, offer }) => { + socket.to(`chat:${chatPublicId}`).emit("call:offer", { chatPublicId, offer, from: userId }) + }) - io.to(toUserId.toString()).emit("call:rejected", { - chatPublicId, - }) + socket.on("call:answer", ({ chatPublicId, answer }) => { + socket.to(`chat:${chatPublicId}`).emit("call:answer", { chatPublicId, answer }) + }) + + socket.on("call:ice", ({ chatPublicId, candidate }) => { + socket.to(`chat:${chatPublicId}`).emit("call:ice", { chatPublicId, candidate }) }) /* ========================= - END CALL + ROOM ========================== */ - socket.on("call:end", ({ toUserId, chatPublicId }: CallPayload) => { - activeCalls.delete(userId) - activeCalls.delete(toUserId) - - io.to(toUserId.toString()).emit("call:ended", { - chatPublicId, - }) + socket.on("join-room", ({ chatPublicId }) => { + socket.join(`chat:${chatPublicId}`) + const call = activeCalls.get(chatPublicId) + if (call) { + const p = call.participants.get(userId) + if (p) { + p.socketId = socket.id + const timer = call.cleanupTimers.get(userId) + if (timer) { + clearTimeout(timer) + call.cleanupTimers.delete(userId) + } + } + } }) /* ========================= DISCONNECT ========================== */ socket.on("disconnect", () => { - const partner = activeCalls.get(userId) - - if (partner) { - io.to(partner.toString()).emit("call:ended") - activeCalls.delete(userId) - activeCalls.delete(partner) + const callId = userToCall.get(userId) + if (!callId) return + + const call = activeCalls.get(callId) + if (!call) return + + const participant = call.participants.get(userId) + if (participant) { + participant.socketId = null + participant.lastSeen = Date.now() + + // Notify other + socket.to(`chat:${call.chatPublicId}`).emit("call:partner-disconnected", { userId }) + + // Start timer for this user + const timer = setTimeout(() => { + endCall(callId, "timeout") + }, DISCONNECT_TIMEOUT) + call.cleanupTimers.set(userId, timer) } }) + + function endCall(chatPublicId: string, reason: string) { + const call = activeCalls.get(chatPublicId) + if (!call) return + + io.to(`chat:${chatPublicId}`).emit("call:ended", { chatPublicId, reason }) + + for (const pId of call.participants.keys()) { + userToCall.delete(pId) + } + for (const timer of call.cleanupTimers.values()) { + clearTimeout(timer) + } + activeCalls.delete(chatPublicId) + } } diff --git a/apps/backend/src/socket/channelCallHandler.ts b/apps/backend/src/socket/channelCallHandler.ts new file mode 100644 index 0000000..65300c1 --- /dev/null +++ b/apps/backend/src/socket/channelCallHandler.ts @@ -0,0 +1,113 @@ +import { Server, Socket } from "socket.io" +import { prisma } from "../config/prisma.js" + +interface ChannelParticipant { + userId: number + socketId: string + username: string + avatar: string | null +} + +interface ActiveChannelCall { + channelPublicId: string + participants: Map +} + +const activeChannelCalls = new Map() + +interface AuthenticatedSocket extends Socket { + data: { + userId: number + } +} + +export function channelCallHandler(io: Server, socket: AuthenticatedSocket) { + const userId = socket.data.userId + if (!userId) return + + socket.on("channel:join-call", async ({ channelPublicId }: { channelPublicId: string }) => { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, avatar: true } + }) + if (!user) return + + let call = activeChannelCalls.get(channelPublicId) + if (!call) { + call = { + channelPublicId, + participants: new Map() + } + activeChannelCalls.set(channelPublicId, call) + } + + const participant: ChannelParticipant = { + userId: user.id, + socketId: socket.id, + username: user.username, + avatar: user.avatar ? `${process.env.BASE_URL}/uploads/${user.avatar}` : null + } + + call.participants.set(userId, participant) + socket.join(`channel-call:${channelPublicId}`) + + // Notify others in the channel + socket.to(`channel-call:${channelPublicId}`).emit("channel:user-joined", { + participant + }) + + // Send list of current participants to the joiner + const currentParticipants = Array.from(call.participants.values()).filter(p => p.userId !== userId) + socket.emit("channel:current-participants", { + participants: currentParticipants + }) + + console.log(`User ${userId} joined channel call ${channelPublicId}`) + } catch (err) { + console.error("CHANNEL JOIN CALL ERROR:", err) + } + }) + + socket.on("channel:leave-call", ({ channelPublicId }: { channelPublicId: string }) => { + leaveChannelCall(channelPublicId) + }) + + socket.on("channel:signal", ({ toSocketId, signal }: { toSocketId: string, signal: any }) => { + // Relay signal to specific peer + io.to(toSocketId).emit("channel:signal", { + fromSocketId: socket.id, + fromUserId: userId, + signal + }) + }) + + socket.on("disconnect", () => { + // Clean up all channel calls this user was in + for (const [channelPublicId, call] of activeChannelCalls.entries()) { + if (call.participants.has(userId)) { + leaveChannelCall(channelPublicId) + } + } + }) + + function leaveChannelCall(channelPublicId: string) { + const call = activeChannelCalls.get(channelPublicId) + if (!call) return + + if (call.participants.has(userId)) { + call.participants.delete(userId) + socket.leave(`channel-call:${channelPublicId}`) + + io.to(`channel-call:${channelPublicId}`).emit("channel:user-left", { + userId + }) + + if (call.participants.size === 0) { + activeChannelCalls.delete(channelPublicId) + } + + console.log(`User ${userId} left channel call ${channelPublicId}`) + } + } +} diff --git a/apps/backend/src/socket/privateChat.ts b/apps/backend/src/socket/privateChat.ts index e5879da..00c8906 100644 --- a/apps/backend/src/socket/privateChat.ts +++ b/apps/backend/src/socket/privateChat.ts @@ -20,18 +20,24 @@ export function privateChatHandler(io: Server, socket: Socket) { const userId = socket.data.userId if (!userId) return - socket.on("join-room", async ({ chatPublicId }: { chatPublicId: string }) => { + socket.on("join-room", async ({ chatPublicId, channelPublicId }: { chatPublicId: string; channelPublicId?: string }) => { if (!chatPublicId) return const allowed = await isUserInChat(userId, chatPublicId) if (!allowed) return socket.join(`chat:${chatPublicId}`) + if (channelPublicId) { + socket.join(`channel:${channelPublicId}`) + } }) - socket.on("leave-room", ({ chatPublicId }: { chatPublicId: string }) => { + socket.on("leave-room", ({ chatPublicId, channelPublicId }: { chatPublicId: string; channelPublicId?: string }) => { if (!chatPublicId) return socket.leave(`chat:${chatPublicId}`) + if (channelPublicId) { + socket.leave(`channel:${channelPublicId}`) + } }) socket.on( @@ -39,11 +45,13 @@ export function privateChatHandler(io: Server, socket: Socket) { async ( { chatPublicId, + channelPublicId, text, fileUrl, fileType, }: { chatPublicId: string + channelPublicId?: string | null text?: string | null fileUrl?: string | null fileType?: string | null @@ -62,6 +70,15 @@ export function privateChatHandler(io: Server, socket: Socket) { if (!chat) return + let channelId: number | undefined + if (channelPublicId) { + const channel = await prisma.channel.findUnique({ + where: { publicId: channelPublicId }, + select: { id: true } + }) + channelId = channel?.id + } + const saved = await prisma.message.create({ data: { chatId: chat.id, @@ -69,10 +86,11 @@ export function privateChatHandler(io: Server, socket: Socket) { text: text?.trim() || null, fileUrl: fileUrl || null, fileType: fileType || null, + channelId: channelId, }, }) - const room = `chat:${chatPublicId}` + const room = channelPublicId ? `channel:${channelPublicId}` : `chat:${chatPublicId}` const messagePayload = { id: saved.id, @@ -81,64 +99,42 @@ export function privateChatHandler(io: Server, socket: Socket) { fileType: saved.fileType, senderId: userId, chatPublicId, + channelPublicId, createdAt: saved.createdAt, } - socket.to(room).emit("private-message", messagePayload) + + // If it's a channel message, we still might want to notify the chat room for unread counts + if (channelPublicId) { + socket.to(`channel:${channelPublicId}`).emit("private-message", messagePayload) + socket.to(`chat:${chatPublicId}`).emit("chat-notification", { + chatPublicId, + channelPublicId, + senderId: userId, + }) + } else { + socket.to(`chat:${chatPublicId}`).emit("private-message", messagePayload) + socket.to(`chat:${chatPublicId}`).emit("chat-notification", { + chatPublicId, + senderId: userId, + }) + } if (callback) { callback(messagePayload) } - - socket.to(room).emit("chat-notification", { - chatPublicId, - senderId: userId, - }) } ) - socket.on("call:offer", async ({ chatPublicId, offer }) => { - const allowed = await isUserInChat(userId, chatPublicId) - if (!allowed) return - - socket.to(`chat:${chatPublicId}`).emit("call:offer", { - chatPublicId, - from: userId, - offer, - }) - }) - - socket.on("call:answer", async ({ chatPublicId, answer }) => { - const allowed = await isUserInChat(userId, chatPublicId) - if (!allowed) return - - socket.to(`chat:${chatPublicId}`).emit("call:answer", { - chatPublicId, - from: userId, - answer, - }) - }) - - socket.on("call:ice", async ({ chatPublicId, candidate }) => { + socket.on("chat:typing", async ({ chatPublicId, isTyping }: { chatPublicId: string, isTyping: boolean }) => { const allowed = await isUserInChat(userId, chatPublicId) if (!allowed) return - socket.to(`chat:${chatPublicId}`).emit("call:ice", { + socket.to(`chat:${chatPublicId}`).emit("chat:typing", { chatPublicId, - from: userId, - candidate, + userId, + isTyping }) }) - - socket.on("call:end", async ({ chatPublicId }) => { - const allowed = await isUserInChat(userId, chatPublicId) - if (!allowed) return - - socket.to(`chat:${chatPublicId}`).emit("call:end", { - chatPublicId, - from: userId, - }) - }) - } diff --git a/apps/backend/uploads/1773520299704.jpg b/apps/backend/uploads/1773520299704.jpg new file mode 100644 index 0000000..d24b685 Binary files /dev/null and b/apps/backend/uploads/1773520299704.jpg differ diff --git a/apps/backend/uploads/4d3e61ee-c14d-4927-afe4-1af2f7be2e3f.jpg b/apps/backend/uploads/4d3e61ee-c14d-4927-afe4-1af2f7be2e3f.jpg new file mode 100644 index 0000000..d24b685 Binary files /dev/null and b/apps/backend/uploads/4d3e61ee-c14d-4927-afe4-1af2f7be2e3f.jpg differ diff --git a/apps/frontend/next-env.d.ts b/apps/frontend/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/frontend/next-env.d.ts +++ b/apps/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 56e5474..f77c714 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint ." }, "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -31,9 +31,14 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.46.3", + "@typescript-eslint/parser": "^8.46.3", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "eslint-config-next": "^16.0.3", + "globals": "^16.5.0", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.22", - "eslint-config-next": "^16.0.3", "path": "^0.12.7", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", diff --git a/apps/frontend/src/app/(landing)/Hero.tsx b/apps/frontend/src/app/(landing)/Hero.tsx index d7e0b3d..d3b885f 100644 --- a/apps/frontend/src/app/(landing)/Hero.tsx +++ b/apps/frontend/src/app/(landing)/Hero.tsx @@ -5,6 +5,7 @@ import { Info } from 'lucide-react' import Link from 'next/link' import { Button } from 'packages/ui' import { useEffect, useState } from 'react' +import { useUserStore } from '@/app/stores/user-store' /* TEXT TYPING */ const words = ['Powerful', 'Simple', 'Private'] @@ -190,8 +191,10 @@ bg-gradient-to-r from-cyan-500/30 to-transparent Channels, groups, and private chats — all in one app. Open source, no ads, no phone number needed

- diff --git a/apps/frontend/src/app/auth/page.tsx b/apps/frontend/src/app/auth/page.tsx index 1893b3a..9a7cad6 100644 --- a/apps/frontend/src/app/auth/page.tsx +++ b/apps/frontend/src/app/auth/page.tsx @@ -27,6 +27,7 @@ export default function AuthPage() { const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [activeTab, setActiveTab] = useState<'login' | 'signup'>('login'); + const [isPending, setIsPending] = useState(false); const [message, setMessage] = useState({ type: '', text: '' }); const [loginData, setLoginData] = useState({ email: '', password: '' }); const [signupData, setSignupData] = useState({ @@ -39,7 +40,7 @@ export default function AuthPage() { // Redirect if logged in useEffect(() => { - api(`/auth/me`, { + api(`/auth/me?t=${Date.now()}`, { credentials: "include", }) .then((res) => { @@ -48,10 +49,12 @@ export default function AuthPage() { } }) .catch(() => { }); - }, []); + }, [router]); const handleLogin = async (e?: any) => { if (e?.preventDefault) e.preventDefault(); + if (isPending) return; + setMessage({ type: "", text: "" }); if (!loginData.email || !loginData.password) { @@ -60,6 +63,7 @@ export default function AuthPage() { } try { + setIsPending(true); const res = await api(`/auth/login`, { method: "POST", credentials: "include", @@ -72,51 +76,27 @@ export default function AuthPage() { if (!res.ok) { setMessage({ type: "error", - text: data.message || "Login failed", + text: data.message || "Invalid email or password", }); return; } - setMessage({ type: "success", text: "Login successful!" }); - - router.replace("/zone"); - router.refresh(); - } catch (err) { - console.error(err); - setMessage({ type: "error", text: "Something went wrong" }); - } - }; - - const handleGoogleLogin = async (tokenResponse: any) => { - try { - - const res = await api(`/auth/google`, { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - token: tokenResponse.access_token - }), - }); - - if (!res.ok) { - setMessage({ type: "error", text: "Google login failed" }); - return; - } + setMessage({ type: "success", text: "Login successful! Redirecting..." }); router.replace("/zone"); router.refresh(); - } catch (err) { console.error(err); - setMessage({ type: "error", text: "Something went wrong" }); + setMessage({ type: "error", text: "Connection error. Please try again." }); + } finally { + setIsPending(false); } }; - const handleSignup = async (e?: React.KeyboardEvent) => { + const handleSignup = async (e?: any) => { if (e?.preventDefault) e.preventDefault(); + if (isPending) return; + setMessage({ type: "", text: "" }); const result = signupSchema.safeParse(signupData); @@ -133,6 +113,7 @@ export default function AuthPage() { } try { + setIsPending(true); const res = await api(`/auth/register`, { method: "POST", credentials: "include", @@ -158,7 +139,9 @@ export default function AuthPage() { router.replace("/zone"); router.refresh(); } catch (err) { - setMessage({ type: "error", text: "Something went wrong" }); + setMessage({ type: "error", text: "Connection error. Please try again." }); + } finally { + setIsPending(false); } }; @@ -166,6 +149,7 @@ export default function AuthPage() { flow: "auth-code", onSuccess: async (codeResponse) => { try { + setIsPending(true); const res = await api(`/auth/google`, { method: "POST", credentials: "include", @@ -187,9 +171,14 @@ export default function AuthPage() { } catch (err) { console.error(err); setMessage({ type: "error", text: "Something went wrong" }); + } finally { + setIsPending(false); } }, - onError: () => setMessage({ type: "error", text: "Google login failed" }), + onError: () => { + setMessage({ type: "error", text: "Google login failed" }); + setIsPending(false); + } }); return ( @@ -264,8 +253,8 @@ export default function AuthPage() { -
@@ -383,7 +372,9 @@ export default function AuthPage() {
- + diff --git a/apps/frontend/src/app/dashboard/layout.tsx b/apps/frontend/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..9aaa9d1 --- /dev/null +++ b/apps/frontend/src/app/dashboard/layout.tsx @@ -0,0 +1,20 @@ +import { getCurrentUser } from '@/lib/getCurrentUser' +import { redirect } from 'next/navigation' + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const user = await getCurrentUser() + + if (!user) { + redirect('/auth') + } + + return ( +
+ {children} +
+ ) +} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..e1800f4 --- /dev/null +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,315 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { motion } from 'framer-motion' +import { + Users, + MessageSquare, + Settings, + LayoutDashboard, + Bell, + PlusCircle, + ArrowRight, + Monitor, + ShieldCheck, + Zap +} from 'lucide-react' +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, + Button, + Avatar, + AvatarFallback, + ScrollArea +} from 'packages/ui' +import { useUserStore } from '@/app/stores/user-store' +import { useFriendsStore } from '@/app/stores/friends-store' +import { useChatsStore } from '@/app/stores/chat-store' +import { api, getAvatarUrl } from '@openchat/lib' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +export default function DashboardPage() { + const { user } = useUserStore() + const { friends, setFriends, onlineUsers } = useFriendsStore() + const { chats, setChats } = useChatsStore() + const router = useRouter() + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchData = async () => { + try { + const [friendsRes, chatsRes] = await Promise.all([ + api('/friends/list'), + api('/chats') + ]) + + const friendsData = await friendsRes.json() + const chatsData = await chatsRes.json() + + setFriends(friendsData.friends || []) + setChats(chatsData.chats || []) + } catch (error) { + console.error('Error fetching dashboard data:', error) + } finally { + setLoading(false) + } + } + + fetchData() + }, [setFriends, setChats]) + + if (!user) return null + + const stats = [ + { + label: 'Connected Friends', + value: friends.length, + icon: Users, + color: 'text-blue-400', + bg: 'bg-blue-400/10' + }, + { + label: 'Active Chats', + value: chats.length, + icon: MessageSquare, + color: 'text-purple-400', + bg: 'bg-purple-400/10' + }, + { + label: 'Online Now', + value: onlineUsers.size, + icon: Zap, + color: 'text-green-400', + bg: 'bg-green-400/10' + }, + { + label: 'Security Status', + value: user.emailVerified ? 'Verified' : 'Pending', + icon: ShieldCheck, + color: user.emailVerified ? 'text-emerald-400' : 'text-yellow-400', + bg: user.emailVerified ? 'bg-emerald-400/10' : 'bg-yellow-400/10' + }, + ] + + return ( +
+ {/* Header section */} +
+ +

+ Welcome back, {user.name || user.username} +

+

+ Here's what's happening with your OpenChat account today. +

+
+ +
+ + +
+
+ + {/* Stats Grid */} +
+ {stats.map((stat, i) => ( + + + +
+
+ +
+ {stat.value} +
+

+ {stat.label} +

+
+
+
+ ))} +
+ +
+ {/* Recent Chats */} + + +
+ Recent Conversations + Your latest discussions and group chats. +
+ + View all + +
+ + {chats.length > 0 ? ( +
+ {chats.slice(0, 5).map((chat) => { + const other = chat.participants.find(p => p.id !== user.id) + const avatarUrl = getAvatarUrl(other?.avatar) + + return ( +
router.push(`/zone/chat/${chat.chatPublicId}`)} + > +
+ + {avatarUrl ? ( + + ) : ( + {other?.username?.[0]?.toUpperCase()} + )} + +
+

@{other?.username || 'Group Chat'}

+

+ {chat.lastMessage?.text || 'No messages yet'} +

+
+
+
+

+ {chat.lastMessage?.createdAt ? new Date(chat.lastMessage.createdAt).toLocaleDateString() : ''} +

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

No active conversations yet.

+ +
+ )} +
+
+ + {/* Online Friends / Notifications */} +
+ + + Online Friends +
+ + + + {friends.filter(f => onlineUsers.has(f.id)).length > 0 ? ( +
+ {friends.filter(f => onlineUsers.has(f.id)).map(friend => { + const avatarUrl = getAvatarUrl(friend.avatar) + return ( +
+
+ + {avatarUrl ? ( + + ) : ( + {friend.username?.[0]?.toUpperCase()} + )} + + @{friend.username} +
+ +
+ ) + })} +
+ ) : ( +

No friends online right now.

+ )} +
+
+ + + {/* Pending Requests */} + + + + + Notifications + + + +
+ {/* Simplified view since we'd need more logic for actual notifictions */} +
+

Welcome to OpenChat!

+

Start by inviting your friends to join your private zones.

+
+ + +
+
+
+ + +
+ +
+ + + Quick Actions + + + + + + +
+
+
+
+ ) +} diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index 5de3702..714cf7f 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,18 +1,19 @@ +"use client" + import Hero from "./(landing)/Hero" import Features from "./(landing)/Features" import HowItWorks from "./(landing)/HowItWorks" import Navbar from "packages/ui/ui/Navbar" -// import CTA from "./(landing)/CTA" import Footer from "./(landing)/Footer" -// import Stats from "./(landing)/stats" import FAQ from "./(landing)/faq" -// import Contact from "./(landing)/contact" -// import PhoneDemo from "./(landing)/PhoneDemo" +import { useUserStore } from "./stores/user-store" export default function Home() { + const user = useUserStore(s => s.user) + return ( <> - + @@ -20,5 +21,4 @@ export default function Home() {
) - // } diff --git a/apps/frontend/src/app/providers/ClientProviders.tsx b/apps/frontend/src/app/providers/ClientProviders.tsx index 1ecbd56..745caaf 100644 --- a/apps/frontend/src/app/providers/ClientProviders.tsx +++ b/apps/frontend/src/app/providers/ClientProviders.tsx @@ -10,6 +10,7 @@ import { RealtimeProvider } from './realtime-provider' import { NotificationsProvider } from './notifications-provider' // import { GlobalCallSystem } from '../zone/_components/global/call-system' import { GoogleOAuthProvider } from "@react-oauth/google" +import ChannelCallManager from '../zone/_components/ChannelCallManager' export default function ClientProviders({ children, @@ -35,6 +36,7 @@ export default function ClientProviders({ {/**/} {children} + diff --git a/apps/frontend/src/app/providers/global-call-provider.tsx b/apps/frontend/src/app/providers/global-call-provider.tsx index a869f72..04f3a9e 100644 --- a/apps/frontend/src/app/providers/global-call-provider.tsx +++ b/apps/frontend/src/app/providers/global-call-provider.tsx @@ -4,12 +4,63 @@ import { useEffect, useRef } from "react" import { socket } from "@openchat/lib" import { useCallStore } from "@/app/stores/call-store" import CallOverlay from "@/app/zone/_components/global/CallOverlay" +import { useVoiceCall } from "@/hooks/useVoiceCall" export default function GlobalCallProvider() { - const { status, setIncoming, setConnected, clear } = useCallStore() + const { status, user, chatPublicId, isCaller, setIncoming, setConnected, clear, setCalling } = useCallStore() + const { + startCall, + acceptCall, + endCall, + remoteAudioRef, + toggleMute + } = useVoiceCall() - const callingAudioRef = useRef(null) - const incomingAudioRef = useRef(null) + /* ========================= + INITIAL CHECK (Persistence) + ========================== */ + useEffect(() => { + const handleRecheck = () => { + if (socket.connected) { + socket.emit("call:check") + } + } + + const timer = setTimeout(handleRecheck, 1000) + + const statusHandler = (payload: any) => { + console.log("[GlobalCallProvider] call:status received:", payload) + if (payload.status === "idle") { + clear() + return + } + + // Restore state based on server response + if (payload.status === "calling") { + setCalling(payload.chatPublicId, payload.user) + } else if (payload.status === "incoming") { + setIncoming(payload.chatPublicId, payload.user) + } else if (payload.status === "connected") { + // Re-connect logic: Set state to connected and caller flag + // Ensure chatPublicId is set so startCall/acceptCall can trigger + useCallStore.setState({ + status: "connected", + chatPublicId: payload.chatPublicId, + user: payload.user, + isCaller: payload.isCaller + }) + } + } + + socket.on("call:status", statusHandler) + socket.on("connect", handleRecheck) + + return () => { + clearTimeout(timer) + socket.off("call:status", statusHandler) + socket.off("connect", handleRecheck) + } + }, [clear, setCalling, setIncoming]) /* ========================= SOCKET LISTENERS @@ -17,48 +68,67 @@ export default function GlobalCallProvider() { useEffect(() => { const incomingHandler = (payload: any) => { - console.log("INCOMING PAYLOAD:", payload) - const { chatPublicId, user } = payload + socket.emit("join-room", { chatPublicId }) setIncoming(chatPublicId, user) } - const acceptedHandler = () => { - setConnected() - } - - const rejectedHandler = () => { - clear() - } - - const endedHandler = () => { - clear() + const rejoinedHandler = ({ userId: joinedUserId }: { userId: number }) => { + console.log("[GlobalCallProvider] Partner rejoined:", joinedUserId) + const state = useCallStore.getState() + // If we are already connected and we are the caller, re-send the offer + if (state.status === "connected" && state.isCaller && state.chatPublicId) { + startCall(state.chatPublicId) + } } socket.on("incoming:call", incomingHandler) - socket.on("call:accepted", acceptedHandler) - socket.on("call:rejected", rejectedHandler) - socket.on("call:ended", endedHandler) + socket.on("call:accepted", setConnected) + socket.on("call:rejected", clear) + socket.on("call:ended", clear) + socket.on("call:rejoined", rejoinedHandler) + + socket.on("call:partner-disconnected", ({ userId }: any) => { + console.log(`Partner ${userId} disconnected. Waiting for them...`) + }) return () => { socket.off("incoming:call", incomingHandler) - socket.off("call:accepted", acceptedHandler) - socket.off("call:rejected", rejectedHandler) - socket.off("call:ended", endedHandler) + socket.off("call:accepted", setConnected) + socket.off("call:rejected", clear) + socket.off("call:ended", clear) + socket.off("call:rejoined", rejoinedHandler) + socket.off("call:partner-disconnected") } - }, [setIncoming, setConnected, clear]) + }, [setIncoming, setConnected, clear, startCall]) /* ========================= - RING SOUNDS + VOICE CALL HANDSHAKE ========================== */ + useEffect(() => { + if (status === "connected" && chatPublicId && isCaller) { + startCall(chatPublicId) + } + + if (status === "connecting" && chatPublicId && !isCaller) { + acceptCall() + } + }, [status, chatPublicId, isCaller, startCall, acceptCall]) + + /* ========================= + INTERNAL APP LOGIC (RINGTONES) + ========================== */ + + const callingAudioRef = useRef(null) + const incomingAudioRef = useRef(null) + useEffect(() => { const callingAudio = callingAudioRef.current const incomingAudio = incomingAudioRef.current if (!callingAudio || !incomingAudio) return - // Outgoing call sound if (status === "calling") { callingAudio.loop = true callingAudio.currentTime = 0 @@ -68,7 +138,6 @@ export default function GlobalCallProvider() { callingAudio.currentTime = 0 } - // Incoming call sound if (status === "incoming") { incomingAudio.loop = true incomingAudio.currentTime = 0 @@ -79,31 +148,12 @@ export default function GlobalCallProvider() { } }, [status]) - - useEffect(() => { - function handleUnload() { - const { status, user, chatPublicId } = - useCallStore.getState() - - if (status !== "idle" && user?.id && chatPublicId) { - socket.emit("call:end", { - toUserId: user.id, - chatPublicId, - }) - } - } - - window.addEventListener("beforeunload", handleUnload) - - return () => { - window.removeEventListener("beforeunload", handleUnload) - } - }, []) - if (status === "idle") return null return ( <> +
) diff --git a/apps/frontend/src/app/zone/_components/ZoneSidebar.tsx b/apps/frontend/src/app/zone/_components/ZoneSidebar.tsx index f4bece7..f624492 100644 --- a/apps/frontend/src/app/zone/_components/ZoneSidebar.tsx +++ b/apps/frontend/src/app/zone/_components/ZoneSidebar.tsx @@ -1,11 +1,15 @@ -'use client' +"use client" -import { Users } from 'lucide-react' -import { usePathname, useRouter } from 'next/navigation' -import { cn } from '@openchat/lib' +import { useEffect, useState } from 'react' +import { useParams, usePathname, useRouter } from 'next/navigation' +import { Hash, Volume2, Plus, Users, PhoneOff } from 'lucide-react' +import { Button, ScrollArea, Skeleton } from 'packages/ui' +import { cn, api } from '@openchat/lib' +import { useChatsStore } from '@/app/stores/chat-store' import ChatList from '../chat/ChatList' import UserBar from './UserBar' -import { Button } from 'packages/ui' +import { CreateChannelModal } from './zones/CreateChannelModal' +import { useCallStore } from '@/app/stores/call-store' export default function ZoneSidebar({ @@ -15,35 +19,189 @@ export default function ZoneSidebar({ }) { const pathname = usePathname() const router = useRouter() + const params = useParams<{ zonePublicId?: string; channelPublicId?: string }>() - const isHome = pathname === '/zone' + const zonePublicId = params?.zonePublicId + const [channels, setChannels] = useState([]) + const [zone, setZone] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [modalType, setModalType] = useState<'TEXT' | 'VOICE'>('TEXT') + const setChannelCall = useCallStore(s => s.setChannelCall) + const activeChannelPublicId = useCallStore(s => s.channelPublicId) + + const isHome = pathname.startsWith('/zone') && !zonePublicId + + useEffect(() => { + if (zonePublicId) { + // Load zone details and channels + api(`/zones`).then(res => res.json()).then(data => { + const current = data.zones?.find((z: any) => z.publicId === zonePublicId) + setZone(current) + }) + + fetchChannels() + } + }, [zonePublicId]) + + const fetchChannels = () => { + api(`/zones/${zonePublicId}/channels`).then(res => res.json()).then(data => { + setChannels(data.channels ?? []) + }) + } + + const handleCreateChannel = async (name: string, type: 'TEXT' | 'VOICE') => { + try { + const res = await api(`/zones/${zonePublicId}/channels`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type }) + }) + if (res.ok) { + fetchChannels() + } + } catch (err) { + console.error("Failed to create channel", err) + } + } return ( -
- - {/* Home */} -
- +
+ {/* Header */} +
+

+ {zonePublicId ? (zone?.name || ) : 'Direct Messages'} +

- {/* Chats */} -
- +
+ {isHome ? ( + <> +
+ +
+ + +
+ +
+ + ) : ( + +
+ {/* Text Channels */} +
+
+

+ Text Channels +

+ { setModalType('TEXT'); setIsModalOpen(true); }} + /> +
+
+ {channels.filter(c => c.type === 'TEXT').map(channel => ( + + ))} +
+
+ + {/* Voice Channels */} +
+
+

+ Voice Channels +

+ { setModalType('VOICE'); setIsModalOpen(true); }} + /> +
+
+ {channels.filter(c => c.type === 'VOICE').map(channel => ( +
+ + + {/* Active Participants Placeholder (Logic for real-time list can be added) */} + {activeChannelPublicId === channel.publicId && ( +
+ You are connected +
+ )} +
+ ))} +
+
+
+
+ )}
- {/* User */} + {/* Voice Status Bar */} + {activeChannelPublicId && ( +
+
+ Voice Connected + + {channels.find(c => c.publicId === activeChannelPublicId)?.name || 'Channel'} + +
+ +
+ )} + + {/* User Bar */} + + setIsModalOpen(false)} + onCreate={handleCreateChannel} + initialType={modalType} + />
) } diff --git a/apps/frontend/src/app/zone/_components/global/CallOverlay.tsx b/apps/frontend/src/app/zone/_components/global/CallOverlay.tsx index 9d51a39..34a7b07 100644 --- a/apps/frontend/src/app/zone/_components/global/CallOverlay.tsx +++ b/apps/frontend/src/app/zone/_components/global/CallOverlay.tsx @@ -6,7 +6,7 @@ import { AvatarFallback, Button } from "packages/ui" -import { Phone, PhoneOff } from "lucide-react" +import { Phone, PhoneOff, Mic, MicOff } from "lucide-react" import { useCallStore } from "@/app/stores/call-store" import { useEffect, useState } from "react" @@ -14,14 +14,16 @@ interface Props { onAccept: () => void onReject: () => void onEnd: () => void + toggleMute?: (muted: boolean) => void } export default function CallOverlay({ onAccept, onReject, onEnd, + toggleMute, }: Props) { - const { status, user } = useCallStore() + const { status, user, isMuted, toggleMuted } = useCallStore() const [seconds, setSeconds] = useState(0) useEffect(() => { @@ -45,86 +47,95 @@ export default function CallOverlay({ return `${m}:${s.toString().padStart(2, "0")}` } - return ( -
- -
+ const handleToggleMute = () => { + const nextMuted = !isMuted + toggleMuted() + if (toggleMute) toggleMute(nextMuted) + } - {/* Avatar */} -
- - - + return ( +
+
+ +
+ + + {user.name?.[0].toUpperCase()} -
- - {/* Name + Status */} -
-

{user.name}

- - {status === "calling" && ( -

Calling...

- )} - - {status === "incoming" && ( -

Incoming Call

- )} - - {status === "connected" && ( -

- {formatTime()} + +

+

{user.name}

+

+ {status === "calling" && "Calling..."} + {status === "incoming" && "Incoming call..."} + {status === "connecting" && "Connecting..."} + {status === "connected" && ( + + + {formatTime()} + + )}

- )} +
- {/* Actions */} -
- +
{status === "incoming" && ( <> - )} - {status === "calling" && ( + {(status === "calling" || status === "connecting") && ( )} {status === "connected" && ( - )} +
+ + +
+ )}
diff --git a/apps/frontend/src/app/zone/_components/zones/CreateChannelModal.tsx b/apps/frontend/src/app/zone/_components/zones/CreateChannelModal.tsx new file mode 100644 index 0000000..3fa2c6f --- /dev/null +++ b/apps/frontend/src/app/zone/_components/zones/CreateChannelModal.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useState } from 'react' +import { Button, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from 'packages/ui' +import { Hash, Volume2 } from 'lucide-react' +import { cn, api } from '@openchat/lib' + +export function CreateChannelModal({ + open, + onClose, + onCreate, + initialType = 'TEXT' +}: { + open: boolean; + onClose: () => void; + onCreate: (name: string, type: 'TEXT' | 'VOICE') => void; + initialType?: 'TEXT' | 'VOICE'; +}) { + const [name, setName] = useState('') + const [type, setType] = useState<'TEXT' | 'VOICE'>(initialType) + const [loading, setLoading] = useState(false) + + const handleCreate = async () => { + if (!name.trim()) return + setLoading(true) + await onCreate(name, type) + setLoading(false) + setName('') + onClose() + } + + return ( + + + + Create Channel + + +
+
+ +
+ + +
+
+ +
+ +
+ + setName(e.target.value)} + placeholder="new-channel" + className="bg-muted/50 border-none pl-9 focus-visible:ring-1 focus-visible:ring-primary h-10" + /> +
+
+
+ + + + + +
+
+ ) +} diff --git a/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx b/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx index a8fc4e5..6140540 100644 --- a/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx +++ b/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx @@ -1,7 +1,7 @@ "use client" import { Plus, Home } from "lucide-react" -import { Button, Input } from "packages/ui" +import { Button, Input, Skeleton } from "packages/ui" import { useRouter, useParams } from "next/navigation" import { useEffect, useState } from "react" import { api } from "@openchat/lib" @@ -19,15 +19,19 @@ export default function ZonesList() { const [zones, setZones] = useState([]) const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(true) useEffect(() => { const loadZones = async () => { try { + setLoading(true) const res = await api("/zones") const data = await res.json() setZones(data.zones ?? []) } catch (err) { console.error("Failed to fetch zones", err) + } finally { + setLoading(false) } } @@ -67,12 +71,12 @@ export default function ZonesList() { router.push(`/zone/zones/${zone.publicId}`) } return ( -
+
@@ -89,46 +93,53 @@ export default function ZonesList() {
{/* Zones */} - - {zones.map(zone => { - const active = zonePublicId === zone.publicId - - return ( - - ) - })} + + ) + }) + )}
diff --git a/apps/frontend/src/app/zone/chat/ChatList.tsx b/apps/frontend/src/app/zone/chat/ChatList.tsx index c0ff66b..b55f483 100644 --- a/apps/frontend/src/app/zone/chat/ChatList.tsx +++ b/apps/frontend/src/app/zone/chat/ChatList.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' -import { Avatar, AvatarFallback, ScrollArea } from 'packages/ui' +import { Avatar, AvatarFallback, ScrollArea, Skeleton } from 'packages/ui' import { User, X } from 'lucide-react' import { cn, getAvatarUrl, api } from '@openchat/lib' import { useChatsStore } from '@/app/stores/chat-store' @@ -62,8 +62,16 @@ export default function ChatList() { if (loading) { return ( -
- Loading chats... +
+ {[...Array(5)].map((_, i) => ( +
+ +
+ + +
+
+ ))}
) } diff --git a/apps/frontend/src/app/zone/chat/[chatPublicId]/page.tsx b/apps/frontend/src/app/zone/chat/[chatPublicId]/page.tsx index aa3cc05..42ac5ab 100644 --- a/apps/frontend/src/app/zone/chat/[chatPublicId]/page.tsx +++ b/apps/frontend/src/app/zone/chat/[chatPublicId]/page.tsx @@ -12,6 +12,7 @@ import { SheetContent, SheetTrigger, SheetTitle, + Skeleton, } from 'packages/ui' import { useChatsStore } from '@/app/stores/chat-store' import { Info, Loader2, Paperclip, PhoneCall, Send, User, Video, X } from 'lucide-react' @@ -44,6 +45,8 @@ export default function ChatPage() { const [selectedFile, setSelectedFile] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) const fileInputRef = useRef(null) + const [typingUsers, setTypingUsers] = useState>(new Set()) + const typingTimeoutRef = useRef(null) const setCalling = useCallStore((s) => s.setCalling) // const status = useCallStore((s) => s.status) @@ -139,6 +142,42 @@ export default function ChatPage() { } }, [chatPublicId]) + useEffect(() => { + if (!chatPublicId) return + + const handler = ({ userId, isTyping }: { userId: number; isTyping: boolean }) => { + setTypingUsers((prev) => { + const next = new Set(prev) + if (isTyping) next.add(userId) + else next.delete(userId) + return next + }) + } + + socket.on("chat:typing", handler) + return () => { + socket.off("chat:typing", handler) + } + }, [chatPublicId]) + + const handleInputChange = (val: string) => { + setInput(val) + + if (!chatPublicId) return + + // Emit typing status + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current) + } else { + socket.emit("chat:typing", { chatPublicId, isTyping: true }) + } + + typingTimeoutRef.current = setTimeout(() => { + socket.emit("chat:typing", { chatPublicId, isTyping: false }) + typingTimeoutRef.current = null + }, 3000) + } + const handleFileSelect = ( e: React.ChangeEvent ) => { @@ -296,18 +335,21 @@ export default function ChatPage() { if (loading || !activeChat) { return ( -
- +
+
+ + +
+
+ {[...Array(8)].map((_, i) => ( +
+ +
+ ))} +
+
+ +
) } @@ -403,8 +445,9 @@ export default function ChatPage() { @@ -631,10 +675,19 @@ export default function ChatPage() {
)} -
+
+ {typingUsers.size > 0 && Array.from(typingUsers).map(uid => { + const user = activeChat?.participants.find((p: any) => p.id === uid) + if (!user) return null + return ( +
+ {user.username} is typing... +
+ ) + })} setInput(e.target.value)} + onChange={(e) => handleInputChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() diff --git a/apps/frontend/src/app/zone/friends/FriendList.tsx b/apps/frontend/src/app/zone/friends/FriendList.tsx index 7c5908d..ea40773 100644 --- a/apps/frontend/src/app/zone/friends/FriendList.tsx +++ b/apps/frontend/src/app/zone/friends/FriendList.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useParams } from 'next/navigation' -import { Avatar, AvatarFallback, ScrollArea } from 'packages/ui' +import { Avatar, AvatarFallback, ScrollArea, Skeleton } from 'packages/ui' import { User, Users } from 'lucide-react' import { cn, getAvatarUrl } from '@openchat/lib' import { api } from '@openchat/lib' @@ -24,6 +24,7 @@ export default function FriendList({ onSelectFriend }: FriendListProps) { const friends = useFriendsStore((s) => s.friends) const setFriends = useFriendsStore((s) => s.setFriends) + const onlineUsers = useFriendsStore((s) => s.onlineUsers) const [loading, setLoading] = useState(true) @@ -96,13 +97,16 @@ export default function FriendList({ onSelectFriend }: FriendListProps) { {/* Loading */} {loading && ( -
- Loading friends... +
+ {[...Array(5)].map((_, i) => ( +
+ +
+ + +
+
+ ))}
)} @@ -141,20 +145,25 @@ export default function FriendList({ onSelectFriend }: FriendListProps) { isActive ? 'bg-muted' : 'hover:bg-muted/50' )} > - - {avatarUrl ? ( - {friend.username} - ) : ( - - {friend.username?.[0]?.toUpperCase()} - +
+ + {avatarUrl ? ( + {friend.username} + ) : ( + + {friend.username?.[0]?.toUpperCase()} + + )} + + {onlineUsers.has(friend.id) && ( +
)} - +

diff --git a/apps/frontend/src/app/zone/layout.tsx b/apps/frontend/src/app/zone/layout.tsx index 891681c..814ff1c 100644 --- a/apps/frontend/src/app/zone/layout.tsx +++ b/apps/frontend/src/app/zone/layout.tsx @@ -5,6 +5,7 @@ import ZoneSidebar from './_components/ZoneSidebar' import { RealtimeProvider } from '../providers/realtime-provider' import { redirect } from 'next/navigation' import ZonesList from './_components/zones/ZonesList' +import { MobileNav } from './_components/MobileNav' export default async function ZoneLayout({ children, @@ -16,21 +17,19 @@ export default async function ZoneLayout({ if (!user) { redirect('/auth') } - return ( - -

+
+
-
- -
+ +
-
-
- {children} -
-
+
+ +
+ {children} +
- +
) } diff --git a/apps/frontend/src/app/zone/page.tsx b/apps/frontend/src/app/zone/page.tsx index 08568d7..cb3a5e2 100644 --- a/apps/frontend/src/app/zone/page.tsx +++ b/apps/frontend/src/app/zone/page.tsx @@ -22,7 +22,7 @@ export default function ZoneHome() { useEffect(() => { let mounted = true - api('/auth/me') + api(`/auth/me?t=${Date.now()}`) .then(res => res.json()) .then(data => { if (!mounted) return diff --git a/apps/frontend/src/app/zone/zones/[zonePublicId]/channels/[channelPublicId]/page.tsx b/apps/frontend/src/app/zone/zones/[zonePublicId]/channels/[channelPublicId]/page.tsx new file mode 100644 index 0000000..14b52f0 --- /dev/null +++ b/apps/frontend/src/app/zone/zones/[zonePublicId]/channels/[channelPublicId]/page.tsx @@ -0,0 +1,386 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from "react" +import { useParams } from "next/navigation" +import { Avatar, AvatarFallback, AvatarImage, Button, Input, Skeleton } from "packages/ui" +import { Info, Paperclip, Send, Hash, Plus } from "lucide-react" +import { api, socket, getAvatarUrl, cn } from "@openchat/lib" +import { useChatsStore } from "@/app/stores/chat-store" +import { ChatHeader } from "../../../../_components/zones/ChatHeader" + +type Message = { + id: number + text: string | null + senderId: number + sender?: { + id: number + username: string + avatar?: string | null + } + fileUrl?: string + fileType?: string + isDeleted?: boolean +} + +type Zone = { + publicId: string + name: string + avatar: string | null +} + +type Member = { + id: number + username: string + avatar?: string | null + role: "OWNER" | "ADMIN" | "MEMBER" +} + +type Channel = { + publicId: string + name: string + type: "TEXT" | "VOICE" +} + +export default function ChannelPage() { + const { zonePublicId, channelPublicId } = useParams<{ zonePublicId: string; channelPublicId: string }>() + const [zone, setZone] = useState(null) + const [channel, setChannel] = useState(null) + const [messages, setMessages] = useState([]) + const [members, setMembers] = useState([]) + const [user, setUser] = useState(null) + const [currentUserId, setCurrentUserId] = useState(null) + const [input, setInput] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + + const messagesRef = useRef(null) + const fileInputRef = useRef(null) + const userCache = useRef>(new Map()) + + const setActiveChat = useChatsStore(s => s.setActiveChat) + const setActiveChannel = useChatsStore(s => s.setActiveChannel) + + useEffect(() => { + const loadMe = async () => { + const res = await api("/auth/me", { credentials: "include" }) + const data = await res.json() + setCurrentUserId(data.user.id) + setUser(data.user) + userCache.current.set(data.user.id, data.user) + } + loadMe() + + setActiveChat(zonePublicId) + setActiveChannel(channelPublicId) + + return () => { + setActiveChat(null) + setActiveChannel(null) + } + }, [zonePublicId, channelPublicId, setActiveChat, setActiveChannel]) + + // Load zone, channel, messages, members + useEffect(() => { + if (!zonePublicId || !channelPublicId) return + + const loadData = async () => { + try { + // Zone + const zonesRes = await api("/zones") + const zonesData = await zonesRes.json() + const currentZone = zonesData.zones.find((z: Zone) => z.publicId === zonePublicId) + setZone(currentZone) + + // Channels + const channelsRes = await api(`/zones/${zonePublicId}/channels`) + const channelsData = await channelsRes.json() + const currentChannel = channelsData.channels.find((c: Channel) => c.publicId === channelPublicId) + setChannel(currentChannel) + + // Messages + const msgsRes = await api(`/chats/${zonePublicId}/messages?channelPublicId=${channelPublicId}`) + const msgsData = await msgsRes.json() + const sortedMessages = (msgsData.messages ?? []).sort((a: Message, b: Message) => a.id - b.id) + setMessages(sortedMessages) + + // Members + const membersRes = await api(`/zones/${zonePublicId}/members`) + const membersData = await membersRes.json() + setMembers(membersData.members ?? []) + membersData.members?.forEach((m: Member) => { + userCache.current.set(m.id, m) + }) + + } catch (err) { + console.error("Failed to load channel data", err) + } + } + + loadData() + }, [zonePublicId, channelPublicId]) + + // Join/Leave channel room + useEffect(() => { + if (!zonePublicId || !channelPublicId) return + + socket.emit("join-room", { chatPublicId: zonePublicId, channelPublicId }) + + return () => { + socket.emit("leave-room", { chatPublicId: zonePublicId, channelPublicId }) + } + }, [zonePublicId, channelPublicId]) + + // Listen messages + useEffect(() => { + const handler = async (msg: Message & { chatPublicId: string; channelPublicId?: string }) => { + if (msg.chatPublicId !== zonePublicId || msg.channelPublicId !== channelPublicId) return + + const cached = userCache.current.get(msg.senderId) + const sender = msg.sender || cached || { id: msg.senderId, username: "Loading...", avatar: null } + + setMessages(prev => prev.some(m => m.id === msg.id) ? prev : [...prev, { ...msg, sender }]) + + if (!cached) { + try { + const res = await api(`/users/${msg.senderId}`) + const data = await res.json() + userCache.current.set(msg.senderId, data.user) + setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, sender: data.user } : m)) + } catch (err) { + console.error("Failed to fetch sender", err) + } + } + } + + socket.on("private-message", handler) + return () => { + socket.off("private-message", handler) + } + }, [zonePublicId, channelPublicId]) + + // Auto scroll + useEffect(() => { + if (!messagesRef.current) return + messagesRef.current.scrollTop = messagesRef.current.scrollHeight + }, [messages]) + + // Send message + const send = useCallback(async () => { + if (!zonePublicId || !channelPublicId) return + if (!input.trim() && !selectedFile) return + + const tempId = Date.now() + const tempMsg: Message = { + id: tempId, + text: input || null, + senderId: currentUserId!, + fileUrl: previewUrl ?? undefined, + fileType: selectedFile?.type, + sender: userCache.current.get(currentUserId!) || user + } + + setMessages(prev => [...prev, tempMsg]) + + let fileUrl: string | null = null + let fileType: string | null = null + + if (selectedFile) { + const form = new FormData() + form.append("file", selectedFile) + const res = await api(`/zones/${zonePublicId}/upload`, { + method: "POST", + body: form, + credentials: "include" + }) + const data = await res.json() + fileUrl = data.fileUrl + fileType = selectedFile.type + } + + socket.emit( + "private-message", + { + chatPublicId: zonePublicId, + channelPublicId, + text: input || null, + fileUrl, + fileType + }, + (savedMessage: Message) => { + setMessages(prev => prev.map(m => m.id === tempId ? savedMessage : m)) + } + ) + + setInput("") + setSelectedFile(null) + if (previewUrl) URL.revokeObjectURL(previewUrl) + setPreviewUrl(null) + }, [input, selectedFile, previewUrl, zonePublicId, channelPublicId, currentUserId, user]) + + if (!zone || !channel) { + return ( +
+
+ + +
+
+ {[...Array(6)].map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ +
+
+ ) + } + + return ( +
+ + {/* Header */} +
+
+ +

{channel.name}

+
+
+ +
+
+ + {/* Messages Area */} +
+
+ {/* Welcome Message */} +
+
+ +
+

Welcome to #{channel.name}!

+

This is the start of the #{channel.name} channel.

+
+
+ + {messages.map((m, idx) => { + const isMe = m.senderId === currentUserId + const prevMsg = messages[idx - 1] + const isGrouped = prevMsg && prevMsg.senderId === m.senderId && + (new Date(m.id).getTime() - new Date(prevMsg.id).getTime() < 300000) + + const sender = m.sender || userCache.current.get(m.senderId) || { username: "User", avatar: null } + + if (isGrouped) { + return ( +
+
+ {m.isDeleted ? Message deleted : m.text} +
+ {m.fileUrl && } +
+ ) + } + + return ( +
+ + + {sender.username[0]?.toUpperCase()} + + +
+
+ {sender.username} + + {new Date(m.id).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+
+ {m.isDeleted ? Message deleted : m.text} +
+ {m.fileUrl && } +
+
+ ) + })} +
+
+ + {/* Input Area */} +
+
+ {previewUrl && ( +
+ + +
+ )} + +
+ + + setInput(e.target.value)} + placeholder={`Message #${channel.name}`} + className="bg-transparent border-none focus-visible:ring-0 px-0 h-auto text-sm placeholder:text-zinc-500 text-zinc-200" + onKeyDown={e => { + if(e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }} + /> + + +
+
+ + { + const f = e.target.files?.[0] + if (!f) return + setSelectedFile(f) + setPreviewUrl(URL.createObjectURL(f)) + }} + /> +

+ Press Enter to send. Use Shift+Enter for new line. +

+
+ +
+ ) +} diff --git a/apps/frontend/src/app/zone/zones/[zonePublicId]/page.tsx b/apps/frontend/src/app/zone/zones/[zonePublicId]/page.tsx index c5c2ec5..939d47f 100644 --- a/apps/frontend/src/app/zone/zones/[zonePublicId]/page.tsx +++ b/apps/frontend/src/app/zone/zones/[zonePublicId]/page.tsx @@ -1,304 +1,38 @@ 'use client' -import { useState, useEffect, useRef, useCallback } from "react" -import { useParams } from "next/navigation" -import { Avatar, AvatarFallback, AvatarImage, Button, Input } from "packages/ui" -import { Info, Paperclip, Send } from "lucide-react" -import { api, socket, getAvatarUrl } from "@openchat/lib" -import { ChatHeader } from "../../_components/zones/ChatHeader" - -type Message = { - id: number - text: string | null - senderId: number - sender?: { - id: number - username: string - avatar?: string | null - } - fileUrl?: string - fileType?: string - isDeleted?: boolean -} - -type Zone = { - publicId: string - name: string - avatar: string | null -} - -type Member = { - id: number - username: string - avatar?: string | null - role: "OWNER" | "ADMIN" | "MEMBER" -} +import { useEffect } from "react" +import { useParams, useRouter } from "next/navigation" +import { api } from "@openchat/lib" export default function ZonePage() { const { zonePublicId } = useParams<{ zonePublicId: string }>() - const [zone, setZone] = useState(null) - const [messages, setMessages] = useState([]) - const [members, setMembers] = useState([]) - const [user, setUser] = useState(null) - const [currentUserId, setCurrentUserId] = useState(null) - const [input, setInput] = useState("") - const [selectedFile, setSelectedFile] = useState(null) - const [previewUrl, setPreviewUrl] = useState(null) - const [showAddModal, setShowAddModal] = useState(false) - const [results, setResults] = useState([]) - - const messagesRef = useRef(null) - const fileInputRef = useRef(null) + const router = useRouter() - const userCache = useRef>(new Map()) - - useEffect(() => { - const loadMe = async () => { - const res = await api("/auth/me", { credentials: "include" }) - const data = await res.json() - setCurrentUserId(data.user.id) - setUser(data.user) - userCache.current.set(data.user.id, data.user) - } - loadMe() - }, []) - - // Load zone, messages, members useEffect(() => { if (!zonePublicId) return - const loadZone = async () => { + const loadChannels = async () => { try { - // Zones - const zonesRes = await api("/zones") - const zonesData = await zonesRes.json() - const current = zonesData.zones.find((z: Zone) => z.publicId === zonePublicId) - setZone(current) - - // Messages - const msgsRes = await api(`/chats/${zonePublicId}/messages`) - const msgsData = await msgsRes.json() - const sortedMessages = (msgsData.messages ?? []).sort((a: Message, b: Message) => a.id - b.id) - - setMessages(sortedMessages) - - // Members - const membersRes = await api(`/zones/${zonePublicId}/members`) - const membersData = await membersRes.json() - setMembers(membersData.members ?? []) - - // cache members - membersData.members?.forEach((m: Member) => { - userCache.current.set(m.id, m) - }) - - } catch (err) { - console.error("Failed to load zone data", err) - } - } - - loadZone() - }, [zonePublicId]) - - // Join socket room - useEffect(() => { - if (!zonePublicId) return - socket.emit("chat:join", zonePublicId) - return () => { - socket.emit("chat:leave", zonePublicId) - } - }, [zonePublicId]) - - // Listen messages - useEffect(() => { - const handler = async (msg: Message & { chatPublicId: string }) => { - if (msg.chatPublicId !== zonePublicId) return - // Placeholder sender - const cached = userCache.current.get(msg.senderId) - const sender = msg.sender || cached || { id: msg.senderId, username: "Loading...", avatar: null } - - setMessages(prev => prev.some(m => m.id === msg.id) ? prev : [...prev, { ...msg, sender }]) - - // Fetch sender if missing - if (!cached) { - try { - const res = await api(`/users/${msg.senderId}`) - const data = await res.json() - userCache.current.set(msg.senderId, data.user) - setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, sender: data.user } : m)) - } catch (err) { - console.error("Failed to fetch sender", err) + const res = await api(`/zones/${zonePublicId}/channels`) + const data = await res.json() + const channels = data.channels ?? [] + + const firstTextChannel = channels.find((c: any) => c.type === 'TEXT') || channels[0] + + if (firstTextChannel) { + router.replace(`/zone/zones/${zonePublicId}/channels/${firstTextChannel.publicId}`) } + } catch (err) { + console.error("Failed to load channel data", err) } } - socket.on("private-message", handler) - return () => { - socket.off("private-message", handler) - } - }, [zonePublicId]) - - // Auto scroll - useEffect(() => { - if (!messagesRef.current) return - messagesRef.current.scrollTop = messagesRef.current.scrollHeight - }, [messages]) - - // Send message - const send = useCallback(async () => { - if (!zonePublicId) return - if (!input.trim() && !selectedFile) return - - const tempId = Date.now() - setMessages(prev => [ - ...prev, - { - id: tempId, - text: input || null, - senderId: currentUserId!, - fileUrl: previewUrl ?? undefined, - fileType: selectedFile?.type, - sender: userCache.current.get(currentUserId!) || user - } - ]) - - let fileUrl: string | null = null - let fileType: string | null = null - - if (selectedFile) { - const form = new FormData() - form.append("file", selectedFile) - const res = await api(`/zones/${zonePublicId}/upload`, { - method: "POST", - body: form, - credentials: "include" - }) - const data = await res.json() - fileUrl = data.fileUrl - fileType = selectedFile.type - } - - socket.emit( - "private-message", - { chatPublicId: zonePublicId, text: input || null, fileUrl, fileType }, - (savedMessage: Message) => { - setMessages(prev => prev.map(m => m.id === tempId ? savedMessage : m)) - } - ) - - setInput("") - setSelectedFile(null) - if (previewUrl) URL.revokeObjectURL(previewUrl) - setPreviewUrl(null) - }, [input, selectedFile, previewUrl, zonePublicId, currentUserId, user]) - - if (!zone) return
Loading...
+ loadChannels() + }, [zonePublicId, router]) return ( -
- - {/* Header */} - - - {/* Messages */} -
-
- {messages.map(m => { - const isMe = m.senderId === currentUserId - const sender = m.sender || userCache.current.get(m.senderId) || { username: "Loading...", avatar: null } - - return ( -
- {!isMe && ( - - - {sender.username[0]?.toUpperCase()} - - )} - -
- {!isMe &&

{sender.username}

} -
- {m.isDeleted ? "Message deleted" : m.text} -
- {m.fileUrl && } -
- - {isMe && ( - - - {user?.username[0]?.toUpperCase()} - - )} -
- ) - })} -
-
- - {/* Input */} -
- setInput(e.target.value)} - placeholder="Type message..." - onKeyDown={e => e.key === "Enter" && send()} - /> - - { - const f = e.target.files?.[0] - if (!f) return - setSelectedFile(f) - setPreviewUrl(URL.createObjectURL(f)) - }} - /> - - - - -
- - {/* Add User Modal */} - {showAddModal && ( -
-
-

Add User

- { - if (!e.target.value) return setResults([]) - api(`/users/search?q=${e.target.value}`).then(res => res.json()).then(data => setResults(data.users ?? [])) - }} /> -
- {results.map(u => ( -
{ - api(`/zones/${zonePublicId}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: u.id }), - credentials: "include" - }).then(() => setMembers(prev => [...prev, u])) - setShowAddModal(false) - }}> - {u.username} -
- ))} -
-
-
- )} - +
+
Entering Zone...
) } diff --git a/apps/frontend/src/hooks/useChannelVoiceCall.ts b/apps/frontend/src/hooks/useChannelVoiceCall.ts new file mode 100644 index 0000000..449b0ca --- /dev/null +++ b/apps/frontend/src/hooks/useChannelVoiceCall.ts @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { socket, getAudioStream, createPeer } from "@openchat/lib" + +interface Participant { + userId: number + socketId: string + username: string + avatar: string | null +} + +export function useChannelVoiceCall(channelPublicId: string | null) { + const [inCall, setInCall] = useState(false) + const [participants, setParticipants] = useState([]) + const peerConnections = useRef>(new Map()) + const localStreamRef = useRef(null) + const [remoteStreams, setRemoteStreams] = useState>(new Map()) + + async function fetchIceServers(): Promise { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/webrtc/ice`, { credentials: "include" }) + if (!res.ok) throw new Error("Failed to fetch ICE servers") + const data = await res.json() + return data.iceServers + } catch (err) { + console.error("ICE Fetch Error:", err) + return [{ urls: "stun:stun.l.google.com:19302" }] + } + } + + const cleanup = useCallback(() => { + console.log("[ChannelCall] Cleaning up...") + peerConnections.current.forEach(pc => pc.close()) + peerConnections.current.clear() + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(t => t.stop()) + localStreamRef.current = null + } + setRemoteStreams(new Map()) + setParticipants([]) + setInCall(false) + }, []) + + const createPeerConnection = async (targetSocketId: string, targetUserId: number, iceServers: RTCIceServer[], isInitiator: boolean) => { + if (peerConnections.current.has(targetSocketId)) return peerConnections.current.get(targetSocketId)! + + const pc = createPeer(iceServers) + peerConnections.current.set(targetSocketId, pc) + + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => { + if (localStreamRef.current) pc.addTrack(track, localStreamRef.current) + }) + } + + pc.ontrack = (event) => { + setRemoteStreams(prev => { + const next = new Map(prev) + next.set(targetUserId, event.streams[0]) + return next + }) + } + + pc.onicecandidate = (event) => { + if (event.candidate) { + socket.emit("channel:signal", { + toSocketId: targetSocketId, + signal: { type: "ice", candidate: event.candidate } + }) + } + } + + if (isInitiator) { + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + socket.emit("channel:signal", { + toSocketId: targetSocketId, + signal: { type: "offer", offer } + }) + } + + return pc + } + + const joinCall = useCallback(async () => { + if (!channelPublicId) return + console.log("[ChannelCall] Joining channel:", channelPublicId) + + try { + const stream = await getAudioStream() + localStreamRef.current = stream + setInCall(true) + + socket.emit("channel:join-call", { channelPublicId }) + } catch (err) { + console.error("[ChannelCall] Join Error:", err) + } + }, [channelPublicId]) + + const leaveCall = useCallback(() => { + if (!channelPublicId) return + socket.emit("channel:leave-call", { channelPublicId }) + cleanup() + }, [channelPublicId, cleanup]) + + useEffect(() => { + if (!channelPublicId) return + + const handleUserJoined = async ({ participant }: { participant: Participant }) => { + console.log("[ChannelCall] User joined:", participant.username) + setParticipants(prev => [...prev, participant]) + + const iceServers = await fetchIceServers() + await createPeerConnection(participant.socketId, participant.userId, iceServers, true) + } + + const handleCurrentParticipants = async ({ participants }: { participants: Participant[] }) => { + console.log("[ChannelCall] Current participants:", participants) + setParticipants(participants) + // Joiner is not the initiator for existing participants + } + + const handleUserLeft = ({ userId }: { userId: number }) => { + console.log("[ChannelCall] User left:", userId) + setParticipants(prev => prev.filter(p => p.userId !== userId)) + setRemoteStreams(prev => { + const next = new Map(prev) + next.delete(userId) + return next + }) + // find and close peer connection + // We don't have socketId here easily, but we can find it in our mapping or close all for that user + } + + const handleSignal = async ({ fromSocketId, fromUserId, signal }: any) => { + const iceServers = await fetchIceServers() + let pc = peerConnections.current.get(fromSocketId) + + if (!pc) { + pc = await createPeerConnection(fromSocketId, fromUserId, iceServers, false) + } + + if (signal.type === "offer") { + await pc.setRemoteDescription(new RTCSessionDescription(signal.offer)) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + socket.emit("channel:signal", { + toSocketId: fromSocketId, + signal: { type: "answer", answer } + }) + } else if (signal.type === "answer") { + await pc.setRemoteDescription(new RTCSessionDescription(signal.answer)) + } else if (signal.type === "ice") { + await pc.addIceCandidate(new RTCIceCandidate(signal.candidate)) + } + } + + socket.on("channel:user-joined", handleUserJoined) + socket.on("channel:current-participants", handleCurrentParticipants) + socket.on("channel:user-left", handleUserLeft) + socket.on("channel:signal", handleSignal) + + return () => { + socket.off("channel:user-joined", handleUserJoined) + socket.off("channel:current-participants", handleCurrentParticipants) + socket.off("channel:user-left", handleUserLeft) + socket.off("channel:signal", handleSignal) + } + }, [channelPublicId]) + + return { + inCall, + participants, + remoteStreams, + joinCall, + leaveCall + } +} diff --git a/apps/frontend/src/hooks/useVoiceCall.ts b/apps/frontend/src/hooks/useVoiceCall.ts index 5d54475..3fb7f83 100644 --- a/apps/frontend/src/hooks/useVoiceCall.ts +++ b/apps/frontend/src/hooks/useVoiceCall.ts @@ -1,254 +1,279 @@ -import { useEffect, useRef, useState } from "react" -import { socket, getAudioStream, createPeer, api } from "@openchat/lib" +import { useCallback, useEffect, useRef, useState } from "react" +import { socket, getAudioStream, createPeer } from "@openchat/lib" import { useCallStore } from "@/app/stores/call-store" -import { - CallAnswerPayload, - CallEndPayload, - CallIcePayload, - CallOfferPayload, -} from "@openchat/types" export function useVoiceCall() { const peerRef = useRef(null) const localStreamRef = useRef(null) const remoteAudioRef = useRef(null) const pendingOfferRef = useRef(null) - const ringtoneRef = useRef(null) const cleaningRef = useRef(false) + const iceQueueRef = useRef([]) + + const startingRef = useRef(false) + const acceptingRef = useRef(false) + const [inCall, setInCall] = useState(false) - // const showIncoming = useCallStore((s) => s.showIncoming) + const setConnected = useCallStore((s) => s.setConnected) const clearCall = useCallStore((s) => s.clear) const getActiveChatId = () => useCallStore.getState().chatPublicId - const [inCall, setInCall] = useState(false) - async function fetchIceServers(): Promise { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/webrtc/ice`, - { credentials: "include" } - ) - - const data = await res.json() - return data.iceServers - } - - function playRingtone() { - const audio = ringtoneRef.current - if (!audio) return - - audio.loop = true - audio.currentTime = 0 - audio.muted = false - - audio.play().catch(() => { }) - } - - // function playRingtone() { - // if (!ringtoneRef.current) return - // ringtoneRef.current.currentTime = 0 - // ringtoneRef.current.loop = true - // ringtoneRef.current.play().catch(() => { }) - // } - - function stopRingtone() { - if (!ringtoneRef.current) return - ringtoneRef.current.pause() - ringtoneRef.current.currentTime = 0 - } - - useEffect(() => { - if (!socket.connected) socket.connect() - }, []) - - async function startCall(chatPublicId: string) { - if (inCall) return - - useCallStore.setState({ chatPublicId }) - - socket.emit("join-room", { chatPublicId }) - - const stream = await getAudioStream() - localStreamRef.current = stream - - const iceServers = await fetchIceServers() - const peer = createPeer(iceServers) - peerRef.current = peer - - stream.getTracks().forEach((t) => peer.addTrack(t, stream)) - - peer.ontrack = (e) => { - if (remoteAudioRef.current) { - remoteAudioRef.current.srcObject = e.streams[0] - } - } - - peer.onicecandidate = (e) => { - if (!e.candidate) return - const cid = getActiveChatId() - if (!cid) return - socket.emit("call:ice", { chatPublicId: cid, candidate: e.candidate }) + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/webrtc/ice`, + { credentials: "include" } + ) + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) + const data = await res.json() + return data.iceServers + } catch (err) { + console.error("[useVoiceCall] fetchIceServers Error:", err) + return [{ urls: "stun:stun.l.google.com:19302" }] } - - const offer = await peer.createOffer() - await peer.setLocalDescription(offer) - - socket.emit("call:offer", { chatPublicId, offer }) } - async function acceptCall() { - const chatPublicId = getActiveChatId() - if (!pendingOfferRef.current || !chatPublicId) return - - stopRingtone() - - socket.emit("join-room", { chatPublicId }) - - const stream = await getAudioStream() - localStreamRef.current = stream - - const iceServers = await fetchIceServers() - const peer = createPeer(iceServers) - peerRef.current = peer - - stream.getTracks().forEach((t) => peer.addTrack(t, stream)) - - peer.ontrack = (e) => { - if (remoteAudioRef.current) { - remoteAudioRef.current.srcObject = e.streams[0] - } + const toggleMute = useCallback((muted: boolean) => { + if (localStreamRef.current) { + localStreamRef.current.getAudioTracks().forEach((track) => { + track.enabled = !muted + }) } + }, []) - peer.onicecandidate = (e) => { - if (!e.candidate) return - socket.emit("call:ice", { chatPublicId, candidate: e.candidate }) - } - - await peer.setRemoteDescription(pendingOfferRef.current) - - const answer = await peer.createAnswer() - await peer.setLocalDescription(answer) - - socket.emit("call:answer", { chatPublicId, answer }) - - pendingOfferRef.current = null - clearCall() - setInCall(true) - } - - function cleanupCall() { + const cleanupCall = useCallback(() => { if (cleaningRef.current) return cleaningRef.current = true - + + console.log("[useVoiceCall] cleanup...") const cid = getActiveChatId() - if (cid) socket.emit("leave-room", { chatPublicId: cid }) - - peerRef.current?.close() - peerRef.current = null - - localStreamRef.current?.getTracks().forEach((t) => t.stop()) - localStreamRef.current = null - - stopRingtone() + if (cid) { + socket.emit("leave-room", { chatPublicId: cid }) + } + if (peerRef.current) { + peerRef.current.close() + peerRef.current = null + } + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((t) => t.stop()) + localStreamRef.current = null + } if (remoteAudioRef.current) { remoteAudioRef.current.pause() remoteAudioRef.current.srcObject = null } - pendingOfferRef.current = null + iceQueueRef.current = [] clearCall() setInCall(false) - - setTimeout(() => { - cleaningRef.current = false - }, 300) - } - - - function onCallReject({ chatPublicId }: { chatPublicId: string }) { - const cid = getActiveChatId() - if (chatPublicId !== cid) return - cleanupCall() + startingRef.current = false + acceptingRef.current = false + + setTimeout(() => { cleaningRef.current = false }, 300) + }, [clearCall]) + + const finalizeConnection = (peer: RTCPeerConnection) => { + peer.onconnectionstatechange = () => { + console.log("[useVoiceCall] Connection state:", peer.connectionState) + if (peer.connectionState === "connected") { + setConnected() + } + if (peer.connectionState === "failed" || peer.connectionState === "closed") { + // Don't instantly cleanup on "failed" if we support reconnect, + // but for now, we'll keep it simple. + } + } } + const startCall = useCallback(async (chatPublicId: string) => { + if (startingRef.current) return + startingRef.current = true + + console.log("[useVoiceCall] startCall...") + try { + socket.emit("join-room", { chatPublicId }) + + if (!localStreamRef.current) { + const stream = await getAudioStream() + localStreamRef.current = stream + } + const stream = localStreamRef.current + + const iceServers = await fetchIceServers() + + if (peerRef.current) peerRef.current.close() + + const peer = createPeer(iceServers) + peerRef.current = peer + finalizeConnection(peer) + + stream.getTracks().forEach((t) => peer.addTrack(t, stream)) + + peer.ontrack = (e) => { + console.log("[useVoiceCall] received remote track") + if (remoteAudioRef.current) { + remoteAudioRef.current.srcObject = e.streams[0] + } + } - function endCall() { - const cid = getActiveChatId() - if (cid) socket.emit("call:end", { chatPublicId: cid }) - cleanupCall() - } - + peer.onicecandidate = (e) => { + if (!e.candidate) return + socket.emit("call:ice", { chatPublicId, candidate: e.candidate }) + } - useEffect(() => { - function onOffer({ chatPublicId, offer, from }: CallOfferPayload) { - pendingOfferRef.current = offer - // showIncoming({ chatPublicId, caller: from }) - playRingtone() + const offer = await peer.createOffer() + await peer.setLocalDescription(offer) + socket.emit("call:offer", { chatPublicId, offer }) + setInCall(true) + } catch (err) { + console.error("[useVoiceCall] startCall failed:", err) + cleanupCall() + } finally { + startingRef.current = false } + }, [setConnected, cleanupCall]) + const acceptCall = useCallback(async () => { + const chatPublicId = getActiveChatId() + if (!pendingOfferRef.current || !chatPublicId || acceptingRef.current) { + return + } + + acceptingRef.current = true + console.log("[useVoiceCall] acceptCall starting...") + try { + socket.emit("join-room", { chatPublicId }) + + if (!localStreamRef.current) { + const stream = await getAudioStream() + localStreamRef.current = stream + } + const stream = localStreamRef.current + + const iceServers = await fetchIceServers() + + if (peerRef.current) peerRef.current.close() + + const peer = createPeer(iceServers) + peerRef.current = peer + finalizeConnection(peer) + + stream.getTracks().forEach((t) => peer.addTrack(t, stream)) + + peer.ontrack = (e) => { + console.log("[useVoiceCall] received remote track") + if (remoteAudioRef.current) { + remoteAudioRef.current.srcObject = e.streams[0] + } + } - function onAnswer({ chatPublicId, answer }: CallAnswerPayload) { - const cid = getActiveChatId() - const peer = peerRef.current + peer.onicecandidate = (e) => { + if (!e.candidate) return + socket.emit("call:ice", { chatPublicId, candidate: e.candidate }) + } - if (!peer || chatPublicId !== cid) return + await peer.setRemoteDescription(new RTCSessionDescription(pendingOfferRef.current)) - if (peer.signalingState !== "have-local-offer") { - console.warn( - "Ignoring answer, wrong state:", - peer.signalingState - ) - return + console.log("[useVoiceCall] Draining ICE candidates...") + for (const cand of iceQueueRef.current) { + await peer.addIceCandidate(cand).catch(() => {}) } + iceQueueRef.current = [] + + const answer = await peer.createAnswer() + await peer.setLocalDescription(answer) + socket.emit("call:answer", { chatPublicId, answer }) - peer.setRemoteDescription(answer) + pendingOfferRef.current = null + setInCall(true) + } catch (err) { + console.error("[useVoiceCall] acceptCall failed:", err) + cleanupCall() + } finally { + acceptingRef.current = false } + }, [setConnected, cleanupCall]) + useEffect(() => { + const handleOffer = async ({ chatPublicId, offer }: any) => { + console.log("[useVoiceCall] socket: call:offer, state:", peerRef.current?.signalingState) + + const peer = peerRef.current + if (peer && peer.signalingState !== "stable") { + console.warn("[useVoiceCall] Ignoring offer, signalingState is", peer.signalingState) + return + } - function onIce({ chatPublicId, candidate }: CallIcePayload) { - const cid = getActiveChatId() - if (chatPublicId !== cid || !peerRef.current) return - peerRef.current.addIceCandidate(candidate) + pendingOfferRef.current = offer + + const state = useCallStore.getState() + // If we are already "connected" (re-sync) or just "connecting" + if ((state.status === "connecting" || state.status === "connected") && !state.isCaller) { + await acceptCall() + } } - function onCallEnd({ chatPublicId }: CallEndPayload) { + const handleAnswer = ({ chatPublicId, answer }: any) => { + console.log("[useVoiceCall] socket: call:answer, state:", peerRef.current?.signalingState) const cid = getActiveChatId() - if (chatPublicId !== cid) return - cleanupCall() + const peer = peerRef.current + + if (!peer || chatPublicId !== cid) return + + if (peer.signalingState !== "have-local-offer") { + console.warn("[useVoiceCall] Ignoring answer, state is not have-local-offer:", peer.signalingState) + return + } + + peer.setRemoteDescription(new RTCSessionDescription(answer)).then(() => { + console.log("[useVoiceCall] Answer set. Draining ICE...") + for (const cand of iceQueueRef.current) { + peer.addIceCandidate(cand).catch(() => {}) + } + iceQueueRef.current = [] + }) } - function onCallReject({ chatPublicId }: { chatPublicId: string }) { + const handleIce = ({ chatPublicId, candidate }: any) => { const cid = getActiveChatId() if (chatPublicId !== cid) return - cleanupCall() + + const peer = peerRef.current + if (!peer || !peer.remoteDescription) { + iceQueueRef.current.push(candidate) + } else { + peer.addIceCandidate(candidate).catch(() => {}) + } } - socket.on("call:offer", onOffer) - socket.on("call:answer", onAnswer) - socket.on("call:ice", onIce) - socket.on("call:end", onCallEnd) - socket.on("call:reject", onCallReject) - socket.on("disconnect", () => { - if (inCall) cleanupCall() - }) + socket.on("call:offer", handleOffer) + socket.on("call:answer", handleAnswer) + socket.on("call:ice", handleIce) + socket.on("call:end", cleanupCall) + socket.on("call:reject", cleanupCall) + socket.on("disconnect", cleanupCall) return () => { - socket.off("call:offer", onOffer) - socket.off("call:answer", onAnswer) - socket.off("call:ice", onIce) - socket.off("call:end", onCallEnd) - socket.off("call:reject", onCallReject) + socket.off("call:offer", handleOffer) + socket.off("call:answer", handleAnswer) + socket.off("call:ice", handleIce) + socket.off("call:end", cleanupCall) + socket.off("call:reject", cleanupCall) socket.off("disconnect", cleanupCall) } - }, []) + }, [acceptCall, cleanupCall]) return { startCall, acceptCall, - onCallReject, - endCall, + endCall: () => { + const cid = getActiveChatId() + if (cid) socket.emit("call:end", { chatPublicId: cid }) + cleanupCall() + }, inCall, remoteAudioRef, - ringtoneRef, + toggleMute, } } diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts index fc8bac8..d8b89ca 100644 --- a/apps/frontend/src/middleware.ts +++ b/apps/frontend/src/middleware.ts @@ -12,5 +12,5 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: ["/zone/:path*"], + matcher: ["/zone/:path*", "/settings/:path*", "/dashboard/:path*"], }; diff --git a/eslint.config.js b/eslint.config.js index d3e442f..c5e9b88 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,20 +2,16 @@ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' -// Use official scoped package name for TypeScript ESLint import tseslint from '@typescript-eslint/eslint-plugin' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), + js.configs.recommended, + ...tseslint.configs['flat/recommended'], + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, diff --git a/packages/components/src/ui/Navbar.tsx b/packages/components/src/ui/Navbar.tsx index ce8d5f5..bb5ce92 100644 --- a/packages/components/src/ui/Navbar.tsx +++ b/packages/components/src/ui/Navbar.tsx @@ -3,8 +3,22 @@ import Link from "next/link"; import { motion } from "framer-motion"; import { Button } from "./button"; +import { + Avatar, + AvatarImage, + AvatarFallback +} from "./avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./dropdown-menu"; +import { User, LayoutDashboard, Settings, LogOut } from "lucide-react"; -export default function Navbar() { +export default function Navbar({ user }: { user?: any }) { return ( - +
+ {user ? ( + + + + + + +
+

{user.name || user.username}

+

+ {user.email} +

+
+
+ + + + + Dashboard + + + + + + Settings + + + + { + // We can't use api() here easily without importing it, + // and packages shouldn't import from @/lib of an app. + // So we'll just redirect to a logout route or clear cookies. + // Best way: just redirect to dashboard/zone and let it handle logout if needed, + // but here we just want to go to auth. + window.location.href = "/auth"; + }} + > + + Log out + +
+
+ ) : ( + <> + + Login + + + + )} +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c62786..0dcf5da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,12 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.6) + '@typescript-eslint/eslint-plugin': + specifier: ^8.46.3 + version: 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.46.3 + version: 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -277,7 +283,16 @@ importers: version: 10.4.22(postcss@8.5.6) eslint-config-next: specifier: ^16.0.3 - version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.24(eslint@9.39.1(jiti@1.21.7)) + globals: + specifier: ^16.5.0 + version: 16.5.0 path: specifier: ^0.12.7 version: 0.12.7 @@ -5873,10 +5888,10 @@ snapshots: '@types/strip-json-comments@0.0.30': {} - '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.47.0 '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) @@ -6759,13 +6774,13 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): + eslint-config-next@16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.0.3 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@1.21.7)) @@ -6798,22 +6813,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6824,7 +6839,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6836,7 +6851,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6883,6 +6898,10 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-refresh@0.4.24(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-react-refresh@0.4.24(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -8704,7 +8723,7 @@ snapshots: typescript-eslint@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)