diff --git a/app/api/bookmarks/[userId]/route.ts b/app/api/bookmarks/[userId]/route.ts new file mode 100644 index 0000000..8e60e62 --- /dev/null +++ b/app/api/bookmarks/[userId]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/firebase-admin"; +import { toIso } from "@/lib/firestore-helpers"; + +export const runtime = "nodejs"; + +export async function GET( + _req: NextRequest, + context: { params: Promise<{ userId: string }> } +) { + try { + const { userId } = await context.params; + if (!userId) { + return NextResponse.json({ error: "Missing userId" }, { status: 400 }); + } + + const snapshot = await db + .collection("messages") + .where("bookmarks", "array-contains", userId) + .orderBy("createdAt", "desc") + .limit(100) + .get(); + + const messages = snapshot.docs.map((doc: any) => { + const data = doc.data(); + return { + _id: doc.id, + ...data, + createdAt: toIso(data.createdAt), + updatedAt: toIso(data.updatedAt), + ghostExpiresAt: toIso(data.ghostExpiresAt), + }; + }); + + return NextResponse.json(messages); + } catch (error) { + console.error("GET /api/bookmarks/[userId] error:", error); + return NextResponse.json( + { error: "Failed to load bookmarks" }, + { status: 500 } + ); + } +} diff --git a/app/api/chat/upload/route.ts b/app/api/chat/upload/route.ts new file mode 100644 index 0000000..e2d4c03 --- /dev/null +++ b/app/api/chat/upload/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v2 as cloudinary } from "cloudinary"; + +export const runtime = "nodejs"; + +const MAX_FILE_SIZE = 25 * 1024 * 1024; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + secure: true, +}); + +function resourceTypeFor(type: string) { + if (type.startsWith("image/")) return "image"; + if (type.startsWith("video/")) return "video"; + return "raw"; +} + +export async function POST(req: NextRequest) { + try { + if ( + !process.env.CLOUDINARY_CLOUD_NAME || + !process.env.CLOUDINARY_API_KEY || + !process.env.CLOUDINARY_API_SECRET + ) { + return NextResponse.json( + { error: "Cloudinary is not configured" }, + { status: 500 } + ); + } + + const form = await req.formData(); + const file = form.get("file"); + + if (!(file instanceof File)) { + return NextResponse.json({ error: "file is required" }, { status: 400 }); + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File must be 25MB or smaller" }, + { status: 413 } + ); + } + + const bytes = Buffer.from(await file.arrayBuffer()); + const resourceType = resourceTypeFor(file.type); + + const result = await new Promise((resolve, reject) => { + const stream = cloudinary.uploader.upload_stream( + { + folder: "vibexcode/chat", + resource_type: resourceType, + use_filename: true, + unique_filename: true, + }, + (error, uploadResult) => { + if (error) reject(error); + else resolve(uploadResult); + } + ); + stream.end(bytes); + }); + + return NextResponse.json({ + url: result.secure_url, + secureUrl: result.secure_url, + publicId: result.public_id, + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + format: result.format || "", + resourceType, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to upload file"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/dev/users/route.ts b/app/api/dev/users/route.ts index 2783682..91a7e35 100644 --- a/app/api/dev/users/route.ts +++ b/app/api/dev/users/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from "next/server"; import { db } from "@/lib/firebase-admin"; -import { derivePresence } from "@/lib/presence"; +import { derivePresence, derivePresenceDevice } from "@/lib/presence"; import { toDate } from "@/lib/firestore-helpers"; type LeanUser = { @@ -13,6 +13,8 @@ type LeanUser = { username?: string; name?: string; lastSeen?: string | Date; + presenceDevice?: string; + customStatus?: string; activity?: string; stats?: { totalSolved?: number }; createdAt?: string | Date; @@ -46,6 +48,8 @@ export async function GET() { const decorated = users.map((u: any) => ({ ...u, status: derivePresence(u.lastSeen || null), + presenceDevice: derivePresenceDevice(u.presenceDevice), + customStatus: u.customStatus || "", })); return NextResponse.json(decorated); diff --git a/app/api/message/[id]/route.ts b/app/api/message/[id]/route.ts index 9214d53..15de7f2 100644 --- a/app/api/message/[id]/route.ts +++ b/app/api/message/[id]/route.ts @@ -197,3 +197,69 @@ export async function DELETE( return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } + +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const { senderId, action, emoji } = await req.json(); + + if (!senderId || !action) { + return NextResponse.json( + { error: "Missing required fields (senderId or action)" }, + { status: 400 } + ); + } + + const msgRef = db.collection("messages").doc(id); + const msgSnap = await msgRef.get(); + if (!msgSnap.exists) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }); + } + + const data = msgSnap.data() || {}; + + if (action === "reaction") { + const safeEmoji = String(emoji || "").slice(0, 8); + if (!safeEmoji) { + return NextResponse.json({ error: "emoji is required" }, { status: 400 }); + } + + const reactions = { ...(data.reactions || {}) } as Record; + const current = Array.isArray(reactions[safeEmoji]) + ? reactions[safeEmoji] + : []; + reactions[safeEmoji] = current.includes(senderId) + ? current.filter((id) => id !== senderId) + : [...current, senderId]; + + if (reactions[safeEmoji].length === 0) delete reactions[safeEmoji]; + + await msgRef.set( + { reactions, updatedAt: FieldValue.serverTimestamp() }, + { merge: true } + ); + return NextResponse.json({ success: true, reactions }); + } + + if (action === "bookmark") { + const bookmarks = Array.isArray(data.bookmarks) ? data.bookmarks : []; + const nextBookmarks = bookmarks.includes(senderId) + ? bookmarks.filter((id: string) => id !== senderId) + : [...bookmarks, senderId]; + + await msgRef.set( + { bookmarks: nextBookmarks, updatedAt: FieldValue.serverTimestamp() }, + { merge: true } + ); + return NextResponse.json({ success: true, bookmarks: nextBookmarks }); + } + + return NextResponse.json({ error: "Unsupported action" }, { status: 400 }); + } catch (error) { + console.error("PATCH /api/message/[id] error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/messages/[conversationId]/route.ts b/app/api/messages/[conversationId]/route.ts index 8051a8f..d402f8d 100644 --- a/app/api/messages/[conversationId]/route.ts +++ b/app/api/messages/[conversationId]/route.ts @@ -23,6 +23,23 @@ type UserRecord = { moderation?: ModerationState; }; +const GHOST_TTL_MS = 24 * 60 * 60 * 1000; + +async function cleanupExpiredMessages(conversationId: string) { + const expired = await db + .collection("messages") + .where("conversation", "==", conversationId) + .where("ghostExpiresAt", "<=", Timestamp.now()) + .limit(50) + .get(); + + if (expired.empty) return; + + const batch = db.batch(); + expired.docs.forEach((doc: any) => batch.delete(doc.ref)); + await batch.commit(); +} + async function resolveUser( senderId: string, senderEmail?: string, @@ -86,6 +103,8 @@ export async function GET( return NextResponse.json({ error: "Missing conversationId" }, { status: 400 }); } + await cleanupExpiredMessages(conversationId); + const snapshot = await db .collection("messages") .where("conversation", "==", conversationId) @@ -99,6 +118,7 @@ export async function GET( ...data, createdAt: toIso(data.createdAt), updatedAt: toIso(data.updatedAt), + ghostExpiresAt: toIso(data.ghostExpiresAt), }; }); @@ -121,9 +141,17 @@ export async function POST( } const body = await req.json(); - const { senderId, senderName, senderEmail, body: messageBody, image } = body; + const { + senderId, + senderName, + senderEmail, + body: messageBody, + image, + ghost, + attachments, + } = body; - if (!senderId || !messageBody) { + if (!senderId || (!messageBody && !Array.isArray(attachments))) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } @@ -147,7 +175,7 @@ export async function POST( ); } - const abuse = detectAbuse(String(messageBody)); + const abuse = detectAbuse(String(messageBody || "")); if (abuse.hit) { const windowStart = new Date(now.getTime() - WARNING_WINDOW_MS); const warnings = user.moderation?.warnings || []; @@ -201,14 +229,37 @@ export async function POST( ); } + await cleanupExpiredMessages(conversationId); + + const nowTs = Timestamp.now(); + const ghostExpiresAt = ghost + ? Timestamp.fromMillis(nowTs.toMillis() + GHOST_TTL_MS) + : null; + const safeAttachments = Array.isArray(attachments) + ? attachments.slice(0, 6).map((file: any) => ({ + url: String(file?.url || ""), + secureUrl: String(file?.secureUrl || file?.url || ""), + name: String(file?.name || "Attachment").slice(0, 160), + type: String(file?.type || "file").slice(0, 80), + size: Number(file?.size || 0), + format: String(file?.format || "").slice(0, 40), + resourceType: String(file?.resourceType || "raw").slice(0, 20), + })) + : []; + const messageRef = await db.collection("messages").add({ conversation: conversationId, sender: senderId, senderName: senderName || "", - body: messageBody, + body: messageBody || "", image: image || "", - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), + ghost: Boolean(ghost), + ghostExpiresAt, + attachments: safeAttachments, + reactions: {}, + bookmarks: [], + createdAt: nowTs, + updatedAt: nowTs, }); const created = await messageRef.get(); @@ -220,6 +271,7 @@ export async function POST( ...data, createdAt: toIso(data.createdAt), updatedAt: toIso(data.updatedAt), + ghostExpiresAt: toIso(data.ghostExpiresAt), }, { status: 201 } ); diff --git a/app/api/tools/polls/[pollId]/route.ts b/app/api/tools/polls/[pollId]/route.ts new file mode 100644 index 0000000..7df09ab --- /dev/null +++ b/app/api/tools/polls/[pollId]/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/firebase-admin"; +import { toIso } from "@/lib/firestore-helpers"; + +export const runtime = "nodejs"; + +function serializePoll(id: string, data: any) { + return { + _id: id, + ...data, + expiresAt: toIso(data.expiresAt), + createdAt: toIso(data.createdAt), + updatedAt: toIso(data.updatedAt), + timeline: (data.timeline || []).map((entry: any) => ({ + ...entry, + at: toIso(entry.at) || entry.at, + })), + }; +} + +function isClosed(data: any) { + if (!data?.expiresAt) return false; + const expiresAt = + data.expiresAt instanceof Date + ? data.expiresAt + : typeof data.expiresAt?.toDate === "function" + ? data.expiresAt.toDate() + : new Date(data.expiresAt); + return expiresAt.getTime() <= Date.now(); +} + +export async function GET( + _req: NextRequest, + context: { params: Promise<{ pollId: string }> } +) { + const { pollId } = await context.params; + const snap = await db.collection("polls").doc(pollId).get(); + if (!snap.exists) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + return NextResponse.json(serializePoll(snap.id, snap.data() || {})); +} + +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ pollId: string }> } +) { + try { + const { pollId } = await context.params; + const body = await req.json().catch(() => ({})); + const userId = String(body?.userId || ""); + const optionIds = Array.isArray(body?.optionIds) + ? body.optionIds.map((id: unknown) => String(id)).filter(Boolean) + : []; + + if (!userId || optionIds.length === 0) { + return NextResponse.json( + { error: "userId and optionIds are required" }, + { status: 400 } + ); + } + + const ref = db.collection("polls").doc(pollId); + const snap = await ref.get(); + if (!snap.exists) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + + const data = snap.data() || {}; + if (isClosed(data)) { + return NextResponse.json({ error: "Poll is closed" }, { status: 403 }); + } + + const allowed = new Set((data.options || []).map((option: any) => option.id)); + const nextOptionIds = optionIds + .filter((id: string) => allowed.has(id)) + .slice(0, data.multipleChoice ? 10 : 1); + + if (nextOptionIds.length === 0) { + return NextResponse.json({ error: "Invalid option" }, { status: 400 }); + } + + const votes = Array.isArray(data.votes) ? [...data.votes] : []; + const existingIndex = votes.findIndex((vote: any) => vote.userId === userId); + if (existingIndex >= 0 && data.allowVoteChanges === false) { + return NextResponse.json( + { error: "Vote changes are disabled" }, + { status: 403 } + ); + } + + const vote = { userId, optionIds: nextOptionIds, votedAt: new Date() }; + if (existingIndex >= 0) votes[existingIndex] = vote; + else votes.push(vote); + + const timeline = Array.isArray(data.timeline) ? data.timeline : []; + const nextTimeline = [ + ...timeline, + { at: new Date(), totalVotes: votes.length }, + ].slice(-200); + + await ref.set( + { votes, timeline: nextTimeline, updatedAt: new Date() }, + { merge: true } + ); + + const updated = await ref.get(); + return NextResponse.json(serializePoll(updated.id, updated.data() || {})); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to vote"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/tools/polls/route.ts b/app/api/tools/polls/route.ts new file mode 100644 index 0000000..b06f782 --- /dev/null +++ b/app/api/tools/polls/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/firebase-admin"; +import { toIso } from "@/lib/firestore-helpers"; + +export const runtime = "nodejs"; + +const EXPIRATIONS: Record = { + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "3d": 3 * 24 * 60 * 60 * 1000, + "1w": 7 * 24 * 60 * 60 * 1000, + never: null, +}; + +function serializePoll(id: string, data: any) { + return { + _id: id, + ...data, + expiresAt: toIso(data.expiresAt), + createdAt: toIso(data.createdAt), + updatedAt: toIso(data.updatedAt), + timeline: (data.timeline || []).map((entry: any) => ({ + ...entry, + at: toIso(entry.at) || entry.at, + })), + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})); + const conversationId = String(body?.conversationId || ""); + const creatorId = String(body?.creatorId || ""); + const creatorName = String(body?.creatorName || ""); + const question = String(body?.question || "").trim().slice(0, 240); + const rawOptions = Array.isArray(body?.options) ? body.options : []; + const labels = rawOptions + .map((value: unknown) => String(value || "").trim().slice(0, 120)) + .filter(Boolean) + .slice(0, 10); + + if (!conversationId || !creatorId || !question || labels.length < 2) { + return NextResponse.json( + { error: "Polls need a conversation, creator, question, and 2 options" }, + { status: 400 } + ); + } + + const now = new Date(); + const duration = EXPIRATIONS[String(body?.expiration || "24h")] ?? null; + const expiresAt = duration ? new Date(now.getTime() + duration) : null; + const options = labels.map((label: string) => ({ + id: crypto.randomUUID(), + label, + })); + + const pollRef = await db.collection("polls").add({ + conversationId, + question, + options, + votes: [], + creatorId, + creatorName, + multipleChoice: Boolean(body?.multipleChoice), + anonymous: Boolean(body?.anonymous), + allowVoteChanges: body?.allowVoteChanges !== false, + expiresAt, + timeline: [{ at: now, totalVotes: 0 }], + createdAt: now, + updatedAt: now, + }); + + const messageRef = await db.collection("messages").add({ + conversation: conversationId, + sender: creatorId, + senderName: creatorName, + body: `Poll: ${question}`, + messageType: "poll", + pollId: pollRef.id, + createdAt: now, + updatedAt: now, + reactions: {}, + bookmarks: [], + attachments: [], + }); + + await pollRef.set({ messageId: messageRef.id }, { merge: true }); + + const pollSnap = await pollRef.get(); + const poll = serializePoll(pollRef.id, pollSnap.data() || {}); + return NextResponse.json({ + poll, + message: { + _id: messageRef.id, + conversation: conversationId, + sender: creatorId, + senderName: creatorName, + body: `Poll: ${question}`, + messageType: "poll", + pollId: pollRef.id, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + reactions: {}, + bookmarks: [], + attachments: [], + }, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to create poll"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/tools/whiteboard/[roomId]/route.ts b/app/api/tools/whiteboard/[roomId]/route.ts new file mode 100644 index 0000000..1a09b70 --- /dev/null +++ b/app/api/tools/whiteboard/[roomId]/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, FieldValue } from "@/lib/firebase-admin"; +import { toIso } from "@/lib/firestore-helpers"; + +export const runtime = "nodejs"; + +type BoardElement = { + id: string; + type: string; + tool?: string; + points?: number[][]; + x?: number; + y?: number; + width?: number; + height?: number; + text?: string; + color?: string; + strokeWidth?: number; + fontSize?: number; +}; + +function sanitizeElements(input: unknown): BoardElement[] { + if (!Array.isArray(input)) return []; + return input.slice(0, 2000).map((item: any) => ({ + id: String(item?.id || crypto.randomUUID()), + type: String(item?.type || "path").slice(0, 24), + tool: String(item?.tool || "").slice(0, 24), + points: Array.isArray(item?.points) + ? item.points.slice(0, 4000).map((point: any) => [ + Number(point?.[0] || 0), + Number(point?.[1] || 0), + ]) + : [], + x: Number(item?.x || 0), + y: Number(item?.y || 0), + width: Number(item?.width || 0), + height: Number(item?.height || 0), + text: String(item?.text || "").slice(0, 2000), + color: String(item?.color || "#111827").slice(0, 24), + strokeWidth: Number(item?.strokeWidth || 3), + fontSize: Number(item?.fontSize || 18), + })); +} + +function serializeBoard(id: string, data: any) { + return { + _id: id, + roomId: data.roomId, + elements: data.elements || [], + participants: data.participants || [], + history: (data.history || []).map((entry: any) => ({ + ...entry, + savedAt: toIso(entry.savedAt) || entry.savedAt || null, + })), + metadata: data.metadata || {}, + createdAt: toIso(data.createdAt), + updatedAt: toIso(data.updatedAt), + }; +} + +export async function GET( + _req: NextRequest, + context: { params: Promise<{ roomId: string }> } +) { + const { roomId } = await context.params; + if (!roomId) { + return NextResponse.json({ error: "Missing roomId" }, { status: 400 }); + } + + const ref = db.collection("whiteboards").doc(roomId); + const snap = await ref.get(); + if (!snap.exists) { + const payload = { + roomId, + elements: [], + participants: [], + history: [], + metadata: {}, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }; + await ref.set(payload); + return NextResponse.json(serializeBoard(roomId, payload)); + } + + return NextResponse.json(serializeBoard(snap.id, snap.data() || {})); +} + +export async function POST( + req: NextRequest, + context: { params: Promise<{ roomId: string }> } +) { + const { roomId } = await context.params; + const body = await req.json().catch(() => ({})); + const elements = sanitizeElements(body?.elements); + const savedBy = + typeof body?.savedBy === "string" ? body.savedBy.slice(0, 120) : "unknown"; + + if (!roomId) { + return NextResponse.json({ error: "Missing roomId" }, { status: 400 }); + } + + const ref = db.collection("whiteboards").doc(roomId); + const snap = await ref.get(); + const current = snap.exists ? snap.data() || {} : {}; + const history = Array.isArray(current.history) ? current.history : []; + const nextHistory = [ + ...history, + { elements, savedBy, savedAt: new Date().toISOString() }, + ].slice(-20); + + await ref.set( + { + roomId, + elements, + history: nextHistory, + metadata: { + ...(current.metadata || {}), + lastSavedBy: savedBy, + elementCount: elements.length, + }, + updatedAt: FieldValue.serverTimestamp(), + createdAt: current.createdAt || FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + const saved = await ref.get(); + return NextResponse.json(serializeBoard(saved.id, saved.data() || {})); +} diff --git a/app/api/user/heartbeat/route.ts b/app/api/user/heartbeat/route.ts index 4caa47e..57d3707 100644 --- a/app/api/user/heartbeat/route.ts +++ b/app/api/user/heartbeat/route.ts @@ -1,5 +1,5 @@ // POST /api/user/heartbeat -// Body: { email } +// Body: { email, device?, customStatus?, activity? } // Bumps the user's lastSeen so derivePresence() reports them as Online. // Called every ~30s by the client while the page is visible. @@ -13,6 +13,13 @@ export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => ({})); const email = normalizeEmail(body?.email) || ""; + const device = body?.device === "Mobile" ? "Mobile" : "Desktop"; + const activity = + typeof body?.activity === "string" ? body.activity.slice(0, 40) : "heartbeat"; + const customStatus = + typeof body?.customStatus === "string" + ? body.customStatus.trim().slice(0, 80) + : undefined; if (!email) { return NextResponse.json( @@ -25,7 +32,12 @@ export async function POST(req: NextRequest) { const snapshot = await users.where("email", "==", email).limit(1).get(); if (!snapshot.empty) { await snapshot.docs[0].ref.set( - { lastSeen: FieldValue.serverTimestamp() }, + { + lastSeen: FieldValue.serverTimestamp(), + presenceDevice: device, + presenceActivity: activity, + ...(customStatus !== undefined ? { customStatus } : {}), + }, { merge: true } ); } diff --git a/app/api/user/theme/route.ts b/app/api/user/theme/route.ts new file mode 100644 index 0000000..713c8fb --- /dev/null +++ b/app/api/user/theme/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/firebase-admin"; +import { toIso } from "@/lib/firestore-helpers"; + +export const runtime = "nodejs"; + +const THEMES = new Set([ + "default", + "ocean", + "aurora", + "purple-night", + "sunset", + "cyber", + "emerald", +]); + +export async function GET(req: NextRequest) { + const userId = req.nextUrl.searchParams.get("userId") || ""; + if (!userId) { + return NextResponse.json({ error: "userId is required" }, { status: 400 }); + } + + const snap = await db.collection("userThemes").doc(userId).get(); + if (!snap.exists) { + return NextResponse.json({ userId, theme: "default" }); + } + + const data = snap.data() || {}; + return NextResponse.json({ + userId, + theme: data.theme || "default", + updatedAt: toIso(data.updatedAt), + }); +} + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const userId = String(body?.userId || ""); + const theme = String(body?.theme || "default"); + + if (!userId) { + return NextResponse.json({ error: "userId is required" }, { status: 400 }); + } + if (!THEMES.has(theme)) { + return NextResponse.json({ error: "Invalid theme" }, { status: 400 }); + } + + await db.collection("userThemes").doc(userId).set( + { + userId, + theme, + updatedAt: new Date(), + createdAt: new Date(), + }, + { merge: true } + ); + + return NextResponse.json({ + userId, + theme, + updatedAt: new Date().toISOString(), + }); +} diff --git a/app/bookmarks/page.tsx b/app/bookmarks/page.tsx new file mode 100644 index 0000000..0430cd2 --- /dev/null +++ b/app/bookmarks/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Bookmark, Search } from "lucide-react"; + +import Navbar from "../components/Navbar"; +import authservice from "@/app/auth/firebase-auth"; + +type BookmarkMessage = { + _id: string; + conversation: string; + senderName?: string; + body?: string; + createdAt?: string; +}; + +export default function BookmarksPage() { + const [userId, setUserId] = useState(""); + const [query, setQuery] = useState(""); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + authservice + .checkUser() + .then((user) => { + if (!user || cancelled) return null; + setUserId(user.$id); + return fetch(`/api/bookmarks/${encodeURIComponent(user.$id)}`); + }) + .then((res) => res?.json()) + .then((data) => { + if (!cancelled && Array.isArray(data)) setMessages(data); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return messages; + return messages.filter((message) => + `${message.senderName || ""} ${message.body || ""} ${message.conversation}` + .toLowerCase() + .includes(needle) + ); + }, [messages, query]); + + return ( +
+ +
+
+
+ +
+
+

Bookmarks

+

+ Saved messages from your developer chats. +

+
+
+ + + + {loading ? ( +

Loading bookmarks...

+ ) : !userId ? ( +

Log in to view bookmarks.

+ ) : filtered.length === 0 ? ( +

No bookmarked messages found.

+ ) : ( +
+ {filtered.map((message) => ( +
+
+ + #{message.conversation} by {message.senderName || "Unknown"} + + + Jump to message + +
+
+ + {message.body || ""} + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/community/page.tsx b/app/community/page.tsx index 4ccc961..224f8e4 100644 --- a/app/community/page.tsx +++ b/app/community/page.tsx @@ -59,7 +59,9 @@ interface MemberSummary { email: string; name?: string; username?: string; - status?: "Online" | "Idle" | "Offline"; + status?: "Active" | "Away" | "Offline"; + presenceDevice?: "Desktop" | "Mobile"; + customStatus?: string; } export default function CommunityPage() { @@ -70,6 +72,13 @@ export default function CommunityPage() { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [members, setMembers] = useState([]); + useEffect(() => { + const channel = new URLSearchParams(window.location.search).get("channel"); + if (channel && forums.some((forum) => forum.key === channel)) { + setSelected(channel); + } + }, []); + useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < 768); handleResize(); @@ -100,8 +109,8 @@ export default function CommunityPage() { fetchUser(); }, []); - // Pull the member list for the right rail. Server derives Online/Idle - // from each user's lastSeen heartbeat — re-fetch every 60s so the dots + // Pull the member list for the right rail. Server derives Active/Away + // from each user's lastSeen heartbeat. Re-fetch every 60s so the dots // actually update as people come and go. useEffect(() => { let cancelled = false; @@ -125,10 +134,10 @@ export default function CommunityPage() { const activeForum = forums.find((f) => f.key === selected) || forums[0]; - // Group members by derived presence so Online appears first. + // Group members by derived presence so Active appears first. const groupedMembers = { - online: members.filter((m) => m.status === "Online"), - idle: members.filter((m) => m.status === "Idle"), + active: members.filter((m) => m.status === "Active"), + away: members.filter((m) => m.status === "Away"), offline: members.filter((m) => !m.status || m.status === "Offline"), }; @@ -261,20 +270,20 @@ export default function CommunityPage() {

{members.length} total ·{" "} - {groupedMembers.online.length} online + {groupedMembers.active.length} active

+
  • {display} + + {m.presenceDevice || "Desktop"} + +
    +

    + {display} +

    +

    + {m.status || "Offline"} on {m.presenceDevice || "Desktop"} +

    + {m.customStatus && ( +

    + {m.customStatus} +

    + )} +
    ); })} diff --git a/app/components/ChatWindow.tsx b/app/components/ChatWindow.tsx index aa4de1d..e3dcebf 100644 --- a/app/components/ChatWindow.tsx +++ b/app/components/ChatWindow.tsx @@ -1,49 +1,146 @@ "use client"; -import { useEffect, useState, useRef } from "react"; -import { useSocket } from "@/lib/useSocket"; +import { useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { AnimatePresence, motion } from "framer-motion"; +import { + Bookmark, + Check, + Clipboard, + Copy, + Download, + FileArchive, + FileCode2, + FileText, + Ghost, + HelpCircle, + Image as ImageIcon, + Link as LinkIcon, + Megaphone, + MoreVertical, + Paperclip, + Pencil, + Palette, + Reply, + Send, + SmilePlus, + Trash2, + Wrench, + X, +} from "lucide-react"; + +import { useSocket } from "@/lib/useSocket"; import { cn } from "@/lib/utils"; -import { MoreVertical, Pencil, Trash2, Send, Check, X } from "lucide-react"; +import PollCard from "./chat-tools/PollCard"; +import PollModal from "./chat-tools/PollModal"; +import ThemeSelector, { themeClass } from "./chat-tools/ThemeSelector"; +import WhiteboardModal from "./chat-tools/WhiteboardModal"; +import type { ChatThemeId, Poll } from "./chat-tools/types"; interface Props { conversationId: string; selfId: string; selfName: string; selfEmail?: string; - // Optional — shown in the channel-start banner at the top of the scroll area. channelName?: string; channelDescription?: string; } +type Attachment = { + url: string; + secureUrl?: string; + name: string; + type: string; + size: number; + format?: string; + resourceType?: string; + progress?: number; + uploading?: boolean; + error?: string; +}; + interface Message { _id: string; - // DB-saved messages use `sender`; locally-emitted socket payloads use - // `senderId`. Read both, normalize at render time. + conversation?: string; sender?: string; senderId?: string; senderName?: string; body: string; image?: string; + messageType?: "poll" | "text"; + pollId?: string; + attachments?: Attachment[]; createdAt: string; + ghost?: boolean; + ghostExpiresAt?: string; + reactions?: Record; + bookmarks?: string[]; isEditing?: boolean; deleted?: boolean; edited?: boolean; } +type Tab = "chat" | "files" | "media" | "links"; + +const QUICK_REACTIONS = ["❤️", "👍", "🔥", "🚀", "👀", "😂"]; +const DRAFT_PREFIX = "vibexcode.chat.draft."; +const GHOST_MS = 24 * 60 * 60 * 1000; + +const COMMANDS = [ + { + name: "/poll", + label: "Poll", + hint: "Create a lightweight markdown poll", + icon: Clipboard, + apply: () => "**Poll:** What should we build next?\n- [ ] Option A\n- [ ] Option B", + }, + { + name: "/status", + label: "Status", + hint: "Set your visible custom status", + icon: SmilePlus, + apply: () => "/status ", + }, + { + name: "/shrug", + label: "Shrug", + hint: "Insert the classic developer shrug", + icon: HelpCircle, + apply: () => "¯\\_(ツ)_/¯", + }, + { + name: "/announce", + label: "Announce", + hint: "Format an announcement", + icon: Megaphone, + apply: () => "### Announcement\n", + }, + { + name: "/help", + label: "Help", + hint: "Show command help", + icon: HelpCircle, + apply: () => + "**Commands**\n\n`/poll`, `/status`, `/shrug`, `/announce`, `/help`", + }, +]; + function initials(name: string): string { return ( name .split(/\s+|@/) .filter(Boolean) - .map((p) => p[0]) + .map((part) => part[0]) .join("") .toUpperCase() .slice(0, 2) || "?" ); } -// Deterministic gradient per sender — same person always gets the same color. function colorFor(sender: string | undefined): string { const palette = [ "from-rose-500 to-pink-600", @@ -55,26 +152,156 @@ function colorFor(sender: string | undefined): string { ]; const key = sender || ""; let hash = 0; - for (let i = 0; i < key.length; i++) { + for (let i = 0; i < key.length; i += 1) { hash = (hash * 31 + key.charCodeAt(i)) | 0; } return palette[Math.abs(hash) % palette.length]; } -// Read the sender id from whichever field the source provided. -function senderIdOf(m: { sender?: string; senderId?: string }): string { - return m.sender || m.senderId || ""; +function senderIdOf(message: { sender?: string; senderId?: string }): string { + return message.sender || message.senderId || ""; } function formatTime(iso: string): string { - try { - return new Date(iso).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return ""; - } + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function dateLabel(iso: string): string { + const date = new Date(iso); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + if (date.toDateString() === today.toDateString()) return "Today"; + if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + year: date.getFullYear() === today.getFullYear() ? undefined : "numeric", + }); +} + +function formatSize(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 3); + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function timeRemaining(iso?: string): string { + if (!iso) return "Expires soon"; + const ms = new Date(iso).getTime() - Date.now(); + if (ms <= 0) return "Expired"; + const hours = Math.floor(ms / (60 * 60 * 1000)); + const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000)); + return `Expires in ${hours}h ${minutes}m`; +} + +function extractLinks(messages: Message[]) { + const linkPattern = /https?:\/\/[^\s<>()]+/g; + return messages.flatMap((message) => + [...(message.body || "").matchAll(linkPattern)].map((match) => ({ + url: match[0], + messageId: message._id, + senderName: message.senderName || "Unknown", + createdAt: message.createdAt, + })) + ); +} + +function attachmentKind(file: Attachment) { + if (file.type.startsWith("image/")) return "image"; + if (file.type.startsWith("video/")) return "video"; + if (/zip|compressed|archive/i.test(file.type) || /\.zip$/i.test(file.name)) + return "archive"; + if (/javascript|typescript|json|python|java|code|text/i.test(file.type)) + return "code"; + return "document"; +} + +function FileIcon({ file }: { file: Attachment }) { + const kind = attachmentKind(file); + if (kind === "image") return ; + if (kind === "archive") return ; + if (kind === "code") return ; + return ; +} + +function MarkdownMessage({ body }: { body: string }) { + return ( + ( + + {children} + + ), + code: ({ inline, className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ""); + const text = String(children).replace(/\n$/, ""); + + if (inline) { + return ( + + {children} + + ); + } + + return ( +
    +
    + {match?.[1] || "code"} + +
    + + {text} + +
    + ); + }, + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + }} + > + {body} +
    + ); } export default function ChatWindow({ @@ -86,13 +313,87 @@ export default function ChatWindow({ channelDescription, }: Props) { const socket = useSocket(); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const messagesEndRef = useRef(null); + const draftKey = `${DRAFT_PREFIX}${conversationId}`; + + const [tab, setTab] = useState("chat"); const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); + const [attachments, setAttachments] = useState([]); + const [polls, setPolls] = useState>({}); + const [ghostMode, setGhostMode] = useState(false); const [editedBodies, setEditedBodies] = useState>({}); - const messagesEndRef = useRef(null); const [openMenuFor, setOpenMenuFor] = useState(null); + const [quickBarFor, setQuickBarFor] = useState(null); const [loading, setLoading] = useState(true); const [notice, setNotice] = useState(null); + const [commandIndex, setCommandIndex] = useState(0); + const [lightbox, setLightbox] = useState(null); + const [toolsOpen, setToolsOpen] = useState(false); + const [emojiOpen, setEmojiOpen] = useState(false); + const [pollModalOpen, setPollModalOpen] = useState(false); + const [whiteboardOpen, setWhiteboardOpen] = useState(false); + const [themeSelectorOpen, setThemeSelectorOpen] = useState(false); + const [chatTheme, setChatTheme] = useState("default"); + const [, forceClock] = useState(0); + + const visibleMessages = useMemo( + () => + messages.filter( + (message) => + !message.ghostExpiresAt || + new Date(message.ghostExpiresAt).getTime() > Date.now() + ), + [messages] + ); + + const allAttachments = useMemo( + () => + visibleMessages.flatMap((message) => + (message.attachments || []).map((file) => ({ ...file, message })) + ), + [visibleMessages] + ); + const media = allAttachments.filter( + (file) => file.type.startsWith("image/") || file.type.startsWith("video/") + ); + const files = allAttachments.filter( + (file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") + ); + const links = useMemo(() => extractLinks(visibleMessages), [visibleMessages]); + + const slashQuery = input.startsWith("/") ? input.slice(1).toLowerCase() : ""; + const commandMenuOpen = input.startsWith("/") && !input.includes("\n"); + const filteredCommands = COMMANDS.filter( + (command) => + command.name.slice(1).includes(slashQuery) || + command.label.toLowerCase().includes(slashQuery) + ); + + useEffect(() => { + setInput(localStorage.getItem(draftKey) || ""); + }, [draftKey]); + + useEffect(() => { + localStorage.setItem(draftKey, input); + }, [draftKey, input]); + + useEffect(() => { + let cancelled = false; + axios + .get(`/api/user/theme?userId=${encodeURIComponent(selfId)}`) + .then((res) => { + if (!cancelled) setChatTheme((res.data?.theme || "default") as ChatThemeId); + }) + .catch(() => { + if (!cancelled) setChatTheme("default"); + }); + return () => { + cancelled = true; + }; + }, [selfId]); useEffect(() => { if (!notice) return; @@ -100,6 +401,11 @@ export default function ChatWindow({ return () => clearTimeout(id); }, [notice]); + useEffect(() => { + const id = setInterval(() => forceClock((value) => value + 1), 60 * 1000); + return () => clearInterval(id); + }, []); + useEffect(() => { const fetchMessages = async () => { setLoading(true); @@ -121,20 +427,41 @@ export default function ChatWindow({ socket.emit("join", { conversationId }); socket.on("message", (msg: Message) => { setMessages((prev) => - prev.some((m) => m._id === msg._id) ? prev : [...prev, msg] + prev.some((message) => message._id === msg._id) ? prev : [...prev, msg] ); }); + socket.on("poll:update", ({ poll }: { poll: Poll }) => { + if (!poll?._id) return; + setPolls((prev) => ({ ...prev, [poll._id]: poll })); + }); + socket.on("poll:expire", ({ pollId }: { pollId: string }) => { + setPolls((prev) => { + const poll = prev[pollId]; + if (!poll) return prev; + return { + ...prev, + [pollId]: { ...poll, expiresAt: new Date().toISOString() }, + }; + }); + }); + socket.on("theme:update", ({ userId, theme }: { userId: string; theme: ChatThemeId }) => { + if (userId === selfId) setChatTheme(theme); + }); return () => { socket.emit("leave", { conversationId }); socket.off("message"); + socket.off("poll:update"); + socket.off("poll:expire"); + socket.off("theme:update"); }; - }, [socket, conversationId]); + }, [socket, conversationId, selfId]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + if (tab === "chat") { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [visibleMessages, tab]); - // Close the message-actions popover when clicking anywhere else. useEffect(() => { if (!openMenuFor) return; const close = () => setOpenMenuFor(null); @@ -142,9 +469,48 @@ export default function ChatWindow({ return () => document.removeEventListener("click", close); }, [openMenuFor]); + const applyCommand = (index: number) => { + const command = filteredCommands[index]; + if (!command) return; + setInput(command.apply()); + setCommandIndex(0); + requestAnimationFrame(() => textareaRef.current?.focus()); + }; + + const sendStatus = async (status: string) => { + if (!selfEmail) return; + await fetch("/api/user/heartbeat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: selfEmail, + customStatus: status, + activity: "typing", + device: /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) + ? "Mobile" + : "Desktop", + }), + }); + }; + const handleSend = async () => { const body = input.trim(); - if (!body) return; + const readyAttachments = attachments.filter((file) => !file.uploading && !file.error); + if (!body && readyAttachments.length === 0) return; + + if (body.startsWith("/status")) { + const status = body.replace(/^\/status\s*/i, "").trim(); + if (!status) { + setNotice("Type a status after /status."); + return; + } + await sendStatus(status); + setInput(""); + localStorage.removeItem(draftKey); + setNotice("Status updated."); + return; + } + setNotice(null); const messageData = { conversationId, @@ -152,18 +518,20 @@ export default function ChatWindow({ senderName: selfName, senderEmail: selfEmail, body, + ghost: ghostMode, + attachments: readyAttachments, }; + try { - const res = await axios.post( - `/api/messages/${conversationId}`, - messageData - ); + const res = await axios.post(`/api/messages/${conversationId}`, messageData); const saved = res.data as Message; setMessages((prev) => - prev.some((m) => m._id === saved._id) ? prev : [...prev, saved] + prev.some((message) => message._id === saved._id) ? prev : [...prev, saved] ); - socket?.emit("message", saved); + socket?.emit("message", { ...saved, conversationId }); setInput(""); + setAttachments([]); + localStorage.removeItem(draftKey); } catch (err) { const errorMsg = axios.isAxiosError(err) && err.response?.data?.error @@ -174,12 +542,71 @@ export default function ChatWindow({ } }; - const handleEditClick = (id: string, body: string) => { - setMessages((prev) => - prev.map((msg) => (msg._id === id ? { ...msg, isEditing: true } : msg)) - ); - setEditedBodies((prev) => ({ ...prev, [id]: body })); - setOpenMenuFor(null); + const handleUpload = (selectedFiles: FileList | File[]) => { + [...selectedFiles].slice(0, 6).forEach((file) => { + const localId = `${file.name}-${file.size}-${Date.now()}`; + const preview: Attachment = { + url: URL.createObjectURL(file), + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + progress: 0, + uploading: true, + }; + setAttachments((prev) => [...prev, preview]); + + const form = new FormData(); + form.append("file", file); + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/chat/upload"); + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return; + const progress = Math.round((event.loaded / event.total) * 100); + setAttachments((prev) => + prev.map((item) => + item.name === file.name && item.size === file.size && item.url === preview.url + ? { ...item, progress } + : item + ) + ); + }; + xhr.onload = () => { + try { + const data = JSON.parse(xhr.responseText); + if (xhr.status >= 400) throw new Error(data.error || "Upload failed"); + setAttachments((prev) => + prev.map((item) => + item.name === file.name && item.size === file.size && item.url === preview.url + ? { ...data, progress: 100, uploading: false } + : item + ) + ); + } catch (error) { + setAttachments((prev) => + prev.map((item) => + item.name === file.name && item.size === file.size && item.url === preview.url + ? { + ...item, + uploading: false, + error: error instanceof Error ? error.message : "Upload failed", + } + : item + ) + ); + } + }; + xhr.onerror = () => { + setAttachments((prev) => + prev.map((item) => + item.name === file.name && item.size === file.size && item.url === preview.url + ? { ...item, uploading: false, error: "Upload failed" } + : item + ) + ); + }; + xhr.setRequestHeader("X-Upload-Id", localId); + xhr.send(form); + }); }; const handleEditSubmit = async (id: string) => { @@ -204,17 +631,6 @@ export default function ChatWindow({ } }; - const handleEditCancel = (id: string) => { - setMessages((prev) => - prev.map((msg) => (msg._id === id ? { ...msg, isEditing: false } : msg)) - ); - setEditedBodies((prev) => { - const copy = { ...prev }; - delete copy[id]; - return copy; - }); - }; - const handleDelete = async (id: string) => { try { await axios.delete(`/api/message/${id}?senderId=${selfId}`); @@ -231,233 +647,813 @@ export default function ChatWindow({ setOpenMenuFor(null); }; + const toggleReaction = async (messageId: string, emoji: string) => { + setMessages((prev) => + prev.map((message) => { + if (message._id !== messageId) return message; + const reactions = { ...(message.reactions || {}) }; + const current = reactions[emoji] || []; + reactions[emoji] = current.includes(selfId) + ? current.filter((id) => id !== selfId) + : [...current, selfId]; + if (reactions[emoji].length === 0) delete reactions[emoji]; + return { ...message, reactions }; + }) + ); + await axios.patch(`/api/message/${messageId}`, { + senderId: selfId, + action: "reaction", + emoji, + }); + }; + + const toggleBookmark = async (messageId: string) => { + setMessages((prev) => + prev.map((message) => { + if (message._id !== messageId) return message; + const bookmarks = message.bookmarks || []; + return { + ...message, + bookmarks: bookmarks.includes(selfId) + ? bookmarks.filter((id) => id !== selfId) + : [...bookmarks, selfId], + }; + }) + ); + await axios.patch(`/api/message/${messageId}`, { + senderId: selfId, + action: "bookmark", + }); + }; + + const handlePollCreated = ({ poll, message }: { poll: Poll; message: Message }) => { + setPolls((prev) => ({ ...prev, [poll._id]: poll })); + setMessages((prev) => + prev.some((item) => item._id === message._id) ? prev : [...prev, message] + ); + socket?.emit("poll:create", { conversationId, poll, message }); + }; + + const handlePollVote = (poll: Poll) => { + setPolls((prev) => ({ ...prev, [poll._id]: poll })); + socket?.emit("poll:vote", { conversationId, poll }); + }; + + const updateTheme = async (theme: ChatThemeId) => { + setChatTheme(theme); + await axios.post("/api/user/theme", { userId: selfId, theme }); + socket?.emit("theme:update", { userId: selfId, theme }); + }; + + const renderAttachments = (messageAttachments: Attachment[] = []) => { + if (messageAttachments.length === 0) return null; + return ( +
    + {messageAttachments.map((file) => { + const url = file.secureUrl || file.url; + if (file.type.startsWith("image/")) { + return ( + + ); + } + if (file.type.startsWith("video/")) { + return ( +
    + ); + }; + return ( -
    - {/* Messages — outer scrolls, inner uses min-h-full + justify-end so - a sparse conversation hugs the bottom near the composer instead of - floating at the top. */} +
    event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + if (event.dataTransfer.files.length) handleUpload(event.dataTransfer.files); + }} + > +
    +
    + {(["chat", "files", "media", "links"] as Tab[]).map((item) => ( + + ))} +
    +
    +
    -
    - {/* Channel-start banner — Discord-style "this is the beginning of #channel" */} - {!loading && ( -
    -
    - # +
    + {notice && ( +
    +
    + {notice} +
    -

    - Welcome to #{channelName || conversationId}! -

    -

    - {channelDescription || - "This is the beginning of the conversation."} -

    -

    - This is the start of the channel. -

    -
    - )} - - {loading ? ( -

    - Loading messages... -

    - ) : messages.length === 0 ? ( -
    -

    - No messages yet — be the first to say hi 👋 -

    -
    - ) : ( - messages.map((m, i) => { - const mSender = senderIdOf(m); - const isSelf = mSender === selfId; - const editingValue = editedBodies[m._id] ?? ""; - const prev = messages[i - 1]; - const prevSender = prev ? senderIdOf(prev) : ""; - // Group consecutive messages from the same sender — only show - // avatar + name on the first one in a run. - const startsRun = - !prev || - prevSender !== mSender || - new Date(m.createdAt).getTime() - - new Date(prev.createdAt).getTime() > - 5 * 60 * 1000; - const senderName = m.senderName ?? mSender ?? "Unknown"; - const avatarColor = colorFor(mSender); + )} - return ( -
    - {/* Avatar (only for others, only at start of a run) */} - {notice && ( -
    -
    - {notice} + {tab === "chat" && ( + <> + {!loading && ( +
    +
    + # +
    +

    + Welcome to #{channelName || conversationId} +

    +

    + {channelDescription || "This is the beginning of the conversation."} +

    + )} + + {loading ? ( +

    + Loading messages... +

    + ) : visibleMessages.length === 0 ? ( +
    +

    + No messages yet. Start the thread. +

    +
    + ) : ( + + {visibleMessages.map((message, index) => { + const mSender = senderIdOf(message); + const isSelf = mSender === selfId; + const previous = visibleMessages[index - 1]; + const startsRun = + !previous || + senderIdOf(previous) !== mSender || + new Date(message.createdAt).getTime() - + new Date(previous.createdAt).getTime() > + 5 * 60 * 1000; + const showDate = + !previous || + new Date(previous.createdAt).toDateString() !== + new Date(message.createdAt).toDateString(); + const senderName = message.senderName || mSender || "Unknown"; + const editingValue = editedBodies[message._id] ?? ""; + const bookmarked = (message.bookmarks || []).includes(selfId); + + return ( +
    + {showDate && ( +
    + + + {dateLabel(message.createdAt)} + + +
    + )} + + toggleReaction(message._id, "❤️")} + > + {!isSelf && + (startsRun ? ( +
    + {initials(senderName)} +
    + ) : ( +
    + ))} + +
    + {!isSelf && startsRun && ( + + {senderName} + + )} + +
    setQuickBarFor(message._id)} + onMouseLeave={() => setQuickBarFor(null)} + > + {quickBarFor === message._id && !message.deleted && ( + + {QUICK_REACTIONS.map((emoji) => ( + + ))} + + )} + +
    + {message.isEditing && !message.deleted ? ( +
    + + setEditedBodies((prev) => ({ + ...prev, + [message._id]: event.target.value, + })) + } + onKeyDown={(event) => { + if (event.key === "Enter") + handleEditSubmit(message._id); + }} + className="flex-1 rounded bg-white/20 px-2 py-1 text-sm outline-none" + /> + + +
    + ) : ( + <> + {message.ghost && ( +
    + + Ghost Message +
    + )} + {message.messageType === "poll" && message.pollId ? ( + + ) : ( + <> +
    + +
    + {renderAttachments(message.attachments)} + + )} + + )} +
    + + {!message.isEditing && !message.deleted && ( + + )} + + {openMenuFor === message._id && ( +
    + + +
    + )} +
    + + {message.reactions && Object.keys(message.reactions).length > 0 && ( +
    + {Object.entries(message.reactions).map(([emoji, users]) => ( + + ))} +
    + )} + + + {message.ghost && ( + + {timeRemaining(message.ghostExpiresAt)} + + )} + {formatTime(message.createdAt)} + {message.edited && !message.deleted && ( + (edited) + )} + +
    + +
    + ); + })} + + )} +
    + + )} + + {tab === "files" && ( + +
    + {files.map((file) => ( + + + {file.name} + {formatSize(file.size)} + + + ))}
    - )} - {!isSelf && - (startsRun ? ( -
    + )} + + {tab === "media" && ( + +
    + {media.map((file) => + file.type.startsWith("image/") ? ( +
    + {file.name} + ) : ( -
    - ))} +
    +
    + )} + + {tab === "links" && ( + +
    + {links.map((link) => ( + + + {link.url} + {link.senderName} + + ))} +
    +
    + )} +
    +
    +
    +
    + {attachments.length > 0 && ( +
    + {attachments.map((file) => (
    - {/* Sender name (only on others, only first in run) */} - {!isSelf && startsRun && ( - - {senderName} - + + {file.name} + {file.uploading ? ( + {file.progress || 0}% + ) : file.error ? ( + {file.error} + ) : ( + {formatSize(file.size)} )} + +
    + ))} +
    + )} -
    -
    - {m.isEditing && !m.deleted ? ( -
    - - setEditedBodies((prev) => ({ - ...prev, - [m._id]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleEditSubmit(m._id); - if (e.key === "Escape") handleEditCancel(m._id); - }} - className="flex-1 bg-white/20 text-white placeholder-white/60 rounded px-2 py-1 outline-none focus:bg-white/30 text-sm" - /> - - -
    - ) : ( - m.body - )} -
    - - {/* Edit/delete menu — only on hover, only for own messages */} - {isSelf && !m.isEditing && !m.deleted && ( - + {commandMenuOpen && filteredCommands.length > 0 && ( +
    + {filteredCommands.map((command, index) => { + const Icon = command.icon; + return ( + + ); + })} +
    + )} - {openMenuFor === m._id && ( -
    - - -
    - )} -
    + {toolsOpen && ( +
    + + +
    + )} - {/* Timestamp + edited indicator */} - - {formatTime(m.createdAt)} - {m.edited && !m.deleted && ( - (edited) - )} + {themeSelectorOpen && ( +
    + +
    + )} + + {emojiOpen && ( +
    + {QUICK_REACTIONS.map((emoji) => ( + + ))} +
    + )} + +
    + { + if (event.target.files) handleUpload(event.target.files); + event.target.value = ""; + }} + /> + + + + + +
    +