From e6da3a0ea46a1b03d15dbb422eb71f62ae7e06af Mon Sep 17 00:00:00 2001 From: Muhammed Date: Fri, 13 Mar 2026 09:32:33 +0200 Subject: [PATCH 1/3] feat: add WebRTC calling and channel support inside zones --- .../migration.sql | 45 +++ .../20260313062252_add_channels/migration.sql | 59 +++ apps/backend/prisma/schema.prisma | 26 ++ .../src/controllers/chat.controller.ts | 16 +- .../src/controllers/zones.controller.ts | 73 +++- apps/backend/src/index.ts | 41 +- apps/backend/src/routes/zones.routes.ts | 5 +- apps/backend/src/scripts/init-channels.ts | 38 ++ apps/backend/src/socket/callHandler.ts | 64 +-- apps/backend/src/socket/privateChat.ts | 58 ++- apps/frontend/src/app/(landing)/Hero.tsx | 7 +- apps/frontend/src/app/auth/page.tsx | 2 +- apps/frontend/src/app/dashboard/layout.tsx | 20 + apps/frontend/src/app/dashboard/page.tsx | 315 +++++++++++++++ .../app/providers/global-call-provider.tsx | 128 +++--- .../src/app/providers/realtime-provider.tsx | 47 ++- .../src/app/providers/user-provider.tsx | 2 +- .../settings/_components/SettingsSidebar.tsx | 16 +- apps/frontend/src/app/stores/call-store.ts | 5 + apps/frontend/src/app/stores/chat-store.ts | 54 ++- apps/frontend/src/app/stores/friends-store.ts | 22 ++ .../src/app/zone/_components/MobileNav.tsx | 32 ++ .../src/app/zone/_components/UserBar.tsx | 38 +- .../src/app/zone/_components/ZoneSidebar.tsx | 175 +++++++-- .../zone/_components/global/CallOverlay.tsx | 41 +- .../_components/zones/CreateChannelModal.tsx | 95 +++++ .../app/zone/_components/zones/ZonesList.tsx | 2 +- .../src/app/zone/chat/[chatPublicId]/page.tsx | 51 ++- .../src/app/zone/friends/FriendList.tsx | 32 +- apps/frontend/src/app/zone/layout.tsx | 23 +- apps/frontend/src/app/zone/page.tsx | 2 +- .../channels/[channelPublicId]/page.tsx | 363 ++++++++++++++++++ .../app/zone/zones/[zonePublicId]/page.tsx | 304 +-------------- apps/frontend/src/hooks/useVoiceCall.ts | 77 ++-- 34 files changed, 1759 insertions(+), 519 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260313061451_add_discord_zones/migration.sql create mode 100644 apps/backend/prisma/migrations/20260313062252_add_channels/migration.sql create mode 100644 apps/backend/src/scripts/init-channels.ts create mode 100644 apps/frontend/src/app/dashboard/layout.tsx create mode 100644 apps/frontend/src/app/dashboard/page.tsx create mode 100644 apps/frontend/src/app/zone/_components/MobileNav.tsx create mode 100644 apps/frontend/src/app/zone/_components/zones/CreateChannelModal.tsx create mode 100644 apps/frontend/src/app/zone/zones/[zonePublicId]/channels/[channelPublicId]/page.tsx 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..72a509e 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) }, 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..badf5fe 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -34,6 +34,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: { @@ -52,8 +82,17 @@ io.on('connection', async (socket) => { privateChatHandler(io, socket) callHandler(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/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/socket/callHandler.ts b/apps/backend/src/socket/callHandler.ts index ba31a6c..9fb0be8 100644 --- a/apps/backend/src/socket/callHandler.ts +++ b/apps/backend/src/socket/callHandler.ts @@ -31,7 +31,7 @@ export function callHandler( const existingPartner = activeCalls.get(userId) if (existingPartner) { - io.to(existingPartner.toString()).emit("call:ended") + io.to(`user:${existingPartner}`).emit("call:ended") activeCalls.delete(existingPartner) activeCalls.delete(userId) } @@ -51,16 +51,18 @@ export function callHandler( activeCalls.set(userId, toUserId) activeCalls.set(toUserId, userId) - io.to(toUserId.toString()).emit("incoming:call", { - chatPublicId, - user: { - id: caller.id, - name: caller.username, - image: caller.avatar - ? `${process.env.BASE_URL}/uploads/${caller.avatar}` - : null, - }, - }) + if (toUserId) { + 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, + }, + }) + } } catch (err) { console.error("CALL USER ERROR:", err) } @@ -70,9 +72,11 @@ export function callHandler( ACCEPT ========================== */ socket.on("call:accept", ({ toUserId, chatPublicId }: CallPayload) => { - io.to(toUserId.toString()).emit("call:accepted", { - chatPublicId, - }) + if (toUserId) { + io.to(`user:${toUserId}`).emit("call:accepted", { + chatPublicId, + }) + } }) /* ========================= @@ -80,11 +84,12 @@ export function callHandler( ========================== */ socket.on("call:reject", ({ toUserId, chatPublicId }: CallPayload) => { activeCalls.delete(userId) - activeCalls.delete(toUserId) - - io.to(toUserId.toString()).emit("call:rejected", { - chatPublicId, - }) + if (toUserId) { + activeCalls.delete(toUserId) + io.to(`user:${toUserId}`).emit("call:rejected", { + chatPublicId, + }) + } }) /* ========================= @@ -92,11 +97,12 @@ export function callHandler( ========================== */ socket.on("call:end", ({ toUserId, chatPublicId }: CallPayload) => { activeCalls.delete(userId) - activeCalls.delete(toUserId) - - io.to(toUserId.toString()).emit("call:ended", { - chatPublicId, - }) + if (toUserId) { + activeCalls.delete(toUserId) + io.to(`user:${toUserId}`).emit("call:ended", { + chatPublicId, + }) + } }) /* ========================= @@ -105,10 +111,10 @@ export function callHandler( socket.on("disconnect", () => { const partner = activeCalls.get(userId) - if (partner) { - io.to(partner.toString()).emit("call:ended") - activeCalls.delete(userId) - activeCalls.delete(partner) - } + if (partner) { + io.to(`user:${partner}`).emit("call:ended") + activeCalls.delete(userId) + activeCalls.delete(partner) + } }) } diff --git a/apps/backend/src/socket/privateChat.ts b/apps/backend/src/socket/privateChat.ts index e5879da..5c17082 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,18 +99,29 @@ 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, - }) } ) @@ -140,5 +169,16 @@ export function privateChatHandler(io: Server, socket: Socket) { }) }) + 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("chat:typing", { + chatPublicId, + userId, + isTyping + }) + }) + } 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..13924fc 100644 --- a/apps/frontend/src/app/auth/page.tsx +++ b/apps/frontend/src/app/auth/page.tsx @@ -39,7 +39,7 @@ export default function AuthPage() { // Redirect if logged in useEffect(() => { - api(`/auth/me`, { + api(`/auth/me?t=${Date.now()}`, { credentials: "include", }) .then((res) => { 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/providers/global-call-provider.tsx b/apps/frontend/src/app/providers/global-call-provider.tsx index a869f72..0b8898a 100644 --- a/apps/frontend/src/app/providers/global-call-provider.tsx +++ b/apps/frontend/src/app/providers/global-call-provider.tsx @@ -4,21 +4,27 @@ 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 callingAudioRef = useRef(null) - const incomingAudioRef = useRef(null) + const { status, user, chatPublicId, isCaller, setIncoming, setConnected, clear } = useCallStore() + const { + startCall, + acceptCall, + endCall, + remoteAudioRef, + ringtoneRef, + playRingtone, + stopRingtone + } = useVoiceCall() /* ========================= SOCKET LISTENERS ========================== */ useEffect(() => { - const incomingHandler = (payload: any) => { + const incomingHandler = async (payload: any) => { console.log("INCOMING PAYLOAD:", payload) - const { chatPublicId, user } = payload setIncoming(chatPublicId, user) } @@ -48,111 +54,107 @@ export default function GlobalCallProvider() { } }, [setIncoming, setConnected, clear]) + /* ========================= + VOICE CALL SYNC + ========================== */ + + useEffect(() => { + // Only the caller initiates the WebRTC offer (startCall) + if (status === "connected" && chatPublicId && isCaller) { + startCall(chatPublicId) + } + }, [status, chatPublicId, isCaller, startCall]) + /* ========================= RING SOUNDS ========================== */ - useEffect(() => { - const callingAudio = callingAudioRef.current - const incomingAudio = incomingAudioRef.current + const outgoingAudioRef = useRef(null) - if (!callingAudio || !incomingAudio) return + useEffect(() => { + const outgoingAudio = outgoingAudioRef.current + if (!outgoingAudio) return - // Outgoing call sound if (status === "calling") { - callingAudio.loop = true - callingAudio.currentTime = 0 - callingAudio.play().catch(() => { }) + outgoingAudio.loop = true + outgoingAudio.currentTime = 0 + outgoingAudio.play().catch(() => { }) } else { - callingAudio.pause() - callingAudio.currentTime = 0 + outgoingAudio.pause() + outgoingAudio.currentTime = 0 } // Incoming call sound if (status === "incoming") { - incomingAudio.loop = true - incomingAudio.currentTime = 0 - incomingAudio.play().catch(() => { }) + playRingtone() } else { - incomingAudio.pause() - incomingAudio.currentTime = 0 + // Only stop if we are not connected yet or cleared + if (status !== "connected") { + stopRingtone() + } } - }, [status]) - + }, [status, playRingtone, stopRingtone]) useEffect(() => { function handleUnload() { - const { status, user, chatPublicId } = - useCallStore.getState() - - if (status !== "idle" && user?.id && chatPublicId) { + const state = useCallStore.getState() + if (state.status !== "idle" && state.user?.id && state.chatPublicId) { socket.emit("call:end", { - toUserId: user.id, - chatPublicId, + toUserId: state.user.id, + chatPublicId: state.chatPublicId, }) + endCall() } } window.addEventListener("beforeunload", handleUnload) - - return () => { - window.removeEventListener("beforeunload", handleUnload) - } - }, []) + return () => window.removeEventListener("beforeunload", handleUnload) + }, [endCall]) 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..7b2e6c3 100644 --- a/apps/frontend/src/app/zone/_components/ZoneSidebar.tsx +++ b/apps/frontend/src/app/zone/_components/ZoneSidebar.tsx @@ -1,11 +1,14 @@ -'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 } from 'lucide-react' +import { Button, ScrollArea } 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' export default function ZoneSidebar({ @@ -15,35 +18,157 @@ export default function ZoneSidebar({ }) { const pathname = usePathname() const router = useRouter() + const params = useParams<{ zonePublicId?: string; channelPublicId?: string }>() + + 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 isHome = pathname === '/zone' + 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 || 'Loading...') : 'Direct Messages'} +

- {/* Chats */} -
- +
+ {isHome ? ( + <> +
+ +
+ +
+

+ Direct Messages +

+
+ +
+ +
+ + ) : ( + +
+ {/* 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 => ( + + ))} +
+
+
+
+ )}
- {/* User */} + {/* 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..d28a505 100644 --- a/apps/frontend/src/app/zone/_components/global/CallOverlay.tsx +++ b/apps/frontend/src/app/zone/_components/global/CallOverlay.tsx @@ -86,24 +86,29 @@ export default function CallOverlay({
{status === "incoming" && ( - <> - - - - +
+
+ +

Accept

+
+ +
+ +

Decline

+
+
)} {status === "calling" && ( 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..a3eba85 --- /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-[#0b1220] 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..9dce69f 100644 --- a/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx +++ b/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx @@ -67,7 +67,7 @@ export default function ZonesList() { router.push(`/zone/zones/${zone.publicId}`) } return ( -
+
)} -
+
+ {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..36605c6 100644 --- a/apps/frontend/src/app/zone/friends/FriendList.tsx +++ b/apps/frontend/src/app/zone/friends/FriendList.tsx @@ -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) @@ -141,20 +142,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..c914a86 --- /dev/null +++ b/apps/frontend/src/app/zone/zones/[zonePublicId]/channels/[channelPublicId]/page.tsx @@ -0,0 +1,363 @@ +'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, 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
Loading...
+ + 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/useVoiceCall.ts b/apps/frontend/src/hooks/useVoiceCall.ts index 5d54475..17d2459 100644 --- a/apps/frontend/src/hooks/useVoiceCall.ts +++ b/apps/frontend/src/hooks/useVoiceCall.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { socket, getAudioStream, createPeer, api } from "@openchat/lib" import { useCallStore } from "@/app/stores/call-store" import { @@ -15,6 +15,7 @@ export function useVoiceCall() { const pendingOfferRef = useRef(null) const ringtoneRef = useRef(null) const cleaningRef = useRef(false) + const iceQueueRef = useRef([]) // const showIncoming = useCallStore((s) => s.showIncoming) const clearCall = useCallStore((s) => s.clear) @@ -32,7 +33,7 @@ export function useVoiceCall() { return data.iceServers } - function playRingtone() { + const playRingtone = useCallback(() => { const audio = ringtoneRef.current if (!audio) return @@ -41,7 +42,7 @@ export function useVoiceCall() { audio.muted = false audio.play().catch(() => { }) - } + }, []) // function playRingtone() { // if (!ringtoneRef.current) return @@ -50,17 +51,17 @@ export function useVoiceCall() { // ringtoneRef.current.play().catch(() => { }) // } - function stopRingtone() { + const stopRingtone = useCallback(() => { if (!ringtoneRef.current) return ringtoneRef.current.pause() ringtoneRef.current.currentTime = 0 - } + }, []) useEffect(() => { if (!socket.connected) socket.connect() }, []) - async function startCall(chatPublicId: string) { + const startCall = useCallback(async (chatPublicId: string) => { if (inCall) return useCallStore.setState({ chatPublicId }) @@ -93,9 +94,9 @@ export function useVoiceCall() { await peer.setLocalDescription(offer) socket.emit("call:offer", { chatPublicId, offer }) - } + }, [inCall]) - async function acceptCall() { + const acceptCall = useCallback(async () => { const chatPublicId = getActiveChatId() if (!pendingOfferRef.current || !chatPublicId) return @@ -123,7 +124,13 @@ export function useVoiceCall() { socket.emit("call:ice", { chatPublicId, candidate: e.candidate }) } - await peer.setRemoteDescription(pendingOfferRef.current) + await peer.setRemoteDescription(new RTCSessionDescription(pendingOfferRef.current)) + + // Drain queued ICE candidates + for (const cand of iceQueueRef.current) { + await peer.addIceCandidate(cand).catch(e => console.error("Ice queue error:", e)) + } + iceQueueRef.current = [] const answer = await peer.createAnswer() await peer.setLocalDescription(answer) @@ -133,9 +140,9 @@ export function useVoiceCall() { pendingOfferRef.current = null clearCall() setInCall(true) - } + }, [stopRingtone, clearCall]) - function cleanupCall() { + const cleanupCall = useCallback(() => { if (cleaningRef.current) return cleaningRef.current = true @@ -156,13 +163,14 @@ export function useVoiceCall() { } pendingOfferRef.current = null + iceQueueRef.current = [] clearCall() setInCall(false) setTimeout(() => { cleaningRef.current = false }, 300) - } + }, [stopRingtone, clearCall]) function onCallReject({ chatPublicId }: { chatPublicId: string }) { @@ -172,18 +180,21 @@ export function useVoiceCall() { } - function endCall() { - const cid = getActiveChatId() - if (cid) socket.emit("call:end", { chatPublicId: cid }) + const endCall = useCallback(() => { cleanupCall() - } + }, [cleanupCall]) useEffect(() => { - function onOffer({ chatPublicId, offer, from }: CallOfferPayload) { + async function handleOnOffer({ chatPublicId, offer, from }: CallOfferPayload) { pendingOfferRef.current = offer - // showIncoming({ chatPublicId, caller: from }) - playRingtone() + + const state = useCallStore.getState() + // If the user already clicked "Accept" in the UI (status connected), + // we can immediately proceed with the WebRTC handshake. + if (state.status === "connected" && !state.isCaller) { + await acceptCall() + } } @@ -201,14 +212,28 @@ export function useVoiceCall() { return } - peer.setRemoteDescription(answer) + peer.setRemoteDescription(answer).then(() => { + // Drain queued ICE candidates for the caller + for (const cand of iceQueueRef.current) { + peer.addIceCandidate(cand).catch(e => console.error("Ice queue error:", e)) + } + iceQueueRef.current = [] + }) } function onIce({ chatPublicId, candidate }: CallIcePayload) { const cid = getActiveChatId() - if (chatPublicId !== cid || !peerRef.current) return - peerRef.current.addIceCandidate(candidate) + const peer = peerRef.current + if (chatPublicId !== cid || !peer) return + + if (!peer.remoteDescription) { + iceQueueRef.current.push(candidate) + } else { + peer.addIceCandidate(candidate).catch(e => { + console.warn("Error adding ice candidate:", e) + }) + } } function onCallEnd({ chatPublicId }: CallEndPayload) { @@ -223,7 +248,7 @@ export function useVoiceCall() { cleanupCall() } - socket.on("call:offer", onOffer) + socket.on("call:offer", handleOnOffer) socket.on("call:answer", onAnswer) socket.on("call:ice", onIce) socket.on("call:end", onCallEnd) @@ -233,14 +258,14 @@ export function useVoiceCall() { }) return () => { - socket.off("call:offer", onOffer) + socket.off("call:offer", handleOnOffer) socket.off("call:answer", onAnswer) socket.off("call:ice", onIce) socket.off("call:end", onCallEnd) socket.off("call:reject", onCallReject) socket.off("disconnect", cleanupCall) } - }, []) + }, [acceptCall, cleanupCall, clearCall, playRingtone, stopRingtone, inCall]) return { startCall, @@ -250,5 +275,7 @@ export function useVoiceCall() { inCall, remoteAudioRef, ringtoneRef, + playRingtone, + stopRingtone, } } From 1499f4bb56901795850d3e17679d76fbc6c0a9ec Mon Sep 17 00:00:00 2001 From: Muhammed Date: Sat, 14 Mar 2026 22:49:21 +0200 Subject: [PATCH 2/3] feat: complete voice call system, auth integration, and profile settings --- ;q | 30 -- README.md | 277 ++++++------ .../src/controllers/chat.controller.ts | 55 ++- .../src/controllers/user.controller.ts | 15 + apps/backend/src/index.ts | 1 + apps/backend/src/routes/chat.routes.ts | 3 +- apps/backend/src/routes/user.routes.ts | 3 +- apps/backend/src/services/user.service.ts | 31 ++ apps/backend/src/socket/callHandler.ts | 256 ++++++++--- apps/backend/src/socket/privateChat.ts | 46 +- apps/backend/uploads/1773520299704.jpg | Bin 0 -> 56962 bytes .../4d3e61ee-c14d-4927-afe4-1af2f7be2e3f.jpg | Bin 0 -> 56962 bytes apps/frontend/next-env.d.ts | 2 +- apps/frontend/src/app/auth/page.tsx | 67 ++- apps/frontend/src/app/page.tsx | 12 +- .../app/providers/global-call-provider.tsx | 200 +++++---- .../src/app/settings/profile/page.tsx | 23 +- apps/frontend/src/app/stores/call-store.ts | 38 +- .../src/app/zone/_components/ZoneSidebar.tsx | 27 +- .../zone/_components/global/CallOverlay.tsx | 144 ++++--- .../_components/zones/CreateChannelModal.tsx | 10 +- .../app/zone/_components/zones/ZonesList.tsx | 8 +- .../src/app/zone/chat/[chatPublicId]/page.tsx | 6 +- apps/frontend/src/hooks/useVoiceCall.ts | 406 +++++++++--------- apps/frontend/src/middleware.ts | 2 +- packages/components/src/ui/Navbar.tsx | 88 +++- 26 files changed, 993 insertions(+), 757 deletions(-) delete mode 100644 ;q create mode 100644 apps/backend/uploads/1773520299704.jpg create mode 100644 apps/backend/uploads/4d3e61ee-c14d-4927-afe4-1af2f7be2e3f.jpg 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/src/controllers/chat.controller.ts b/apps/backend/src/controllers/chat.controller.ts index 72a509e..aeaf234 100644 --- a/apps/backend/src/controllers/chat.controller.ts +++ b/apps/backend/src/controllers/chat.controller.ts @@ -69,7 +69,7 @@ export const getChatMessages = async (req: Request, res: Response) => { } const messages = await prisma.message.findMany({ - where: { + where: { chatId: chat.id, ...(channelId !== undefined ? { channelId } : { channelId: null }) }, @@ -155,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/index.ts b/apps/backend/src/index.ts index badf5fe..b69d02d 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' 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/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 9fb0be8..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,99 +27,167 @@ 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(`user:${existingPartner}`).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) - - if (toUserId) { - 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, - }, - }) + 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() }) + + 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 + } + }) } catch (err) { console.error("CALL USER ERROR:", err) } }) /* ========================= - ACCEPT + ACCEPT / REJECT / END ========================== */ - socket.on("call:accept", ({ toUserId, chatPublicId }: CallPayload) => { - if (toUserId) { - io.to(`user:${toUserId}`).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) - if (toUserId) { - activeCalls.delete(toUserId) - io.to(`user:${toUserId}`).emit("call:rejected", { - chatPublicId, - }) - } + socket.on("call:offer", ({ chatPublicId, offer }) => { + socket.to(`chat:${chatPublicId}`).emit("call:offer", { chatPublicId, offer, from: userId }) + }) + + 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) - if (toUserId) { - activeCalls.delete(toUserId) - io.to(`user:${toUserId}`).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) + } + } } }) @@ -109,12 +195,40 @@ export function callHandler( DISCONNECT ========================== */ socket.on("disconnect", () => { - const partner = activeCalls.get(userId) + const callId = userToCall.get(userId) + if (!callId) return - if (partner) { - io.to(`user:${partner}`).emit("call:ended") - activeCalls.delete(userId) - activeCalls.delete(partner) - } + 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/privateChat.ts b/apps/backend/src/socket/privateChat.ts index 5c17082..00c8906 100644 --- a/apps/backend/src/socket/privateChat.ts +++ b/apps/backend/src/socket/privateChat.ts @@ -102,7 +102,7 @@ export function privateChatHandler(io: Server, socket: Socket) { channelPublicId, createdAt: saved.createdAt, } - + // 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) @@ -126,49 +126,6 @@ export function privateChatHandler(io: Server, socket: Socket) { ) - 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 }) => { - const allowed = await isUserInChat(userId, chatPublicId) - if (!allowed) return - - socket.to(`chat:${chatPublicId}`).emit("call:ice", { - chatPublicId, - from: userId, - candidate, - }) - }) - - 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, - }) - }) - socket.on("chat:typing", async ({ chatPublicId, isTyping }: { chatPublicId: string, isTyping: boolean }) => { const allowed = await isUserInChat(userId, chatPublicId) if (!allowed) return @@ -179,6 +136,5 @@ export function privateChatHandler(io: Server, socket: Socket) { isTyping }) }) - } diff --git a/apps/backend/uploads/1773520299704.jpg b/apps/backend/uploads/1773520299704.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d24b68518dccb25720b620ad01f2b5a488bf727b GIT binary patch literal 56962 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
}kjOb4U9O%sKb{o$e` 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

Zd*VRInRsv5(iz8r0 zHPI!$k}J%$g)rNXf++vwBxzNQ12A&AJ0KDe071bzsmh+6cyYSU>`pqlS_N*{jokj{77KYp@U8gAz9`L*)5$#{g#`S`6*Q@>S}=uvz2kjZq1R?TskSw2co$HJ+X zPh=Ii7VlbLs3rEKv2{{nxl5^B+v<~Y%3xtG~4i(C+<$P(J zV2gxd?$M?vDrmPos zd*EVuCf;z8A3rwl=P)O?Xo(e_uO0@YZ0eEvL0+myTANr$Y{{PYLz`msLDM;c+6L46 z&)5p!Eq^?8i*RE++5Mq9Tqz$}=XcmIxc=<}IhdsRpd%8TG3JR-WN6fEa7eA#gkF z-)kcPtbOW|K`Pwn0t>_m*}$`F#*YyPmc?hJQa=&HuT(}sdaHS;2|%9gIh19E6?&wb zRPe|lfnD&%4wbRIN8^_pL(44vc4wa{mEhnvQh5ltt!nANr(i@Zc+2Ungy1;U()a9LhXE zYj%+S%53b}1g9wEzMt#3yt zFnH46vmg4T;G||^H=oAT02sKw0ba(-4;a4eAUL^!`2>{qzo+^pK#YO-W+z>HeE% zY^nKp*(Nq*-uS00ddm%LM9|x@a=NpjpVZd#tX1-}9H)E6xE2m3XAGo_Uc6{}rJyH= zFYNKTjF^b-3#YmZRZR6b3)Xdu3i(DYQEz-0(6GYVeJPleVX*p9@6#}!hg}jYN<$zD zh;d1-2~efDlESuW;IHkHyaL1~tSRFK+3`US$|bSv-OSt(uvJjkhGk}@CHcpfvC8FC z(HjNAq7IuzBO1C+V&IN=EvK><2VfZ|1hHhDZj(4VYqXYBDb>@F{k}G*u!zse<)Xmy zxvyx&Hj`W3R;B+rQ(rWur$%b>?=`zE_0R{U58O(J*4CL!06APKAulJoyN7)nn*ga^ z{3gV953!KE%(4kU_O{ezc7G(-gOQX|F4@qK(t+D`7Am08uGET|{IQ7>kwP^H7Vz1T04G%9Ay2G^O8jjR^2A9YnZ1V%lA62?*fIPJg(Yi;X%#uFv0qe|EV9~v zE4BD7Gy9}P)2QD5ku6`&=jMz^V$I`uhJ^-?qxD@H6&ZQ=X-(GCIQXk1H4Cy4$UPYk zgxS~wz8BDa=gP>}e(QtA1a(OPJR~dH=^5MeL8it5G*#~m{9kjJm$9h*q_VL_J8F}R ze!^{7^SaNlPnqiNTd z>z1=Fya5I5`*xiJI}m?vr^v>&NX0x5J6vGd|ayitO&!Xv?SzBX*K z)CC=zSO!Pnz-X+ha4DcJi`p(pVcallBRg}f)=x*YF#{XUb*wUaXQBRFz+2l;7Cu#n z=t<=>9oS0}9~);eY)P>~9j8kf%=g7fBs(=?!HV5j$zU8(d8)mMr8x+(yRR>QqD4k} z^aQ)mTCvbLy-D-~6!Fy*DgHo>BvFSRE#tFHe4%tf_(vz=wPG9+=_N6fd9!ThaZmx) zX2=eD%7%@FZiKhtBYoiutq9?Q2+orWbS;gM{|dV-EoTJWDDP*EfaL?DFBUoJ(F}Q- zJZ3eF=Xdg(?&RGM>jj{b{$4ZqKrfkKUS*^Ma>D4x%Z|}C#PfZ^xA+Y`A%_F}Uc!o#PdI(~iad_z4G|Wb~b^jwR6;ij*M#NL7#scax z?v(Zpp4{takdFbHVgVwX2KnqaV* zt5%%Swz-ddzAig-l2hfL4;VY-{vFmBt^%IIv(UJ1wFVx6X1r)4N zGCq($Y+81|tKr(?5VaRrzpAm(bSHy(ksO$4eGJVG7aD1A&r&(D=g)b7 zkt^Xy1zV=uBQ7o3GX80!NXvO!NY!4?{TDHdcBTo)N9x^Bu^1u@v<6Vr7> zP@XV5CsLBKHXbLXbwyjoiW5^-vuKmHI*8(#Z_-K6!i?VcSBxcSeb^81x>XC8*oq@4 ze{bt@=(4ddu!<1=BPtt+GJKgNGXL!!)Nu>pCFC=LHazx(%+$;( zEL1VYB>kRMz@(GDSpkcp83kuruCmMX$FF;iaP(DB89KAeRzRP?bl?KZPe1K8SOA>E%-e%~G7(B$f2yUOCE2w<+6&&aJpmW_uh6VL4f0k6YzY@% zy(<}>Y(3oR#vo4_CJe&lR!+y-qDp2C2JkK zobOg8CP81QgS(tyPp(c2(5CxvA~DOG3_nq7yBzwqwHN8cuy_M1$&fC^=p+O|A*HKk zu={?_uYnZ2v$rH+YpKlyR3LhSBibH6BLBI8^cXw2?)wwP)cE~ zi>n@_QUbb)1o$!zAX=6t6s0W|m}PFkO5yW}@t3W`1y_hdNg(%<$Dv7-m2+P1e&~hZ zE4J|DV2xRJn2n?6*93*h3--@BJr+l>LJq6Q$`X%Jk6s6lCmy?W#)HD>heg^)R%Oip z<%_G9KS>abCzvJ0?N!fKxKCt3Aq|t#)juX!e;t609C9cy*-A6_(&!wpLmc>00X#Fn z#};Qv4-WeSD$7|T8S@H#mX}tR@p}FyUfQP0Rt7EdyG;aU@N{b5;2Y-ewO&BvK>C&# zGi~_-`QF!4pTHdDSS!tA5dV0SE(!aZqssFWjjgGtK8-u!U!skN$*VN= z9IYxO+}i`bH{S-G;6#;O5jxK}Uuqeay+hRdjXghdLk%-*6f(!4XX_Zy`Gd3-MIhG| zeR2}^h5j1I*~7m?$dnu%1m7htWo9T;#!hAZUWOi0DkGzJiQytjl&F4BaiYd2hhPD8T7{duGUF zEcVPAZ2d1-wpbxdb&1CHM0DdsMUtwxH>dL?#-LjSoCE{IB$J43EyUJ67ZF3Z+CELX zQJKVARd>761%7d*#rxv`FiX3NNm4nXhusuoBk%W&n+>FIClq+#dWLPr2PJm0UnqS+ zv>t#Bj1w+zoGS*w9~io77Hvj=R@*hxg>S}5cJCQa1P#uCy{hlA=aMsnlF(~=eta2T z23b$|zeHD<9JUEe)q-WdPO!i_C0K;xd2Zfq@x<=1pbvGsegHCJ$rYcq%{Db>O z&KmLDwNS=S)Ry=L&;?dNE;#nsPZ|$4-hV@`q1Bd@&p4@5{wZ%53$3{5a zGq<@>Qt~?ngAcg`#)_E=4u@I(UNdYJ3DW&Y*URsj>Pa{g&?;JLTYb;fa4L#SyV8s? zG#R;~5Om2eil;Zt@ed>w#f#-{GouT#D(k@Ez+i~wz>zlWb1>wtlQ+&28Dn10btu39k3G5pL_n5vc#Vj1!$*?K#$Nnn?k|*gE+0d z6l#;uM_Ch-xVg6lo#~u-8Lkw#c@pZOuC z7~)S3$Om+4kC9&@NtTkeoku3(_8%~oeSlZkkoFwcgQ!RH6F?YbJbppzN{`;_48)tt zUi>gsGWtZX=f8{&fpHBdv1*bj=YRo}A(>z;mas7o1MU#G=P745W2KlpopC~ z0^6Ls^~RF$584uUL&=q%3nnMs6EM)2*o5gW*LyupHZ)b798RZqr8DxfNfnBr>y76z z9-@>bzX;+*8}7`ba=UE2u_leq?B?#yn{VzNAmrTlHpJKWK5I`L(q%IB60;DNvS=6j zpHf*^n+HDc3!raK-Vt84Gh?#!GuZaRGnr3xj!T}gPu-;uKG6_FGMv*M_;XTj{Fm~g zKSab+_t6#D&9SOkCN;>ZkhcED1$ngpz%2sHN60d2LwNta2CsTtc9-b{T@$Y{!3V|F z2)~mB883MqW)iE>`Dqj3(|c3vkpimD`BD0819Uw(GHofb$G)chSiT^!Tw#l`R*}Gq z^r&+ok=i}m@A@TYy!9)Z@+VcqqD@9~xjsPX=#`~Jr$UE{3|-)@SxHyDsktW zPbEbIH^3YbKapJp+g?P1T1+U&;;LIKDH7<>iCb6~bn?o+A!!~5=JeLVhsVpZ5?hjP zerQ*P+w_5G{^U#xSL{Wa9ckIs;l{aV%X)oiEx6)h=2BlQ0WyP)7!{Y_7INcJeA5C-3y}PMS#ZUZFoB zf&pNrY9|##zPVb$3#W0Zv`I?wtT?HPy#q{dpPdhvZ!F{_Vx7v}M?0~QuE$6!`>8Uk zcwx#tRm#wxy-E-gd7mBxW?h#DaV(CW0?q*DSzy9E%L}sKCY|A}XwFU=_ei0TbD;an zb3nWp3k`)svx3S7*F@~m%?NU0^L#VB@61w7T&LfCuymnN!&;Yc(KPkIUpbh{@XJf> z=a?g>nL^>Z*;5rQo+%g!Jv`6+jb(qT1Afcu3Ndv^wb21ZoC(#T_ng&*G-uYO+5TRu zOIC;M+(RLiH3x@s;&9s;jsXVgY8k^p#I5~w721tZz5U4OEUOrON{J_=hwr6e4Yfn6 z62>XRC|KXHoxYTuV~G=z(z?^OenD^DV+-}uIjHvT0oG75@LN_Qldm#r*n`x}f>Hux zso-NZ(QM2y$%EjUNOI!*y&TV-m!eKv3DFr}(jS+`m~0KJzWk1!|0q>crHYWQm6e=e zc=(i&bBB_4f@}6?ROg(j7yrP*cl3l21Rt69^*Zg(T3{6 zrt~s;_c95Ut7w3e-J-1fl#=|zG`=H!Q-e-thQj5UoJ6>U(98FL#ZlfQASgscoP!{` z1j9w07^NscuMlLr4*fQjc%9GkfmHq)d=qh`VYb{C!h%YHH_ncYKN0uNxTziyo3G4P zJUc~RH{qq+kUDW&?iTyy8^C8REV-AWmwYqlOVlgudn3Y3Ox7{endAIIL9+O4bZStP z+$rn!grUe0oDvobLeI5te$utJT0c>LS{m09{ua(Qu*#*0zyBe%B?yHAd#e~_YPkQR2 z$wH2uNQ(RoO~xAGj_O5*ma(N=!30oUoIaJSR^DE5cn;3e!|r*6Yn-ufB}a@&?;PKE zbn9L`7%K9%L3^}um_st$y&tPF^g{I_V@>GBE^N-om#LoO!CX=%-%`w}y63COu&n{up#DJ$-4-t7sTC7ytRY31 zg}`ksXa3+sO47L2Y{sb?kQ8?zKQwa=|N054^%6fRr> zd#&DQg8c%XE*`);)HfC)-J%94l-rGShTb$fQcGNllVT(0nDuZRidbW8nmn=AWXp1` zRL!JqU6H!@*OOe&*j9!24egJ`@+nIJyzCLkDFn zFW%I;Z**_pX)m=4Vm-46C|33r-9O8nsq?}7h}XbRa0f+~k@Dd7eA{WN*f($9W${)i zPv@wgiDv(fg9Q)E_wy?nS#*Qld9rLBLo{cpo&IQ~r~Ql_=@t|*#%>L6A6rBDvwGrQ zpBTSo7V{R7jWO8y_*ssZaBX?L(L>?%F9nftmPwg3nfsaO@mhlk(jG>Gcqs`FqiBZ5xG#kAvNRSxHbd53EyMiM2tX%#-{tn!+w5Mf^5Xr% zr0cEjNu^j5Ny%V6&s#4}0Rg*Bj1HhPVDND`tQh)8lOgOJNEiWK&*ymOO7c~k!J}+W z;Eh_doG9d0J4Vrc`DK``Fiz(A2ze4QJ1KOa>*!(h3S$IpUjFH!I(=x0lOSh?d79KK zfM5IG0uqFev^fPue!^@%<)|Hn>4=;40m&WbN#<(5W;%# za(>>G-V|p_Grs9E^KrhXb4`MFG)B$Ee>hy8x*>~7N(ZFzQ&d6;e3h_yB(k9q2j=uN za*oa)ggMNfj1ItCgjW&=-7Q|u~i^X#<0Z zVB?Mi^ds@YHaLH*3{N9XSUSk%SmH2;Xhf_V{y|k2Hk%@t5FU3hkoZUr9A}#u#U6W{ zX^!J7Z@(Bji4v;zg!70-L6M8K>Yl8L~QSeW})R+frjk9FX08pU3#|_Gf!{Su88!#{iz+K>5nQ_z0feM=#ElI zQK7pH(-O)Wx4cwI_V3$6nYSqhSiCRv&;{D=5JM=7d@us|0_qy~i&@|JU8bCIc{B47 zC=seyGqcdKT;P~Do;C^T^0uOK>f6OSeYDPS)qPhLx0?Sb-pzWr+VhLv$90*MLLDm0 zZ=|9Ut29mpZ%AD|pmSlRoMgi#p7Do6iV&gDyElh^hUZt4H`+@n54mTGl`rP(H+#D^ zoIuR^rjB#OTC+Yw4$kCjaIwTWpaapI;7T#6LY->8GMhHgtrD!)p;!jysj&MmP%$s>)UW6dnq0mH$4zs&~^ur}7AN7D8a`J}+IENV3nFO2I* zi{-~@Loki4A|3(_Nq?-{KTX%$Pm1vRLm`yE|D;Si%#Gx?TAY%pUrY+ina+gcyon!z zx#Rh5Esk3EwjX90wmI0Ym@F{qeHFM_WRc~i{)%a<);HC&Yv6idfrpL6b@OvAPY=tb z<(IRmky>Y1w_6waKSISE9X;lDK`C#sK}5AbuyBw~{R$f??QQSCQ@% ztl^tS!>kpcw+tVzb1v$$*ohCiV{(wV1&r~a`&*oNs&XmdgTZ{|nI7D_I{KHclV>3Dxkk zWm4!W6^DlgyvkeI;N5@5pV44Z8dq0Y$&krpa<0bz@>uC|Z;8Sld`0_aW@ZUjH!S@` zz#B!Jr?~w}`{RSlbI(YGEK*1~$l|ouaGB;cmbFDjC;g`Qs^*1*alv*PNrl&Ev z@l7Dfc4+81V4Ujp1=CuhXXXa<#x435%%kae+E2rrvBF%T&?0XKxg@n&Mg6$(q~(P! zqo931&6u$|TTMt3Ka1Qmtl9n0#ibj`ZjyT?`1+zS;@||J4;X0s@SU10(;%EZw(@OV zQ%O3r#HeEOS({QClovM=GC-@jyjnabp&;8T!Qby&20!;g7 zn*8YQopT??jyw(M_!jvj?-joH@3rGHmgXqlk!IQJM=wSE0R9+uy5-!!jy|pM%$j^)M>f zz(hQDA8MYdbRsUe(A2o}3IrwP&NIsElCo~59J%WAF)E20{ko9(dreesqVUlQH&CgD zl&;4)l;6-RWQ;*s2PQ;V*k-l8$|Qa(vcWsZ=N6p_1w=vtGVFXAQWe5B`l-qRQ*8H+ z9pd|f8Dc2>i#N32Z-h)_xZO!hu8Dzvtgrn|vOY-8#*4?EQKyx$XHnP-zw$1gbESP@ zzW>-GmTo*p_=<_UIRo;R{XoOlBl45(XxpMs$S-j}_7@InK~TDd>??iji+jrp12_8c z0}WYrzyg?$pW6Y+53ou+A~art^PYgJE)v`MHZ$sn;mnPw=|b#>nl?puX*Kt6v9lDa zIMHB2F+s`5`Ofy1M<+kM7&}0uZvU6UV>+nvdy7*Qxb(TENg{Zgm~rTXqRVOC?80B~S<$HVa;atB z8pYoiIXoUs>m*y<>d#o`alSRFp@`KDdK*CWJ$aM)mhT|KW_jR7LnFz)iUpu$kGy0X zN_wF}d>1Ji#jl~*rE;X(Y;YvrDfchj1Sp{3rYshS6}EuaiTi#2(v?Pw1M9{=McEi_iR5{EvYA38jwi zops5)mh3G$?`56PI4{Fm~9Wbuo(n=#mbfq+)bzc?%Q?Ufq8e!xo_j_@C zF6(@gEv%o-CrLr8U?$b~q*}QS^~v88nWtEABYw4UKkQTaiJgOFX^Ee(fo^NgBPF>$=DVKg4Pq_34#LaBr-RowI0>*MC!dV-QR9`!!U(zffb@_ot@UL;G? zP32oZteJlC{cXM2UXrgyk!P81f`UMLDb-0fLjN;bevkI=&Kj!-ekXkzivkh zfWxF71c8{|Hw&jb3Y=wfJd&!e!x<0tH`VcQP6fILeK=KP$m|Ey%;S|fQ1J^Nwb4ob zCwgF9bed+yNx*o7(ARzYnVJl+k0I$usw>@V>QIb$&TX$7zC6~P5rXo_(9wO%6w$72 zdJ>cQt7@SLDGYRUecez1bz&b&vlk5!ZZT9#Pg5+qT>(JFbI#7@FVa+X&6viWVNaF1 z&Kk{piKqFs2m~K!%Zy-P0K!Pw=75{zl^9T{JnPb>Fhm@DVg-np)Uc`|J#+jQ-*z7p@2S(e^sKwNMrJ$s1u{Cj);{r1U`|^ z+Uz}ow_Rp29wRJ0^(25~;~@3X45=T)Pi&E%N(WPAMQT$Sx7xdum78OX`8why?lo`_M^Lk(j{b~?vL_k^qaMCV)FQt!bQPH! zTCD&P{%}~+2e7BWe2-BKP!QU{p`aD69vnlPVV6^QuE!249EY8|r!!c##<{uTAqUd* z{|`gw{my3pxA7jO!!EJsElBK5Y~R`|c573Ky;s%t*kU$f&pS3%8q})VEB0#bRYA0& zDXLc8PyT`Ya2>fm*XR8{&+~OQezIluc$?3=jHn-<$YX8d+8|l=tL(2C0H@lq7l^~A zZ<0cYJ4$Deu%?4P1L0=-(SHw;lL8fh=g<7Oa@2@P{=n`7Dz>Vs00^$`Sy()5bd4oW z(Zjyl$R&Xdn?=VzjMsPks zIkddfrdv{tb*ACL3(r2P$2CS%WIQhmNA8X3mb(OIdf4^SiT`km#h*gO*|Zx#U4KcM zly$58*Tfo7+d^7~N0CwLNE?6Z*({0p@x!kw}X1dQ3PqTV+BU_)e>52Gr z`p8xKOPcEmLI*T{bWi0V`nF)2BDUJ*PEZBcRr+S4U{WE8ozY#;R<`R|{$I{8uG@Qt zyel9}W`@O}Fy|e`y$&belA5kqx-j*ucKHOGut1JbDU|_k9?2s9NhKElyYioG4V!Fa zw`oHjHourznUTM9w-=;cHJ1I%Ycp$;%N=D7Pga4D=i9F!xtaX%>(Tx3U-qiKSc9KV zxgqV~&^B~!gL9c!9YrT!vE%=lN7bDPMZ{72y{#$Z@AwC158%@K7SQ{dp{n?G{A|sq zJn!ngEZz*R!YM=Fk0+tUUt~z)YZpiet(d?z80g_U=jJf5=@JdUwvH2u_L=%Q@xC^b zdB)4==n(z%K+7mUx|h$p*5cNakcz>mOa_<` z_WRRMfD(fcXV%@gvpo5%ZB*x_dVd&Q$uL(eg&}aB_kmuivRu8 zWGKv!BOF0gsrmi>NN+p$0%UnX^L@5GKS!fO-%_B3E~VVU8~)-~ZMJ<~?v+Q5CwIlK zCyM-B^12sSv7%N{gs`GGIHOUdfclMw(EnN$FA?r-u6Jo$Oy#<#bjWw;pkQ#CTiYGK zRcj7k+&LjFSK(57a^i#@ZX8Iev(6{CLD8zvc#$`IH-iOwUno?9h|u?<$?z*i~BK5xoHx^ZZj|CoO#09A&dmsT*1L?!Js=Sj;5Ze^lw zt^YmEh1#yg#Q6=w&p`xZ7`G>@yL5dl>EM$2HCUppZa)&+t~$|F|4Z(cD9Ns8=%4@R zZqFN%%-E;(3#f5V0$YxLmOU$8dZ{c&{BS2_;KYZC6|8d*bcmHIHjS{++K8(DtDX}e z+V~t(PEaOi zx**5uSmf!nU4Z*Ng?zhI<8E3HvZGQSBoyKmlw4QBW`Ow>lUTC)vmZX>oiN<3Zp$O; zIh8D@2GxuuS0FKVjOQz0`M;HP?F3rT4P&go3d^#-tJ3ZOLs7kGXWm7dHHwc@J&bBh z3u4jFzVu_O_9GQ37V_&R3K)8&6p~kKEQVRcM6|4eT}JhCRm-F+&jcQNq0oWo4J6Cp zIL?sqGRap;*%T~ZZKy&;xCGO;^P>i{FDi19!}YwbnIf4j&$IOKSO2V4fGY+`rUPs< zTkq6b1<*cg8U!=fJDX`vDrN}$_?XI0ruDExs5u~J5+yuGJ(i(~bO99<3>o?!(`LQ~ zv`n15r;s;&zsFlZqcou{HkFor2qDyWTb%s<@4@b`cP~KVF?7gjsHTm5?6WH|jy~X! zFNaAyhhUobp?yb1Wq~_t9OZ>CI#lE#=ILULDy@+AI&^Wvfe@7L{BIqaOOL6{Y?{o) zO_qf{MsMI3%*H?;Ogd}0AV+71VenHrO z;$B^&!^S<3DBR7crP{+Vj@|lSvoqyQ;e4~fYT;?>Uc*a4FP!CV{7VbNIJgJG9<5c6 zsbYnFE?Vuj5%&&b!oM(?PQ9WhT4A`S!Zm7-B8AR`>WLCpn#7S0XJG)e$OUBmUfJpK zuQ*}Jrtrc3R++~Wxj5ALnwnN)erWKGRB31o)K5Dhb~q~v!1qbXya+457f>n@m0qii zzIRj`Dt^AC{;|bWmGBPRUT|J>k`2W2K6!)aB$+yiVa$1w&cC9-zkp+<#>4uxttD4r zT3bKehI=hRftC%s1G)!6?e6Ha3L)0giS;G(B)#(}WC?t%hPK}7)4`4-+-X}{-EhjJf@4>=>DLO>-XufZ}o$CBT zaQcdI{(XZ+L@-wxZ!5xrNjP4S<*_s=V7&0{pMI?q>!wI_ROUF|K+D_;Jn*G3RQF+1 zz3hia9k%jJs>@#NSL_~0WG}4-nY&eqrv{I)u z1Y$b&evXWtYek0;IS%eR&3{~_?n2yPx6s+ryMMO)E?mu)sNm)0ME0_a|G10SF!*V> z=k{cwKxsdjm#tT4xOAm-I#vn2Kc62X30lne#{b0Oy|XBNKVdHV=3>ddcLFuc zd|gE2o8P364ri%H3G9s)aJbyUjs}uEVjBJidrqKG+$sZFO0v? z1>Y$;WOJN_k-m48G5VPK`?0!5O$^LV6rr2E!;bZ`Bm&x_0=-III&56dzgCGca8CYU znl&h*h*RjsNUYNBeQixl?3IdCsy=th!ShEzZT`gtD)tf!Zk|lF!gmk;c`In!@8LcB z>akB7xAs4ir zPAg_|mW`k%PGJbi0Is=`X-$ z5p2^wL`3mTD5t&1B>b+D!Rmd92qo=EizL+amwEao(#BjUzgt)VP_V$HET3xS_F+wg z%v^i*oKTyL?&kJH4^Lu*MO=nb-&2)oWmEezs=O*r)wF5Rf`L9n&vRnDY44WRUB-}sA|HhxkHlJza;;EwB>fAE0*)_3q&02mLm3H>(QYSbAMccOa z?tD$Yc|1cZs7q)lFZ-N1VADnGv-FlN6Br?@a-WD{lYBuaH9&U5OeQ{*hBH(Ez0~=9 zgX5P5`n8t&Rf~SMu{mnjF`8rA^nGmh28A0_orrgtIp$h_py~rqy8&}yn)$9c>a#0)7Jg4zC%fQ30~`kkhFQR zw@}M}51<2GjXBmRW0&J$q9JnPa&0n0Vx%A5yL)*+AF@n&rcP+c%hps7P+(xFqlQ(R zn0GB)DfktGrP^MC9~UzYTGra6j#B(DSKTd~NB!VVsiN{JJZfN5L6w)zR2S48#ZZat zHA{AGRGvdFlJu=QtoumjoY_PzP-h9s`(=d?*%+yuFFiXh@&~Mv*2}qhsk-}d8=(nZ zUDi^F&I!6u7Oip7$pPcSI;tEp570+1m91;A=`Oz7N(65ajo1>PZC|ncd|CZ9WIP8H z#!$l}EtF?6uqXR=*?!u^h*ViwzqPYkt29ucCY;fIz){KZ<5FZ=KS8oHR+p_$Ti;Sz zuW^S6Dh~RDw13W7&ys{&>xq}CYNz-bf%MF(a8YUpeNpAfdJ-q1Z|057xuY?3s&oWl zcVDEmebjI@=Ba|cR1MO&fQuF%U1i@Pjqb;P;fgf}>v_o5(lrQ)N9T9`reNp)eOP{f zoLuSi&MmWT18Ybx7SE3su<xd#SwL(*rorhplHoB5Ap37DPE^hGs~2=KF(X{lDQ^_G+R-pVu4o!~25A@6|8$@# zEAj>mzw}yvA+)Es?j0m6D?TKlA4Bo5dTBlBOk$v}u!bicv^{ck&-3>3X$uQvFP2Vx zaYXh8#)~)L4arNBj`Vd#()M9Z2*IwdhmpShd#P(h=VUj`_B-*1UAX0#v1;gmQ@*M4 z`?kw>gc0MUI(4L2(E^^|5Z)=ivB^r3d!tdz_fJ@!hUXi}N$gf2|Hje@W@WrG$yHT$ zik-5O*qv3t@W=MjG7wImlr31LvIiw4-;ZZhQY)4uWa;G0=xW*ua@2g9Bg={uOUgKiHx%G|eeAU-om7 zblt63&0)Orux)^Ngv{eDaqSdQ(dkWP2w}5}HxpU%FW(+^F$cV}q2hai zm8O^WNB-V*63S^qhcmj-EZ%eO>j>#LkeWY_CJqo0X&o})6b=lP<3ey|^K!<^c)<>s z=Rydkz2Q|GE<8@cU4SLd?rD^A?**M`7PPXN!6`2~72G1Nm>D8uTpL3H+O8uhx?0d# z`rE{7_Q;#Ml+9uW$qh$GrgJjc&^^C90ny@3dsM#V){kn7vd=B+J7{6Q-dJ?uS%2d* z9gdM$ycylK(_2P|z{`e8^KV=K>yl&s#D=QnIs}y7aIUv6K~AjJA{f{KSqCQOqjfbM zuSl>#eq*M9iXIK-_JA~o3zd;p>c&uKr-g1Vn);(lLDa{bkQoD@1J+#Xs=s32QP;Ox ze%AH(o#;C*L2kMELvag6A${ymta*86eJYP~aYp#5q!IJE;O85|P!2G)%^Zv{?LIpSabS%VE zt#&i2h3kSRY89BiMthnf3zU;X(qiG8{mp-Ey)PgMi~j!R8F~4pttF(o;69Hrp@J8z#q~D zjd`XO>75l$iz#rUoTdxj3VhP67|Y}bQHu40Lw{#3R$fM zOgVQ@n7~F9*YldkE7B*J zG%|bM$X88tTKuKz#24!N51{v974x@DU9c$7Yx!3uXk!MZR|QR|V3+zhIoW^Hm_zD= zAHIm&(nT8?t{wUqEPVa<%&6mo_NDigRo2rm_MR%91WUe4p3^h6`Tc%4A9CF({=}of zmZKDJ%^l#aK2n0_bmxnDo^C2wAuxggqC*1%J{V(DrWfewa8WZ<@qYhK_3uYmdc^e7 z>9vs07Idg^>k~GRgusXMZ4>9Bd+UdfRk0Y0<|kPcrBYv8&cq2@MFF6RuRWZLuHaVt z0QEDSA4YzrWiI~7vdc#U_lN>bIKr*Y1l`_$af$EP)~Qln& zJUeQlnG)*6;Lm5ioqY1ev2n2Lc%VcqS<lr?y zI4BbEQKjndw(h##3fXhC9Yg|N^1Mf%*)EC}T1UHspAkZ-eH8mkp~&3M+H5KN%Bs_c z7ud1-Z7TqB^SqAJ5KG~IK=Y_K1b8q!%!^4;1yKniwZ%eR;eYna6BgP-T zAIc@rnrGnM>I6zC^WqdEWuegx-7q%=&K}CiPz!rp94L+3G!7V31zaMB&2!x`&hHd? zrf!(9NTx|Zn_m_iG>t>w$9u^&bo7RK?7s(nbidRqrETV`-8)ju)1M{tVds9_x211~ z8WLm|4SsgqKgEUND)`+9U#Cel-<{U|$LjceW9p_Xv;Mu?e8CuO&)h(s6;|p>{0J9| z16N?lGIBdC)h{aGMeQl#`9X58FJgFB?_9zW=)*UIq8G05{<`-xLs=$w=&Q^L;x?_$ ze(S-a<{K8lFoaGype!o_(=}K(oq4?b8>!T>e#ho{j)d6{QXIjCQZE7;`Y5WABrnA= z9i{gL2)E1EDYt8SJwBfURjgnKkctC4irGWH{4Y~3OF#c0XS&`S1Ws6xJXHYvQDDvm z-lZL&6}wwKDqzSLe$?i34>zR)gwoB4uSn!zhTn0EJZ`KQ&deUhY{Ec!k`gLNoXzAR z;ny{Q1$^6^-OCB%xBtsl4W_iIYXHKUKMFw1+vt(o%T>J6i}|G@(-{d?*Eu=dcTU_o zUY7Hx)ZrSgHJ(>RTvDREk84!gUhZiJ&5xvbe#Su#+LK|q(q0uj7cO!+0xBFaT236R zJ|7k+*m45^e@wwz-Jh*qCk4=Zro^>D|-We=W9PJEG{=|;3(!SCge z$^A}4nVtxH!lzgx6G7+cOtEMC6t%u`@G4{upIzQ~ATa;e`+O7>_Z#zqHy}^V|Dn=; zPQ#?ZcO|A7X)0Flj(w_Gsk|!PYOe;4VzAdgv4;+9u|cBqCE)}6zC~43t$!)Y^hw1F zgT;SY{QA+~miq_Sxq{pQPfet*)!ky@?zbFGH14ys0cq}JrEM?eJI(@@U5bCW=n@0+ zd!=bsxBAIOHQ{@XlL5qxi+-{-k9uTvg*Rq=3$arwGZLaWkXDiu4^y@v_w0`1`Odg< zV78d>Om}Ca* zpQs>jOf-@m5p_Xo7$u3xkjBa*Ak@BSkP>Nb~RH;juafr)Kv*`A*`#v2IOlCw^r%0b1``VuTRQ#s4&coZPQe zN`d6{#ZU;1YMOQ8Pm07b%Nmc4D6x|$Hf2hm8t_?_RVCR)U^o1ycEWXd6WZp zv1|9)51U(SGJ8ewUmsU$bsk+Y3z0aO^E+7DLmq(dk-a*fGywW+$=1a;2--2mZP$19 zxtaQZzA|29FC%**ywaF>S4m3kG@v5#6{WLqZ|50Ie6p>=(vKc z<&o}Y=5(l9+J6r`yrf4Zl}Y^UuPS@2$U1!Q3~R+8;9ruiubzCQ$O~Vb7GWPRqvNJ- zh0qZd?GnfKQ+ahk%u?v}(!yEOL%Rf8b?wDl&gWO0!WFW*mxkdegm9%O&Vf^#)mzh8 zq+DCRZa&M>w?eA`TU7@23adeIMo6R?bz}s`?T#x(}l0u1g53zd=~O^;D6P zy_#1#sXmHxqm%SyaSPHRYhK_{d9*>|H0Ym^`szy%~9v#K6xV}6HF^Lumf1#j@3~RrL6@OZmnbb zEKP6GpE3`}+r)hbw({o>YfVeKK<@q;w$>l;uakwS2!`?B-|3DJ-T%#96nQx2I)B~* z9M`iM{a>51*dj)eG&R&c6GXh6U1dMCsCbi(9z-dL|MRqLd1H;ie0|rdd{@#~*ir{I901Fb4q!BJ#1HCT=xG zn02k;1&hFHlLWrHE4BX~oW&+!y#${Avr8F&+28q zJC5pms>^Za#wRp;R~(S|_js6bhyB(WM8$;UwY*_3H9fbOC>+7mY31J094|>1B+3@& z`L`~+F%6uZ3{qCe@x2yCtZdSTf^qfc_vE>%A&OI>@N5t$s>J(oC=A-YVAg*HB0=$` z;{T#ZC!|Xg!(PlIh~1z)MF+On22j)_?$^a4l?;$#36B?CVr@Y1}QAttiWJ@M{#D$n_4ZILsvJ%7RAaLa3+VA`}hJP z@RgBEL8DpRoyHK-x2!HLrC?(!b|Fp4peNcfuEVJYpNBVpn2Uv~sFQ1W zYF#L^`RD6$lb5nI6??~>)y~R>v*Z2bGYvCxR^nQuMJFwd_HkOXQWNKMy3r*AJ98S< z|N32X9}P@Osxv?WN_(Zg0;vlRnN!%oaTn5-$Dt6Yae$@#&v@V6fPKKg{o^xaO(CcD z^yf1O)8hlPk`^~7oIy={O6nvtN!_v|CDv`3C3t|FU6Go>21ReqEO!Q%=_PI?w}f02 zf<2=FxhH+epPIF^$vghUDUF@UiKiL3KXeLLOUIiOVN@rL3(j%>=bedxx)GGgf5}G`G7eGDo7!$fDj6hVeVF zLwN->Y$lC+MwP$ro(@~oiTMZH%DA`DN$2iFe24khS~5pJh$s;-LZ6k;y^%@kl~{)! zH^e*(e)w9hBv4;{2N%pCHpLJq8a{Ku(cmU49GpzdF=Sm4zgA=n=3P*@D!>~!pJWSU zG5GuDMEXkX0U4h7$~gU>0R_14@9UAf7G$#cEuKq=Es&yV#l=QHSeskRBy{Xe2M1Hq znz*OoRFMjYF@@OzC_whJyu2K%8T7bUQ5a6<$>EY?z99;OnuqlK{j}@JpgD<2+?mSl zHR0QS6z@siW>utg*#*GRn4{-jpMCt|$wb~9yh8jjap!YGpT@PF5B-Zh$Cs9{SaZZ+ z?f%@Ezc5#Z+bD9*k+$4l)v1H7xxVTwP9}Pp=Nt4r5&J!Hufm8kp~Rg}Tp)t3IV``9 zEt@=Q3QtxJ549_*ik<)M9k(o)^No0tExuAK+xE=n#T+(lH~gJPZ7}xT>+L5deUFCGA^#WSDcN`-@TJk?q<(GF&KYRF5 ztr10Ci|v8Sax}luFRjHw_6gnZmA#a4TQ_RCAimf8fJu5j8PW!g9 zo8!`dqG#gRF2E0h^C~q5d`(eg4zTaxS!)fxd$pFQk9hfy1MS&YAAJ+^h3;10mmd)s zx4fUFp9o2vrzRF!F=W1#(33=cbiasZf$+Duic8ntB@v0*d7zx~AQF%2z-+3%u5h`CAel!tL_w`aNYlc-|>Ocr>4sc#phXEMM&>u2J_KKGTt(!VKwo0@}w2h@Np}fM9;U zfz51^u$qyUGtP^Y^^D)ViqGv@_%kU0(db+P)F;iaN2LXv2?oRVuEgo$j5gqwb#89w zAz*~3I#vpr-hxypx2_b*z>um+=`qoWLx6zgPMamzKP#JZ-vxV8>taOm^5|! z>ITd{0>n(S7HV~`_OzHT)>hPtKHt9~?*%U+DC>w4U-oFOvxm5?M%XEB4%90jv?cQ2 zaJ)EpTv2OTwP<$Sqm{O?q+>N+Ec6lYT@PibsErm9xd0pPYfk#-!K6Rs@U{d@Js=C1jUAYi5-ud%I0KM;0?g_rb#))Fa zGksX160*7~@HKp7Tr=X@$XEW`6$m{9@8o+!tM;T+1Lp0&pdE538med=ARYE+epAc+ zEMTF*yPiv!$2j?0dq#7Z+8X6X2~O_~T4**YhFbPQoR!@?Pc*T2&`zgqx2@wRDZxew zf^Vjw*>)`}okb1AtK59rzpWshry$RvEEN#_E{UAQ7ny>Q8 zbM%%ecsEO$7^Z@HeWuu82^R2J6x=_{xKhkjogD?hH(Pw7y;~V4`<~PTnq;h*Ak3EL zEUnzQ86*<$iPK}?ux?^mp+S`{KAJX1XN8PJGNM@LID1nK@e>#) zGl1=#j&=W~{E%h21Nuw-E|X^3E66yxO>r<4y&>D1IXNmd84-)F6W&AA=PDOz7MUh5 zXcTJB?jy%Ey^CTV;f+;j4}&of2-M})JQ!9%kG3sl88Gt{`u4Q_{0>lEu-Zw!j|uua zNTK0`EcTpkdAMFc&aA&!#1h6=&1&^9)5=92(KYblG^wbO*^026_}$Dg)69sn&DD%T zEv?E`iybrlB1HT`bio_$u5%C$ipmX(JJmi0Nbt3weq`_c_O6`bf!}i0<;Ofe8jlZE zN8v0sboFdvo+1cACE0xA7cOJgHvsgByDiYo!7cgFB}Opx(_IC5i*9FSF_hs;dPXT- zqXavT#eyM!BiHo3F19}WOnuLM#^hI1ZJOTt?iEPqXR-u(aW{_{p=@(fp*Pt`@QX!v zH&rf1({2#;Zw0<+I%xwPI7dT$4PR7358s}*5US{fZ8e7m6qW)BbU5Gfg zdJaB%&Dnnypw7SJKr@O`4UiYK;ppa6da3?+U0_;S$6+@yR9?>QeqwI*R}ds9Q$!9#@)ltOa^kkS|1>zo2q+AW$H?QUD#i9asJtx-nC7(l*& zk1>-dOJk{&GR{w2&Cv0RDHf06AiMxnOjKqpCXGxZ_Qo}$)aU=w*i*E+|9u#~_x2+Z z>nUZ_CoM86ir5@Ns$GDY%c$Dtl7$ojJ%uu#nEx@!_+MqasNJ=|(V7HS{U(~`OzB7V zw@U+q)D8%|LN`LqJs|`_nB{-jqvBJc_tAzU^R6U{b(Xpn6i8O}?C3t{!y1!j3g0c` zXzX4MEoGcX7CT%gK!)tU2g9?*f)@P2JR{-k8yLG?GpEW(&0A#!^;nnTxT!PqBIX&l z)Gkv|-0)6?bQCadzh)te~Q zCjrkw%FEX}7ejo4j^V9-d9)6nJ^V?i*&dvK`QwIaxvp`(+WNC{gr=2-@r!_o@~kLp zVoq(iSUtfHcAIScEvqUDWsdYTzZ4j~a4raD7!{WnFjk|nAG#IIv4wVzJ#J(aFiL}+ zFrE}Uw_0>x-%GX&ozex~Fljhq9mzLyfYDMcCeHAQ)EEgYvBvFSKZW+wIbpH$st4sP#vI)z9 zGfF5vuSrj~?1{|ds}$tI-KdrCSi}P-ez}D!OS)2ZS6QkBMzAlcNMU+3$54bo7QUns zlsM**iLE032tIBKRm8R*j?4Gy>=U^iBXubppF+AhgN;gFoT&@yRw>@VEVo}R#=-8gJ zsX_*C3Q)PCp6Pb%CY5+oV)aV6Sl~nd+(y<0-D^7eB#v<0P(|)2#XANwBOm4c!CV|P zwq1bfgkq>LX;9))Wo)%#h%nzrH_WHgH?lM_H*e#F4h6lNF8Vs%haG=d4gNLI{HpwK z+SI1?Nl}UP>8|6hAH7;d&To9NmDUCmeEx%YX6wF2{k>|3z>B!?R^q?caY}$jW9*?p zF*?*QuL6~W7S~4Mhih`>{@e+vul~YHY0XN4^6MufiAsY3!`>)_kCewtAGrDEorT#oqK7R zFpr*!)a3tTBQb0H0tP!O{)0j z5oeXKFqI&5j%A9#;R|11z~Jn#SbGKn&R~+2IKEqgFCFH$#$AXCGTUF1;6}T@!f;ro z9P6VsscDMO?3^R4QJEJGZZxs=F9}od(0_+mS{3gAqA%y-klZ0o9>Iyphn0^+SO?1Z z<-DFO#--;GY@!Ml%#!8*HQDVAGPYV>kJIfSRXt1ACoU%c=zDxhJvUN_>^XM1(w9vs z3{#;M_}yyIUY`(nEjbd#QH~#z9S>@U42xE|EdB&0u@gd@b@t{yDv@G3gPpq~L6y1y z35~_L6+TlDob@{{)o$GJtJdO*oTV0FibDPDZa{vmi`{CCqW@DX(G*1%Tq(`*zoGbqCizFggoRee-^Hdx8b|B^U0;6)52K0}UqxEkSksbB_ zr8)c9V7~k;eab@DD1FyVw*KOaOPO@y!AtUQ4AsJV=67^WfJkPqlFHy~P-W)!BSqbb ze*P($)Jx$Pak$rzxK9GTYYJLB8e?^FhjXjn#itQh<@MHkH2nSein&L>e0JbJ+$&!h z*Ugu&kRci&{Ofj*pp2im#$gBg_)csJmKj(W<% zFWidTnQ*IuIJe>JE6J2RzNO5}n%$^c2+rI4&5_H*=8@Po@<$S?xHXM7a=Y`Tq`H--Wjym~G{yqWFDX(kJ)5nLfNB+-9g)^x{ zB1DdxPh-(nE+aef-zhxue*L#JUW%iXks2_J=I+Em~t5-Aepak2+YI3@z)~l zax`5#R5*uLzw_I^V^Y0gdf)dXUES;P*Kg1mt=4?<{%O4s=XY)L#21L=h&GKe{mt>V zfs$?g&6p&0@&;WG=%wLT+D%0DFG`>zgvWLF_~b0<=}|gU{EnT3+Q$W$Z$Fv$Gdq-b;-U=Y!v|xha7- zUXu&RB_WH)bj{SD(+g_FZXcfxH^oq+yCUP=LHQECG`F14>}9Vc_gHxCZ&qH0@s|_J zTeoV?1OrSN+O648pB$yb+^5~2O&$TIm-F~0g$`^ZjumFo(3Un&4|zctBp8U6zPvBP zC9hm&P0Nh==WBXx@zMAMykq6lI0MfnN@3Rvk5-Xt2{c6ZJl<9IkbKXTDEqCj9V`Pp z((to{07n`@VqrY6H|SksncV|QaXW6AsLaU%Je21~e2Zd+w`ctl8Wh1%$GV1$#`_C`hirO?}uz`3kRit_{LQSfTdHBK^W82l^ zgWFA*w3Z0|r8L*OzGAdXUc3JO)*KuKFP@GgKjK@vHG=8*0z)7Q<9u}#RdSXc}BX zhg9nM3n+@V5S0fh_QP_%)Tu#bzbkS}iN1y~h+9g`mnsiRWCIsT{Xk|3MaNS;#c@V6+y->ChbW8U0X$S@zvt5+Kv<%RC31@L;ZiH zp(y_U9u(34_rNgB?M3YFmYqh22)rqin0$gI6$uZ^VC`5;J@zp_q@6QIc@;+%v4i=C zkLFkID{_RNuwV032)2(>A$zCV>~f-V@a}|uLK*Y)KL0uOWZh@(82yFBh?aO;U=)&G z)TS8sI-_82;#NRM8O%=%XOFVd0xPMwnL%4td}MOEfZ&mcasX}<-w$IjsgH` zVyim@QUlFC$dcbcaBA|1MoDM}=84gT&TnR0*~Bfz*pwOz=6+=eW|qB#UbGvDa1{Bv z^UdMkq}XSDrcF^wb>+E9{`on}`ESlfN1CT5k9f`=mk(^%M21;Q({tc!vBZw%j8B$b z9iN|^ra!~K4#w_eLY605iIm;Fr+W>B<)SxowJENmNGe_(twIl9zMbmtznEng!`SL2YFOrAeBd;8Ly%rc^{R^#t$WOVC z3%eJQ^%7VJIHakn+-7XnuWJD!e|f%*hi;+bg8VU?G|Mi~>RaiNlZl+l@CyE6X^z+h zIh0eY`5WYBMi)mZzyT1w8%vr!x``%ioF;@wsEO7Vrh9cmUt3XrPiTVp1~zamF|aKS z=>S8cDVFT{Be()VrA=9R#xYM6Y~;=Bu9-t+=6bzo& z0i&`?@L{|9qKGXCuAJ*6EVHGqNDj@oO9NBQ=hn^O9KR4xwTJBn5e*#XO(q6#pPh&@ zigwaP0&2k28G|tYx5Sqw;6Ps-fI`Al(-;V*L4O${W%|(&37EkiVj3(xGmXuiTa%+U z=@8;GULgUO)`$AZI|u8b*e5fzR_}xKJJm*-Nu#$i9JddD0S5Mi>@TG)!X>|n22-l7 zVirUm{`bJ9U_nwM*#thP8<)P9`IG2c=m?$o6yxHn-!#c-x(*9FNGr~9LeCIQ$o0)S zalRZ(J)kCo;<9W`TDvA1Vr?k29>77!-{;p>(#@plZq15ROcC96Ky|WS3(m|i9DYzK zzD2HEQJ18|Tl^WgH=5WQ>KnU`89S^}*Nke>9MTm%I#V&f26P!DFZwN9bE>rYN)2q(e#eh zM!+P&WE>+Gfz);hEj>cIKp`(~0mHGJsV3d>XoGODJU@DR3Umy|L;dW}HIic|~{oFd3314_RmFh*lJcV}svmoYEAW@3Aiqi>_koO5`_OZ?~ zPR3iHHSUwK*43_cvnh^`qJds=afgY2q@Bg2;S0*epc6XZzj>AJ)D4w(??Rkon?937 znPcf#@?+E(o5Zc;_kV9y1xf?(W&t1Ieu!zRoHbkpW^yb*IcP13%+XBBb1jTLwln^m&!$Pj2Bp85_SHLY*k0*@#w zt*B0mx|VW#_wrVH6tYd_H%iq$r@WNL?&8}IqA+6`adjm zI%avIIlpzhTYfkZ7UrbGc!EC}<>++TdAwM&x@GckOgrT@(BRPh%7p4JDbgd42^AgA zoX2AEEQ!MKhFNH}csmT_VChjZ@UNXyj`_>pftE{wn%YZ)uz;?6RT@p5UOKEQ)ico_ zH|zmM{S)u*i%m!Nt#?1!XRRs5y4vNyWk;6Yxf$R6*oMbTsMubo;(@q)4p2ME ztssP#c@$Y8yW2y!ecwr1WZ5Kb>-=y42;oDCkY{z>Tr2Z8Tmpk0QNdWLT+MJgehDfP zLR-;BGm_;snXl~|l$3%H@oTms_hO#QL>q!hn*3^vgg7k-(sSa$x z!$DYDNwq<7@H>!r5&&j;5(4_l-7T&TLx4B}LBOAJdj<^2?>=!(PM|!TN4jN`^rv~^ z(>V|G6ZhdU&b;P_L0~3|s$iZgPp?NNlYk*v*>RA=Q9|lm9Auhem{6E?PmMPwSQF(N z>XEsJiZCyRVt3Evw{lQ>Gl%ilaY#H>Hi(F*ah1suhx7EA&hm&Ef}BLr$> zT`MD{>q9_4p+W(d2Wvuq<*Ep*-V->wH4uLEp_le3=r@E&! z_#{g68OjE}N{f5!wwAm}Dv8-brFUhr%Op<@4E7`vJrxNmHW<&NyQd@b0z`TEYDzj}n{K^Ddl`XQC@DRu7*}&Y%~BgQ zU}dz=f~|2`_JpA|gD`RCC&s4hVK|pw#>La*+_Q;*awLlh|L~yjn={c73N4m~Oho%R zzBpXJ{H3LaaiHi@FGY`88O3maoO!p8YXL|K7g}%8kpl=1!+W5o!kIaRe4vX(_*@M9 z#7g61ATYugK=SX`5J7k0%)&T(pjgD5av571}8R-NI@C z>%&;3A<(D!YGZ9ZJ3-@9Y6sq ztfad6s$3dO$Nw%3AIO8=2<&B3b_`S9i4Q`JC9j+Re?#Z}m-HUC@#iV(9Q0@^uEKE~ zfP1EpTAmcm1?I?ImN;_Y%vEMDoS~ryaD$p#%W=eArlO>v=15E2Xt)Z?$kTD<^Y9OR zU!UK;_kG>(>$>{RZ`cp%d=~p$oo(Cy3oJO{V_X>lLbbEoiS269afCi_=dpIueLsM`(W&MPkFmaL%U^F7vvm5h(R zk{r~&S-Y1@-dj()6jg}2q0}Llj&S0j+BcthMXT+yf1pUqL9Pal%8@f6cKM)R1S!0y zLOHW_`f^#?(tiRKz?YTDp2p&#imjcVehK6bft?UkV{Xb3Y~R|W}IP~soxHY#%_kn8p3ZEyX(RJ$iGTx@xy)s zZa2h5?K=y;&d0|HOm)I@${+8rYz5Ccq0o&=tg0tqt|sBYkG&uX<2J>!Er7#q+eKPP zCdhviR3>beB-hW%F;RBM^{g%vhSeg9IT7k2|LzcG>$1R)#^idy&c}jaV0-TzvDM=3 z2d^ZO7r{BBEj0hqD5SkF=Fqm73*)aMpJE~rbYL$6_o7Wl5B>yaG=Wf% zRGK`MPe09)sh&r1B4?M#`a)LC8Ed*{YqHJHet~Y5X7g^zT&)thl{H&IxDG@Uu2~FG2V78ih&vDNkBUi!>oh>V{}D;T#DhptkNUlQrMV|C5cAP-_Db>Hz1ci;h>>nTi($6e`!MQSJSE z#13{dD^yX2r@yIxuvtT_OfnAIiTr*SIle86~fv*9}oe^w~BEG6C1jl)BDRp1lC43hu0F zwZHZLwVe#v52E=^_kioH?j1=a&M$Eyu-88l(or&(6yJLocv+5Lb2I~99S7nMR5BIL z6{<4C_2G~=AiddX?$Z%D;zP@iTY&FgGIOf8!h9+Mz7gG(%v13LpQf68xt`8un-=brJ~Wxpy{4!&Dbvj>YqJ{c4} zm&0PX2U(p|{ZdX0?y-ytC66Dou2A<~1}g5O+bm>1v$uT^8E@40^x~E6Q@tf7mXKqXL&$NDy-wqH{MZciYuZN%s7wl_ zLBWt0wz=oi`ypo+KV%n}^@ZHZOC&DDaUW!gFR(HVp`5p{`yPTy5I;s|sNq0cYSgL! zySH(e7GC1bof7;3U23Z>EZdoTGvB|lzjcBSiB~D2Kr)u{&=CXeBqr6N#&2o@qB-%BteIl^@~JFu>wg0Ln`mBWBV~AA4ke-IPZvJexu4mQUOOj(Tj|TyCKf zR_@2d^U${r4ewLj*>z9Olzbw0ZU4tDLC2&yY_!_#8QL3ix@lPJY28&pstBL&{p0L| z=fyVYJX7hAU{d8ja80q=Z0{tQm*CIuPKP&F@Qb#0SRKmgR3Uq5I}WN>>000L92j&2 zE##)X3T?~f6)c04qXX~hcUXY)C7^E$zR`_M>xtAmwl`mFJtK^sf<`grCo)g`2tkSRdtcUnn zWBUE~hW8diQ%A*f*%P;OWg{~j^*BZG#5e!ZoC`_oM#S!SWDzXb-zAGsc~nRWOoaOV zlD(p!h#MygWFXL5uFO?|GaDjotuDY8M<=2!^6!Z7k0kaJ55PqR-=T8-mI-q>?IG*_ z^Udcb*dqktC?e)|WI_Ov_CCz9^|o;?(GmG}gjp!YuBoPYu)iltR<8p)=X?AFy=wXD zO2dAhTEh9d70w%UGcm_&fVG3G2%M2rq@Y?8hFq?Am4J(;9U{k(Bg%P=$!S|vRJU^; zo-)e8odvh%^BmgX2KL+WsRK(U(}scH{&4Mv2()R#r_P)ryV;nN#|HlR-9rRl$4Q^4 zKaNgYMHL@_KG>}O0y&WaHDL^jefm^)EvwL$Eb3ptUL|YDE(!AC9b3C3_Yh-;P`!C^ zT)%-C!UJ}3MCCC^eT9|LAB6YcZ%b8=^z&Hpj{yC$S@x4#lc=NXi*!Lzqrt7~FPeE{ zg4Qkn{Lo}zeZn^OIy^~KL0eJL`ZU6=TSSSR$PqPE>sV4*^nF)ydT zM7n+RA&XA}R`jrmM-o(BVnl43#*-9^QbR5-RJW}tf}U1#QqVlVKnm!eAD|5%bc3ZorDG5hX9SEp$w59$vkI!Y7q3^pibC7Pc=0D^;1Q@h>=5{G_}@)d!tR^+0^~PY8Are%Bvf$m_>syp{+c9g)Q4QBmI4d8 zD^+x>4bi!2^fkbyQ?BZdEBYX52a^PtNQ=c|ma+Y`aht57BeeW%sd+`<4ZHu^cAG#+ zahRX}+!7@D!dbtLsReW$_Xla#zJ=uTVPE6+-J#yL3xK)bj#Wn!6g?lp(}?Nusv{LM zZ{mzpIv1~0y72E8eQj4EDhtTo90Nv=pTX; z#&dgaF~6{%ze5<*#-EL+O`9YY3PEATza7O<{J+Aveu$Fp&a*C zqRw=_E;;i|ZMXi|<4ceEqoL9eccS@?XF?<|Dn3xLk~H=+LaYZiD_4EeZhbl5-xkS3 zO@C9h!&h8x(aP6g?A*DLV+A~9VK-4e<9o283Jn3UkrFOSOS&%OdqYQ!!h6>smODTl z@=So5@1b7Y>SRsylB(qCh?rvVF(ki?v03~0A`(OLDutrzwe!b|u|Ed-0ITV&m*s%^ zjX0BG+l`!$kAeN$j-i0D5q~CnixZKuwq4{@`B-qqI{@dS_u+=;&!apIzE*eK_ytFg z#E+bby}$`~0kxL_4t&pG!_COz$I6esSJC0~sUgu0efV1Q+H2CISdgyXbN2^$M`jNu zdZ`!xMr2Y4A5tw44(b)V{lXBLUMEJH zI~%-MBE5xqTJ&8d<69AUr5{kco)hv~)4Wul>|J_6ZO<+j8L8ysURgrY3@ZcXu;uVbTutW-ejK@rDRas*@PZ`#>Rpvve5=(s1URi_)~M4Y>v7p8*?- z2~yq%Dq`?fJI1l>4%%H%15o>(nwu^LEf$$Dh75}&a*~PpI90)UO^z(+tY916AAHw3 zgMO%lR?Eh2^C&pi#udO3#)fM4UI%iGhn8sY5o>FQ?ud-V17;gfQOE-+fwwg82vGnN z6|1;S;$TNJN+xlp_(pbK(3xP}L*n%rSNL|6xmpd!aS7Oe4f*v1KLoBP1riGu{IGZC zbqrM)z4)=?s!(lwm&cpJa4*QN8{^YCScpR&A|d62F45ZRam$&g@NwhyIJl4OrV03F z5#teI7v-4?$8z>UXS$J!+#-wP?F{Y--Q6k%su(040Rw zG{xDd=&i5l%#nM9T;DDtkoU9EC;2ki`_UBkLRa3ETVJ2OyKYpxcefbimJ=5v;{P4wW$#H82H4+k6v+uC<^5^xp zD)-29H+59)#G{@7wAs7=o(a)@DB)@|Yus`y_u(SetWw7O%vjnl!la9bU?Ma|y+YP7 zB0XD}-*FX$?z(-A5N1;+(t*Hk0Pzavv*vcPAW>7LG3J&JPRH1k$eD_0eo*XdDLW5X zhjMro*SRT&#wiYYT(PPxYS!Xl09=&1Z&PoSga);d3Fk z;!QFu(*XY3GK!)Zu0r$il4lIZY5lbjMHj>X2`~}fDE4vAzk9=C z@GoC2D9F_*V|)5BPrYsvRx!^Ix3eVTxvxfvb*EEH$*lIL|6U~sNcurcG{i^r<@H-{ zrlnH+rUXU8&V(2OhBHus$ORdZ*M2eDC!lj&C-q*^8skK^BPDbnW~XNgEAsXG zqJ^?DHDRwnIC%nP%G^w%kT`~Hq&5T7>NqhbpczLdmBb%j0jvjb9f5UPWoe58D=RPv z14K<`ec^iX;TnwfbtkiV-CtGOl_6o_!H1xISa>=%p1Gseq7no8>G%sO37=O-anY>= zkVmua;V&sTEktfiT|lv^WR_S=@>XK}KeD1~xo$fnxb}i`eWI=>*+4?yV_w;IF{S5O;X$^Xu!SJE))4fIk?V!tctMW&dTOSINW}mld z^K_u}3i>zT8`098q}<3hcGCjpfH&IjzZDIyDQ|rwXuRL<&QcWD@PDVd-Ftc?qY7H^ zJf0akFma8Q(E{jG2PT*?;=(JxYp`t9-7iW`sIAB_^IT5e>L8@FV^zISarej-uEmgd z_XjHlYdbGQ+@r-W#OxB?rH6YnuWLJBqOYjQQZOREE{Z3m*NG1QM>H;R3UpV!(=8(h zT<%xhT?~VWfJvX$ zSL&wFTgi5llPR-}PKiIQjI!iS0yR?wA;*KB_fw@0nMMzqq z$7|mf(r59D4L==J3h(5UEdjfa;a;uxpl@=(jnNj63g4eFiWyxuVLR~&v$~WC$L^hJKLXvtQUJYE^xAlf$MT|y7mVxu7`{N;9xm%(a{3i zI4!XR?8zs^Xi*v;WCuV4%ubO@L1+Kv6^AE_QQOs{)0&c588gVCjR9H}4~4wDdX%wd zABH~WI@p(0EwcIa|j!LWbGDy}LHpX0WW|m}i z97E%YeYY>GZOv%JRD4_}jHI{r-KIqtsUGq*o%G;*2W7W^@Kjn7X(f@4h%^?kY!?cu z%QUB!611qFB*TMSU*9UIm7D%z28t=ShzBOXe2GDQ=U3(i7Gfs>UA-!P?S2a9|9-Kb zvF75_J+IdrHL#?sGRk{Usfnw021ozI6Z|vrtQ8ag217L`!=G;Q57Z*ANJr@7A=m6F zCET|Vhxud;tFm{0EohIe-#~L>-exJ?_ytOh4<(JQq_4D_yjJj4DQL=h>DN?URAq-y zN`&6>Pl(?$x_R%9kS$(_lqM%>ds!V*>F0VR{qe1S_yOq9Lisxp^D8hm+4gBm6S&o7 zchBT$wN;#20yu;E_t*!U>5o@MvU+W!wpz_!q(0abrjpe!*MPnZ{2WD4@AAQY^X&^v zQc25v+U`^{!ZHe4&U~qzs7E*vwg`zNMpUbnG-oH{*MgKouE=YJdc&<$#4D0N6_mFctedqIW{acg8<7R@(M)4i=0RQ&3;WI2&A_!M&4 z7x#$|zvd*iVeUT%n));(@BP~a;mZ8l02lE6qP$ORYBPJr0n4aQVi^#0tn=pRUo)aS z_*V)|*_4n~%QyAg?b{_k?>MW)Oz5V@`&=O8)|_>n2iai9;ZF2;p?53zB>eY&n(f_^ zU~b%M?^NqFJY9vLg6nqe-8bv;JqYq!3*wt5G0Ekvs~sniIYr7}AA+$xuDYFuF2Yj_^-NMx377F!RV|`OUvCq@rd5ob2{AZP z@TEaN)k3hMYnXwGs01ih(oRsjBve%S_v?v2-DVG=pQ~&(BF)xFt%~P-+3`C5Z{V;H zbH9v*BO?MelJ^?fD{>w)Vh2V{58>(tpsNS9mf&RRaySqYy1q_|cyZgWD7yb(My}Lk;zr z3*l~GNG0IDVjcOgVlM$8v>IShX;1G;3E3qJIPxc$XtiQ*=yJjosrkv7M8X=Suq}Z& zq7pCrOQPKRzzf`5r#d@6In@wnA6{75cFoSQSkm&oCrf&(ci< z4`21W>)$icV2PnkqIKT9#F|*=VAhH`3U>7_7CES8i1^yp6~GRU1QG ziARq1yij`4fS0s*H?6>0+}>o&zO=9C&evYXyEYm|4?dFuz7>{I3Hbx=(H1qZ@`u}j z;sX2K7+IzUVbJsnR5mLy$8``C6#d3(_Q@iHW_}j3t&1+Quvxv?0ky77Lx2eP8f=%6`vTWUS0W3K%;loz>ULc zJ(n`)*lWExBvHBX4?yumOoB#%OQGzgv8XfEb(by$!x4rdjrfQ&U1#i~*F@&60S(pw cT)j0v+wDX|a}auX_Lr?}7;EC8`Tx!TKjD4p^Z)<= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d24b68518dccb25720b620ad01f2b5a488bf727b GIT binary patch literal 56962 zcmb5VcUTi|&;_~)p#_j6K2pC?KFB7OKEq{J!tG_uspaeUjbm&YU@CX5M{wb1-@E0YI1-7#jc(2m~+! z|A2!TKo4MnF|#nkSXh`@SXo)v;0QRJogL15gqst=&nqCn&x=M2iDJZrgr!8#XmOml zl*}9^r-O!_u{GpaToJE6D#nu6xCnzZ<~6RgH1N`w zv5T`4w8^!k7s=4w4Xkr5(Nc1Ru$eW56kRJC$*DLC=aoT1XAp~DW`H;dK1hNE%#zn*%CaK8+%D$u?1@>{d5JPb zaCe(5X|g^Yw2{bX<%5tUV<^0M2wJkg4nqalZP2{z7ElX_lPsJWz@}R;Y8qjq{cvoJ zJ~K%kj;lsh^)sVJyNONAI3MOn%fpy~K_FV|3;I}t5%X~;(u~V;EoF-Cx@R1s$)aST zd?*;C3tpc_m$XEaY5LRzG8_*8P-a*mxK3hDg1DP7qN)a%W$UmsK5RcPV+LtrDO*Jx zucBuVOX2mp#Q(ALk08M+_X>TKkF=e9O9&SRG$2B5NEO7dAL`!)j(JE3t(#etoNhIr+ zd%Ib6OVjj`&LALn8w?09&(=COPcV6nL@1do4r&g- zRRM*}CWY8a(3EVMNfigSs$U*wQY}v`re|0U5bg9)nFBU_?D$d;7w&(s0qlo?Bk>;y|${V33Aq;VedA1cc%Yg(qI zOtI;*hdU^XEJ2DJ2#=CWq?18$WYIBHG8G0$S^)BF@Iq`d2Z#$>Q=G&YG^t`F@nNfy znMfS6g@ugHsG35c5MPaE2Ia>Kz`(o$5dmy48+}1bw6vT|nQ=v?mj@WeEVMMu-95wB z!^-23!Q4u>ync+Wtt7>bte?OKNTR54d=(s31Fedb$JW%FRO1>=N*jtZYf19eve*Gx z-ZDH~&KXaUbFu&cI3a^@7(=HbGypfoBZ&a0IAWeO&G;~<+-;3PX;8?C?wLN)ugi>$ zF?3_&3^y1Wuj@j_Af4ElX@wSYa)d#wwN=%CJg!Q<)J7gPT9sK9)o+1KMw-Y1Bs|2~ zi5Dymvl4q$!rpGR4M~9(H{)Wtku*iVP#uhmu>AYwPZw zNlvuENYf#1WC+Bn2up((@p6%{$-ayf*>RI`MpX@?3Vc;qS7Wn)1~{}IV2({PDFvYE z@=?4M%&7J{G&H>unv^ap4i+hZMo6bX(Co%pS!gK@y0L*rSth8CI~s`?x<1{*=buBIhISOU=n4@a`UnDF`#TE>g6iNNwl{Y`oUK?t0RRg9AJQZW3chH6hkPaSRjb(#!$rH^!>C{;tdcnd`XRaRv!R z!e!QEb`knyV;Joeg10T1Vq@$_E2Gox11i03>2`TpwzmJ-PPy~hT2Sy7Y}oM%kPa&x z4r1(ds018`sOJ0pN8M*;r-F6#SHj{K!gOjv+lt0_PF>wP01SV$yl)6<^Z2tgDps(Z zhQ|Y7FadyWRZM1gf{`(F3>YXEV_UZbIvAiSeo&ZTaduCa@v#EslR;#FNg!uci~6F^ zsAUYj!wMWp1R>^$KTdpJ8ATL@nOG?54~;HY8(E!xl0;)2V!ET zTHulHi9|~bLORRpuqI?BVzMxF<+JLbjzRY_DQ+-Kh70I@RKFay-@4it5u=4m}jN`jo|y5KQ{{SpMu2! zU_RomPF2L+RH4^>Cok-TaZ{I8n||h7u1xKEi?q@KG#E5COG_7EkjH!BLkFh{j-1u}eO6=jQ`MeBPtC^C9#-=j037A{J|`J?Mz3H?dE*cJ3CF3?@p{k; zG$=(0JSNdSrU;V=mJwTz;(r05OVdH>Ojp1}$53*O*_~)?Cb+tB`Fi={|IzkkmILV3`A`b{xc=9%&qy}h0bsTsW@I}+bCpyvDZQWgTt;nE!=+a;rrntQ>%cOYNN`uW> z8g1-^M$t%CDFjgChiv&)`;J%ts|sMq&Y6JCfYHxvhA6_ufKK7W3VL?8*Di`~1hcNb zEvVg_Rc^Vu%9>`&x-=fltvojH_Q{dR_uC<%IsrWoQ~EX{;)kB?zvJ#MXuEXv;tfA7 z9o@BaUqc_J4Q>>?Z$8Sj@a^iah=GxP(&;q(^1HQHI~Ph4=L2qjyKu4l)$-8W;MD8y zwPUQoG7J)BQ4xt_cfw>iGv~5{Ds=fD_7C#{e49XF%OIkG$`jN#fnXAVIQqw{%da28 zIdNFzYCUS}(f!5!%z(Tu9syFuZkV{?_;hM>;kV?634rWceWR!HXP8?Nb8O^~{R?-; zxm5rlG{X0x@*RK*09J<%kjsb=2o5zM*_GK(q7nX)Oz68$l73PT@?DAO!RFp)sbHQ(4OfvADMc5X7Z2g#;=kIXV+kcKa9srjFp8{ks->f>xNGCc}9K004BuIn( z?a=kZQh=cothg0uQJ5%-OFvE?>057A%cyel|R>-YLv*ihM~dH5_(>+f?FA?FiAE}rG}__pxApl9RCz)A^AQFPi*$4f1% zAD7NFeij+bFU&p)$_Xl4S7%fQ!4c(fMka@?Fj5-Vhl{DdPwZdp0t*I_Q3&qRkBUn4 zv1yVzuj-e0<9{l}yA}I@^XJvTn(=Pghg@k}hNa*uNf2i*C;-_G-S>Rk!#}Cj(ao}CSM=y|@?Rv5539idb3YI}G7>G^Yes!JOW z48EQIy+3!V-~U$7P4hqV`>{jsJy-5O`uXu~ci73wADYU$SI#bLKgxdoU}xhfN3)K_ zZVPK@UrBiI%d30aH}h9N&s7h-xBL>?!(EaV`rYx^`^Ue(y*T9r5U4;FCJMmCun}=F zMkd7i6OLAknFEU?c3eHGt6rRe6K6o23a#w2Y@FS}wqG+k<=)bNYk~&WM=*=PAwaXL zUqTZ)p~6ADQlAR#u5@C*e|x&3*o@sr(d!Pu>>Ren=eD1#vTb4Vc6icj+TS)jTZ*0r zRyGL=kmQ_zEL1xl1&@?~F?vZzTBIRF<9h!eO5QyFHL1>g`- z?cy8=1d&mMr~sPJogEK7wDfO*#^5+`09hAKh1G$E5JVgi!5|^;;-u?h zG6y8&VCnTRYUTj6s}2EPT-b29I2fB@Z0Pb?wv<3{R0ZnB;lh+(?8IOS#@@x=X_R7% zp2S+Z(+E+8g=B~=(%A=ENGL2sRZ13C60ngtEHf2hMFzydCW4cLKwyL}JQzPXkw^l~ zqEKD97}Nl&3kfZRp;58WNi@aQY7(q+W%Nl0a>|8?L;ApOcW5oZy@>;~SmA&u#-ge4 zsPsax2Ju=`8FU!Vf&;(WGr^_jFV7EXfL6& z0}#jC&}h79DI@zswqlz>NQZ_JCI;mm%7*|X>;SEXXbxLhQazG0!z}W!t3M{IKXhKiG zL7AyUAPI>hbVy)9h2x2J88{OAAxcyXqXWsof$D500T2MJ89-sBGdSPkBV}w`IFT3# z3WGkGA^~PxS&A~40!Aue(v@NPjgq3GihSTSG8UdtS``H%f=3mSVOTg7q3ec~%|aju za)^3dJwg{(mjNq%LPB-mp49201`sfO1%QCb;sNG*3qGepiaSL=3uFkCezTf<;`M8) z&Y~?vVZ6pg4hm|Y?2i0VISOpf{fdvk25sC*86*xk17ZiptL9*4Q z*W-`~X-21YLMakXLgEUI00aaas}}Mi@#r!%v$0>auI*uklm|O6zpdmd228X;tkt_I zAAVz(BELbbr3;8neh{l?ghhj(z`3%7imI48G{_lF*BNI*fyanTc5p~`KoLnq5{D#= z(E+Q66(MMZt~!t|JO>m!1i;xq@Ni6}eryxD0!(X)4|>X_2!s~k-mC)B6*cLgObYmC zk4xDkw$e?)$I_{i3txeS!lR0CvQdTgaB55?JQ;?I>SgreNMKGs!Qmyl>Ll2T5O@@> z5Qc~7LZiSb1c1*8tUnfTx*e@TmOf=oJ5)onN)x7%6yRQI`z}R%)cAUUN$i+Pv*f$U z3OgDGCLi^mNmL=jsu!fJ%gpGwS`DFy4Gdx@1cU`ab!MPC(}~0iRKEle%fN`kaNv9^ zsv^;X*94roAluO(PQ_@OI{DaA6)>GvWk=DIDxf+|N6YL$seqwFze|yn<8Z39xu%N2 zN*0ojtJF+0ENJ2ty6`9l7TZfgRMbUvkr4IRA|yN+!Gpf3ujIQ zNoYbBj)G+*KtF0S+jO)L3}!T_2D?F75JPD z2(~&Z0#E2n&}B}Bf!f4dfU*OM#IXZlrLls^vg08ncovGr!3!<&OQ?_sdpI0S+5i)} zU9=UKc!h(cEWZkvL$6~q{04zaj%)ILSacEY&<3NhCYGA4nl4_?0mGE2!T=;r9)<^K z$p~lyIOf3?Ct85h<_ZXKnjFFeVPSznnf^5fCI}S946q=OC>~xuapkKDib^V~P7+e+ zc4>?*IGN4{&c8#Lp$CAzl6!@2?BmS8>w-a=6yM%vy>Qju#a%H!w?`M{1DCpj5=PS| zXZjpsu4uy^X6_0HnLp(|?N}mtQtqpJ+IuT&RNn#c332Ph3cKCkM{$%<-Lj93*>)i< ztde1m>gnFyad&;x(zN53a&fN9ym%tR!BW@irkCR~%#eaSYf{TKZ{sZCOG* z4S0S3c+<2T4JP5tm!195H}uNbSND=(x}a%zzvE`l^3jf42*>VeJ4qS6+s4;UREk}Y zjnnIymNuNVb9i&6#lVKV=_@>K5Q0>?>QXu*W&GaMcJp+stBER8mCWa*XAi--xGlQH zl!i3*x%y8WqdV}pMZu(tc8TxqO&va)^M0Bf&!_xkPM0RP3*|yvw9wybK3@IyVT!J% zpD8D`!Pf0$=&b&;FD2pTDL+x+|00(fQ|4CxX1xA^dhY7MF)3W4^>f~;b^EA*)WY)a z=@8rar|nz(kgxJe8AWmfXZf}z(`(yLPyEaXKI?Sa{KmNSN_NKGw^AQg-UPn;483tq zEx@wxtMrE!n?CQJsSY2;z=ze71NbUJQa^v-tq8fVg8h+f+-H0afT{n)4TA1xbA3J5 zXgRpW-}bZds43K#Ny|*=3X12F%+2-?Cf~WLl~Vz%AtQf?W7nQeoWHl=rDt6&(-ZKt zRLHOG?N_F`5r#u{klzJQgS2OnH`|NO1Ow6^a5-OQ?z8?TrL-JnU?45cq zOn zK`XWYi00yVYajGW+4ZqfQhzTyQs-*snModOib#;!>9j~Ie8E7-#K-Li8c&DP>+!zw z3g`KJC7k4??uaUB+RJDqW8BednP-BuOn)TgeppxZc0F+>F-N1%5-!(%3*D*k>p3a= zcEzeCKI!BvpSt0(BV*qdujGC+Z2TR0zB~2wi(h%-Tv3s{m$!~3F@5-;FSP&t;yLrM z_l(#-DoeB5HkQZ@MZx%|LKxy(QxEKVOnt&D?HL~IF%1_|jzEQd&$WS0H@T15Pg2D{ zUY7h%FmSVPl5WXT_LSq=^NF9Bue-&H`x<}O-!5FgyU^E=_r>_rqao37+rGfZ2S79b z_*mP`1=`U$t(I%B;PU6e(qVO&b2QUl)YHPEw|UpL1RfUv=@V(T8tl8TE--2-vDu>Q zCMVCkTB%N2J|;f+OCJ+wA-OTH_rKOCNs#hp>6Ct$`s=*yTwG(#*_q@O@1du^PWFZ0 zy0r9!CrXz!nD(Z`{!Uk1e4hXNk*3l=`YUU}+t`S{E0;K0na{-*-^~ut1gr?(5_coR z%rrN{Z-&`{N47ego^$(p0FcO3tWJzW{Q zs6Du{+;+}0Ot5@ErSVwB$@3v?edqQ`eXbH2^PgRxtCxsoG94OF-wdH8NZ3w^Fq&>o zls^x-J24hs{(2|CY){zdVr}({j9|0!?53=<+wyatT|GqTJm`k-qXx3?&z)+yyYaJ? zMRngcp+bG;xt|HH#nzsrc_a^EZ466NJov}!Mc*5Gu zxn!X=*}_dYXVU$vbesj}-3ya91|Hz=c@!oky(+ms5aE|&1EZY#Y<&4e9bLuVILG^# z+O+>Y!@U39%Kn{axtxBkEJIJoEGAL@rTVMu?imBv_JN}!l|6Ur64uY;d1HJxRBqoV zdwqYo5!1gDf4q_Bbh^MbuJjc*HNDx>xb`sw1n-i zJaI7?OLED)IruKx8S&07%jX#0<&(~2R24GhmElk8me+#!q)vM{y~8dmuCwJkcdLIx zWWM|wz8vxd9zEbZc;%VncY_;9t}YjYVe3V{UY6S zsli}0{9}PB%gbL$Qr6GRlSLa%{N|OHsgrXoFh@-v+Dw<^{4gr+lKe0M2Led5+BI>r zQ-u9a*w?X)_+PF-vY25I=Kn8OCZO+4D0+UABW`tPw>|16NLH_RQaZYx<$UtYr81yB$P>E%rLwGgtao43%Z(3tLO- zF=W?@sH*TaR#r*tJ$+7Xv5J{k_FfT@M6=n@&sVl-HkW@DN>Mcfs_G0(>jUqZ&TC(v zddl_=9m=UfJ_3I&yj2aKU7~n8cv3t?DL39c?ccH&n?YR7N|VY_C^6v-_x>9O>EF1& zCzSS}LAv0zPMRXBRH-8_p(^BMg{ybcTUQN}7hVP!uapxOt;NjSdUlb0P@RT4Z0cxX z?Kj-WRD{C*nN+bkhlRdWeyxgc@;_b7l3V7K+UruQp%2@IXC*O+aaSU+-`70RT!s3mm;ML0~Mb zEG+*k0`NfxCL{tSp$HR~)V1{Iky28&a=xnIbS*NYgO}ivSyZpW98laoa_)aeax6OF zNUk`?ZmxcxBA;BJCjNA@#;tXo*-WCDJHahFPggkMH~vLQo^nXs-epCR>AVf0h8u^X)xGM)a)tJyj=ZPO4Yn89C^h|e08BG{lmRJ=EcVrF-~{>dYie1MeB{|FMocKZWf!n zxa2SQqpwY>{Xw^Q$)uNWjrrm!Ug2Z52Y^;9%ZXq}Nw9w2iGfi2{>s;HSdY}+z1+~F z$uxPB&!}M#ldZvm#g9YQXO3BRSUasxUn#s8e;XzE_zHF-b2YkC*}>ZBsOCh+y|{wo zfkDr3M|+HKyB+m*EzVbKDS_9fFN#T4b96<=>8lR8-t7|oXnl*x4!+)4^P{OZcmC?p zzNM}=cO@cN?F|9Lg2G3)gF|Eat(RE0YejUR7{Bo6eQn>kTTtdk56##gI?E_$g)xVw zeQmJe@7m}V!$g0~Fvfk>2v%mO$L(k?BBZ3!>J`89+yBM!l;}uCpVZThoxgow^P(vU ze?FmPCOW?G+|Q~9tfKe>fQd%_)>F5ZT^E|Ba*5fbz&Jdp;9+U1W}fx;UrfWOrN^oz z!RFC=i9@Zi3&xt}stXlcLyNrnj7{^Yi0nM(Qp7cl10afe06aGQ{Yd0}YRRYmAH|A8 zT&^e+rL3L*ASSE-`TT zC6I#d=-<+wif$Pb-ZzYD+1eqWLe%>^@9AE@a_Nbvfq$z9{Pt++gWRgMp06CDa-TkS zIA**Kcznm6yQA_vf;DgLWa#PYJl+#bFD(NGmmP9HuHDXflO2l;k`FNmtW%77sLm|H z(0(e@Y*7C+>!Us9$868}n#B0e)lBbso>%(@uKU{)Uq)C}<`!7LRbl%edUv}(wOQwz zPxnv0J7AOg+O~-+*rl`R{<7ye9l%E7$i-3ziJL`pX5HuX;#Nc59Y>b} zQ$L_z$uIhE4o7DYB|~u<3y=7`UtyaJBRei6SVV-ss7Z`jFRpg>3tiuE@HO+FubQ;K zs1>$#Q}tNKm+B{#XSko&OAj;O9@MILFum+Q|CMJ4f3i6?OC9Ioz2)y<>fV!FXT=UY zo8zdx@ixYzr9xlF(*A1QPZewLmIHvjxi+I_5gh%-_sSeeDVFpKn$|1py2??V^8HMZ zgh-0lB}Vz`g)t5VmBE`_I}bm$Jqh%XX|QUny4@JrG4a3KY(~|K9*=Q{%nlm=2>VmO zx7yrXyrE^p;zCpN7CSB`y4oCzP5u;=&NU@`EFh%Evew{y%j(%h&9@<#U$ zGm%r}#%&RrOkK@hawaEIO%H&Exf^#xUT&_izX~e38)*@+Yv(ikc8hABs@s5Qotle` zOaX3pmZn^a&#?-0nEi^#Huf{_i@#O^czw`byQy*jM6W&c9X(^0derCiCQq6)lRnVj zM-H;4C??a#$=o3;bG;qVC7*p6#rP$$>rvCH{@*mIX0FUa?^^%A2R+!* zf4JcGx4Z7z|7-6&KlKvzim5xqR83d1gkag!5 zx;-?B`FskaZypOeOIXTNaWUbC{HU+~aRA)ad|!#yvU~xp_`W>*=%i|V>-XSx729^v z*!3IJ_p)`_t96tgKWOWmIr{64zHin&dwWu<86WXM!@kFi)a;Wc)4|V7mmGB@>g%7h zo4k{~Iq4iZ;otFE_zp+g=?cq%`=jxO$IoW@Hy6eH(Nms3dv9q9&4qb#ThwV};dE-p zZpbVyWw6L0V^@m>J@TJMe$d0seTnM~?RSHjZOIj78BZFV9`=6AXX0R*BwaXFDXgld zuMef`-nF-Q*gB&nBXoOxF44cA34fR`R+)N&Z+U#rkWcvYW1+WDfAgCVo9k;OL8my_ZhyVs zb$rg|ytL#JARibYJnpCae(myc#{1%4pU-EKInl=@JV?O5HRDV&(k6X}lG)B>R^IT7 z!wu5kuU+)+WjWzhY%cfCH8F5pa2y;LfU`JEFmQzazr%O{i4a%Nl~BZ6`dc^`MP}6h z=ondd`uKknIyw@m*0Gw_uN{xQv@N5V+6zFe#^sq5h8<(8*>VJ??TNVJTRbM?c9IWx z<)w~yb(ebQ$s2WKEPCmnrq8jXJ1JaIut+sg-D)N;G4k zj;mZz)~#+RzcI}v|G_!f)U-1|V%)A;PgW($QhNc}V6-NiQ9Le`lR1|2Ubg_HYP!?_ zJE`M!5hm+O#N%xdylb)#M;n@7Mw@%loJNIY`0c-NMqMgVD7#e~)4_Y}g%ylbJ*!F$ z#qgeHXRJu1&5j9-$w*Awd`a;>u_(07;A3yShV!sTlcP%PP={`^{Dcz`8t?H-n<)w^$*PB*Fd%0MN z1nNYRtHo$eqL7~HNIy$!1!wuZKa%jr(4k>7r<&NE!Pe_7__$n_{p3F3Prq#EX#N<* zQIDg~%ki!jk-}McYz9&N*WcFUGB{w!Yh3K=f}5%$s+N6;eTz4GEu+eIYMkm*(& zIV!~xo~3+#6Y_Sy{qO@`LXNbTM%JQ=jr5TQ^OQoLq!$g_5=f5j^zrsc9=tNz!Sf^v0M z%l>w~_7;-75ud}}jb4^v4Q_{Q|MYTeVIAR+KcZo5-|rLg9ia)S`=eTzsm>#BJ) z;;-}jIG^L-lVg_`rUtXO+!NM3D5>9nKwJ|@5>I3~FNzNp>NEZN95y!(Gv*!-g65FMEaids^JgKZ9<$E4n4+3|7<_fVJh`j4s0XWNG! zdO?ytWuBW=(j5S1**$pvCUpzlyW6xV0GYIWCSNOCe48yYUY%GX5&w$I?}J=wRoF$& zAg-_4>!Xc=9#M z5O5X!{puWh<1i-fG)Hkq_LhZa*4^*sYp(C`+w)?a1HTzO5LH&Pd;dd)XOYZ-l2Tqt zp}iVB!d^WGK+MnmwE~%S0ZQc1{kGo1i9kU_LgmS$_^%-^l=UF1JPemRE{oV9&yD$f zPUspZwqvj-9mjjLJ2`p;uAn?i3H5=#c8a%!1YR?RcE_FjD=bxvB4@fB(|n8Xy+tPo z4spTq#YtB!Vm5oK6RpjE67sX8K(Beer^|DW^!*lYQRuH&H)&LoR0+3T z*RsluCch3(&?#t=wcKTi`>R*Sw4Pyj`i3HYXLiiQ zYf8|Rb4X8+2rJet5e4F#PG|yOeqQEOj-rkUaMg|Ohz=9hLm__st;7F8af#87msuhc zD_=$7V~9i;v*n2600KXj%>Ck+Y{P7CoKB3zhtEn(UP>UNItd>i3lf39rjAT$3jk2( zn`Y0G+F|yE@QOe{cEm>%yX(ie$h-2d=m>5ijb33Gqw5r^H$2GkA}5b~u6CG8y^{y^r{9}+ z$;m1%nTHJKxFOaM>U=vgjcpeCImkDkSM>g;lLQpuDnHEJX_H@hX48z!x-o5fx$CLh z=7sIAm-Dm?T%~%w2KCf)N|>OjR@0K{XOZH$>xFXc@)ZIZw>~TVEzE&`oVwR!Hb`BH z^KRsSw4*7`K_kPpM0uhgb{-cz4fKp$wxk=szVWIM0NR~k<<2}%wyPG=UtcoGTzRjj zM00z_?dENFrwBqelJP@WePVY*oN?@kfVZU>zOVVP=yaQ}&+V$_yR>3;bygN1oWNzy zfk4QIX8F}CHgSGedJ&#XgQ&>9zWB-Z1ZiDCL|m@zr3D~vCse$Y?W-8#Nq{kMEdVw* zZ~m1nSL-po_m-@a@2$^>Be3>fHKFIy&&;i~IQ8y~sf0BPS_dhzS{r{(8N-xK_p(BTu=HC<^JI$36p9cy_JKh;0) zCQOap&?*c!Ol?}>1$go*77&N$$InQ58mbfn%`5Jn4oG&quY&gwL;|@ppW1i%H zxwBrl8w1l)Qa}1T;ml9Yus0&A;6*(o8fbT~YIv!~#3L73?DI1QiV*r_;lZE1ebMmo zz1JFHAG~NT&iCLg*e|c#kq8728+$CW;^zTy{L2R3NKIE=rq}SzQz_@JD3@ge@%Cr{ zA`VHE={;GzA0v>mr91@{EuL?WYP$9U(aKXI`dW<5HRdJ{@A-heQ^_IgU7TIgJA|VP zssu>>@n;4Wxe0RJ&@-9vTQ!@xJKfV#9^^;g-fkGNe@sW7mT~*?IL>`fXup=%wHG=e zWS++N$S_g#NS-psz~4vzO)19m$VW~={h#CGBey%Uk4sE3cRhhbGBJ!oINufP9T&M4 zmN9ViwxN`OxRz+f6M+x7_}C~;$&PyfV(Lz(Je!C3?dYq|F7~U_c-%6sI>0AC`E*N17uU|r__5y zu;Z}>k$FJb|HnzipXp{r&%7i6gnm-rg0a(_{GaX%>(srROSKZ`)8BTQkHVz+UOxWE zk>}(5?qH7gzw^qD=NDj2@1LZlZ^cht+g9PicwNCqCx1Rq^H=YEBoQQb&%inOV%DZ`&#O?qZy`Nj zn?c_kW%kmKa=Z#HQ~GTDwxCn;Ta>bCXtS`?smG@-7~fiC$xTdeFTTyWJ>1AjE%A}E zPRBhG*?$Uu->34-aR~Do&WKZWfpqbAmpJ26{z&Q~g^Q3t%nQ@A z4(!6E?|IJxd_5td#7F^1?s2&EvvI=gnIc%(;KF+(^8VL0n(=~ z23;%^n06)n+#x-Sq_D@(srS5l0sQIjc>|O3Y0k>dUvqqUxT2ivHq2|7fDeKI6hPLq zv7R)N@#S~^nuLO!f&da!gs;U>!Uu7d4sA~c^lG3(0?^Kt5D3XGXTQtswvgXjLfvIo z+(#Zi^1%#nD!jm|m~lJuOOCI0hK$>9TRbI%J?_PnvJ+Ji&~bh!_az6so?($OJfCi` zx|mbs<7$43YgzPo`wLk?Kh2lFABmCukq}|<_ai-oM#yktWMZVjWy``#Y&SD^6!&Fz zjv27HV&>f#>qewtVpOWmmlP%@9${O-cKLZ($NMe7_>N(IbP%tbhOmCNk{_m={e!IL z2@7gJ|1z2usAu&n&%oX+_#_g4BSALM{`$E&S2=w_xU$_HuPa4ji}G^HIWy*bl?jf{ zg##DEDCK`%al(}aoV^1gZL^ak;4YR52Y`Zy+zQ2xg@xZBCm~07c(`q=M1Mt;-8QW} zX+#LO6IA1&VeaC3OG;XE6qTmZ^$1cJ#Ag5Z+=DdUxEkBweEco{yFzhAIU_1_DIZ@y z%7r;fRXB$9d&oq0e9_82o1&9+s`gcO#sY&krkLC24Lv`HIyPxtFo??tJnJH0$7_;ekWslG7J@l46D8vOWcKiN(HZ ze$bUmB$`^7*HNiO*#wPW5;d;D=7?=-q-_r2*R6406GjW-88hph#JT%-no@gvmsjkf zUw!HG26Nr4vjILQr5jve@++}ZRDAdrti9wyKWXJjWKpaz-i!FCFnI2?{-Q>)i<^Ri z@~wU_%0I6A%017UxM=RhYdT~#<|*7Y5{6ks^1^b=f{&hL9cg)<@uk)9rHIi_!Hc}E zmm`CftKE*Vf7m(VbDw7=->R15wYviEcHTPgN#O@MUb7dC{`eJzada#x^hei}HH6^k zfhH)nJQt{^Eq&}+nsuOIocHfDEs|+ov-U>Pj6sKhLX@*UcsnwG5Bg>2Bq`e9QqqMnwtM0` zh85C5XDSA-)BK1Fim{cH8_Ctgj*s_<@oZYc0`8n8UYx^ObManls`LDc&yMgop2RHY zUO8UfIUolQnmdxOSxs1;7&Z_*UVY3q^->PsEr>f4LQg>=kRO~W`1dtCjbnzDVC>s8#W2QX0p_@<5)61W7Z1Z2$zIJ3y z)W2Q&7uU`Ac|^CB<)9XfV((WYOekWSt_>7op zH)|{_Y{2@X_tG~csW#*!ke5Pb={JQq5wWD|2hjs7lg$-^fH2g^Y6igq@ zW?L!$O{Q;)3SF>iyYcD7@3`I{V}4sT?lzS_{s;&0rJZYoFhT@2em;ka`g`1`6qywE z-LgLQyRW5%X=k#sE}ZZ9tzeGrv@=@SubaRmHL{;f*O>_U@+ijILENd;J#xIDg{&HI z0K8SmtB4MdSNdl7J<$70>v7{qHFm72*QeD;Q&ri~br>Bem&37X5-=FV~ zh`i}uJn8XU&EqeF|Kx>lyS%*aEz0e|OT5lK74iHo5BB?^{bn47&>~LQjwLO@by1gj~_LL zhLQ_mF;+4b#nPn~6uZyWaUpU0&;=pot5YVzgZ43V!RzltItO?^6)0wodUwTDHT3Z? z+)O&0?6pL4@`bBaP5vV8RF*{T)pSTg9-dQbOI*jS^En3JjA3i}n0mUH`YH1Cx`P@2 zxoc-EG#Cr>kkwMMg_Hlij{npI`Q11?zSh~D54cgp1n@_FM23BWXx4s`&7!)o9=g-wp3}#xN}~<4sj2NN()Ze2KE%~bTnsHK&U|ut zv;WlY-T|=n3#xMftVM5sP`O%Qo;sL*|3X)PoUZj3dvgN%aK!;Wc`?b8Zo4*P+H};!zZ$qb}&mNxS>6_&xjJv&uMYy!Vk3adR^&jdM#r0L! zyDu|*>jarEJqc*F=~qiS06?8+uI+spYY8J)wBa6Y?cYBDHh#=EZlbH5v^*Iv*SN)e zm9N{BU%DWEOx##@_m=*M-;uB%r2IjR-2(tb2(I5*F>C#Li8Is#A>4d^>w_Y5vH0GV z$I<26{t9f;OqS&PuTG0|PY>gbfy;pxg*p~D#CG>)Hg^laY~35u`qle>e&f*Tus4-M z+6LmEK1t&f*b#zhJ*cQlv~4(U`I!624;fmP4ZUS%v+eMkYkMJ&{+3q<3~3zzi;jEY z@854$ktT`TIoN^hwj8$dYt3({ce7?Ymvl)7K%=ef;z^q^uKc^T2SE7VzZ1Ow^k?Zs zD%bbj)wZ?G+O^D|G+R$0W|qAe0(*lJ=%TZSN(pB zE3Q_crSrW!)iVvG>-lb_qRd2WRB}e&B3W1dHov59t`Ip%R)F0IT4#4_LX7jjwYKL0 zMOXIb0`}f+?hpOdZF|4=bN>MNzS7|=@9aO~B$*Hc<6zk{Nq1=oJ*t|Xz@(etaPjSf zw@0T24}i;z-RZtlW!YvyP=mbfmyO{wZ3{`i^gT+<`Ud8YKFRk9NV|1C^Tp|la`COa zRDaiV^jh1G&x=5WH97~t&K1V)2mh9W;R9f-tYvR!OI)5+^pS+}B?=|N(2hGJHnKtg z(G6kGz^p4*AkmAJq8nXxcTeu(Hl75neVsdYx$Vo%ZML?ZT86Uz^<_1^J94fvGboy9 z`*y4wpJ$ueLfxlo-!F0R+#VU*wkzGe^0-a$iskgy-HFHMRcgb!*D4=q&F7toBaP(0 zI3*ww#sA&uK`&S0?Yb2QkI#G^G1RWXPlDMt8eXMZ3dgb^4fr^3p8oGo;We`;tJiX- zUR$YdMQ4Qd&W=$tC(5$=eVBWeWBs6c2FI-_?)RO8$7iLB{qmo#f(2hE|LNL(wQ|KZh|I~jZ1~bI&GH8@e%ts@ zybWC%*B*PNy*Vv0@jj4)&CR&xMr`95>ZLa&9NSwXl6%-3(B=l^ypg zT{Pc##ap$&KkoL`u?xBiZ-e?|uItAB{S$6?PL4A8p&(4k{Q7rWNKz5IwSA}wyyEOHv z$|avY=xvzFe6_Dfke=Evmp`RmDoNFnpHj8Q7F1&>>P&nk+$@9Kw?@scBT-dqt5sag zCj?lfEUl%&K79QebHszlwr?7c0MR;crZ$F;GUU9iUQvqcRX4n5w5{f7Ev7%xG0*zE z=Ge|<4#8>xO~trtYNV_!WBR$>SfY_ot=7jxRIi)pTdi*?*6Wdr58G4IGqU8t=e&{U zMA(c&4}eEn1@Tnpyy0Vdh|jOh5??m*n4~Ii`RE^GRrz$}O!__d2A~>>UnwUzJx|eX zCG!AQiMEiIhpID2+SV?yA$#vjp(bP2md(qL{gymc1YwyIHm3zEl#xM2F`}DsSS#>oxCJV6PlEjk~ zSZ%*cy_H)yKh8WKdWQXc(uf?>_5TX$(OGqOPN?10V^I|Ts3pRrY*c{#^XioJ5JlIS zZYLi1|IqXvP)%>l}(Y0^S9GzkOXBKUuLy^Z&SgXot?$cA5gsDGx@e;57ntGKxQ&T6#^$eYuj z+U;csOPsr1;hFX@Xh?8@zSgaMzav>1Y!4rK)+x4qw3ZzD>Bj26KX(jnQw@8_sEynJ zcs&*bT*bSM%dsLVxd#+~@Q4cKQtDeGY}Y9GGzv!Tf0XV2TjKiPfAuX&dF!s51u_^P zhyB08)_(vs)^GMSJuPx@qG_Ti%tMGiyI6rWap|aKG#4

}kjOb4U9O%sKb{o$e` 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

Zd*VRInRsv5(iz8r0 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 literal 0 HcmV?d00001 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/src/app/auth/page.tsx b/apps/frontend/src/app/auth/page.tsx index 13924fc..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({ @@ -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/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/global-call-provider.tsx b/apps/frontend/src/app/providers/global-call-provider.tsx index 0b8898a..04f3a9e 100644 --- a/apps/frontend/src/app/providers/global-call-provider.tsx +++ b/apps/frontend/src/app/providers/global-call-provider.tsx @@ -7,156 +7,188 @@ import CallOverlay from "@/app/zone/_components/global/CallOverlay" import { useVoiceCall } from "@/hooks/useVoiceCall" export default function GlobalCallProvider() { - const { status, user, chatPublicId, isCaller, setIncoming, setConnected, clear } = useCallStore() - const { - startCall, - acceptCall, - endCall, - remoteAudioRef, - ringtoneRef, - playRingtone, - stopRingtone + const { status, user, chatPublicId, isCaller, setIncoming, setConnected, clear, setCalling } = useCallStore() + const { + startCall, + acceptCall, + endCall, + remoteAudioRef, + toggleMute } = useVoiceCall() /* ========================= - SOCKET LISTENERS + INITIAL CHECK (Persistence) ========================== */ - useEffect(() => { - const incomingHandler = async (payload: any) => { - console.log("INCOMING PAYLOAD:", payload) - const { chatPublicId, user } = payload - setIncoming(chatPublicId, user) + const handleRecheck = () => { + if (socket.connected) { + socket.emit("call:check") + } } - const acceptedHandler = () => { - setConnected() + 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 + }) + } } - const rejectedHandler = () => { - clear() + 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 + ========================== */ + + useEffect(() => { + const incomingHandler = (payload: any) => { + const { chatPublicId, user } = payload + socket.emit("join-room", { chatPublicId }) + setIncoming(chatPublicId, user) } - 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]) /* ========================= - VOICE CALL SYNC + VOICE CALL HANDSHAKE ========================== */ - + useEffect(() => { - // Only the caller initiates the WebRTC offer (startCall) if (status === "connected" && chatPublicId && isCaller) { startCall(chatPublicId) } - }, [status, chatPublicId, isCaller, startCall]) + + if (status === "connecting" && chatPublicId && !isCaller) { + acceptCall() + } + }, [status, chatPublicId, isCaller, startCall, acceptCall]) /* ========================= - RING SOUNDS + INTERNAL APP LOGIC (RINGTONES) ========================== */ - const outgoingAudioRef = useRef(null) + const callingAudioRef = useRef(null) + const incomingAudioRef = useRef(null) useEffect(() => { - const outgoingAudio = outgoingAudioRef.current - if (!outgoingAudio) return + const callingAudio = callingAudioRef.current + const incomingAudio = incomingAudioRef.current + + if (!callingAudio || !incomingAudio) return if (status === "calling") { - outgoingAudio.loop = true - outgoingAudio.currentTime = 0 - outgoingAudio.play().catch(() => { }) + callingAudio.loop = true + callingAudio.currentTime = 0 + callingAudio.play().catch(() => { }) } else { - outgoingAudio.pause() - outgoingAudio.currentTime = 0 + callingAudio.pause() + callingAudio.currentTime = 0 } - // Incoming call sound if (status === "incoming") { - playRingtone() + incomingAudio.loop = true + incomingAudio.currentTime = 0 + incomingAudio.play().catch(() => { }) } else { - // Only stop if we are not connected yet or cleared - if (status !== "connected") { - stopRingtone() - } + incomingAudio.pause() + incomingAudio.currentTime = 0 } - }, [status, playRingtone, stopRingtone]) - - useEffect(() => { - function handleUnload() { - const state = useCallStore.getState() - if (state.status !== "idle" && state.user?.id && state.chatPublicId) { - socket.emit("call:end", { - toUserId: state.user.id, - chatPublicId: state.chatPublicId, - }) - endCall() - } - } - - window.addEventListener("beforeunload", handleUnload) - return () => window.removeEventListener("beforeunload", handleUnload) - }, [endCall]) + }, [status]) if (status === "idle") return null return ( <>
{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 +
+ )} +
))}
@@ -155,6 +173,26 @@ export default function ZoneSidebar({ )}
+ {/* Voice Status Bar */} + {activeChannelPublicId && ( +
+
+ Voice Connected + + {channels.find(c => c.publicId === activeChannelPublicId)?.name || 'Channel'} + +
+ +
+ )} + {/* User Bar */} diff --git a/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx b/apps/frontend/src/app/zone/_components/zones/ZonesList.tsx index d96c244..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) } } @@ -89,41 +93,48 @@ export default function ZonesList() {
{/* Zones */} - - {zones.map(zone => { - const active = zonePublicId === zone.publicId - - return ( - - ) - })} + + ) + }) + )}