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
-