diff --git a/.env.example b/.env.example index 0cfe07a..30770bb 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,14 @@ JUDGE_BASE_URL="http://localhost:2000/api/v2" JUDGE_API_KEY="replace-with-proxy-api-key" PISTON_PYTHON_VERSION="3.10.0" PISTON_CPP_VERSION="10.2.0" + +# Ephemeral chat. Run `npm run chat:gateway` on a persistent host. +CHAT_REALTIME_TOKEN_SECRET="replace-with-a-random-secret" +CHAT_REALTIME_PUBLISH_SECRET="replace-with-a-different-random-secret" +CHAT_REALTIME_INTERNAL_URL="http://localhost:8787" +NEXT_PUBLIC_CHAT_WS_URL="ws://localhost:8787/socket" +CHAT_ALLOWED_ORIGINS="http://localhost:3000" +CHAT_GATEWAY_PORT="8787" + +# Vercel sends this bearer secret to authenticated cron routes. +CRON_SECRET="replace-with-a-random-secret" diff --git a/app/(auth)/join/page.tsx b/app/(auth)/join/page.tsx index 43f9694..78f22ed 100644 --- a/app/(auth)/join/page.tsx +++ b/app/(auth)/join/page.tsx @@ -53,6 +53,9 @@ export default async function JoinPage() { > Continue as applicant (dev) + + Continue as member (dev) + Continue as admin (dev) diff --git a/app/account-bar.tsx b/app/account-bar.tsx index 57dee86..3a7bf74 100644 --- a/app/account-bar.tsx +++ b/app/account-bar.tsx @@ -5,6 +5,7 @@ import { relativeTimeFromNow, unreadNotificationCount, } from "../lib/notifications"; +import MessageIndicator from "./message-indicator"; import NotificationBell, { type NotificationItem } from "./notification-bell"; import SiteHeader from "./site-header"; @@ -46,6 +47,7 @@ export default async function AccountBar({ > + ); diff --git a/app/api/chat/ack/route.ts b/app/api/chat/ack/route.ts new file mode 100644 index 0000000..34b0461 --- /dev/null +++ b/app/api/chat/ack/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { chatAckSchema } from "../../../../lib/chat"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { chatErrorResponse, unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { acknowledgeChatMessages } from "../../../../lib/chat-service"; + +export async function POST(request: Request) { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + try { + const parsed = chatAckSchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "INVALID_ACK" }, { status: 400 }); + + return NextResponse.json(await acknowledgeChatMessages(user.id, parsed.data.messageIds)); + } catch (error) { + return chatErrorResponse(error); + } +} diff --git a/app/api/chat/conversations/route.ts b/app/api/chat/conversations/route.ts new file mode 100644 index 0000000..d7a8a6a --- /dev/null +++ b/app/api/chat/conversations/route.ts @@ -0,0 +1,51 @@ +import { ChatConversationType } from "@/prisma-client"; +import { NextResponse } from "next/server"; +import { + createDirectConversationSchema, + createGroupConversationSchema, +} from "../../../../lib/chat"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { chatErrorResponse, unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { + createDirectConversation, + createGroupConversation, + listChatConversations, +} from "../../../../lib/chat-service"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + return NextResponse.json({ conversations: await listChatConversations(user.id) }); +} + +export async function POST(request: Request) { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + try { + const body = (await request.json()) as { type?: unknown }; + if (body.type === ChatConversationType.DIRECT) { + const parsed = createDirectConversationSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "INVALID_DIRECT" }, { status: 400 }); + const conversation = await createDirectConversation(user.id, parsed.data.recipientId); + return NextResponse.json({ conversationId: conversation.id }); + } + + if (body.type === ChatConversationType.GROUP) { + const parsed = createGroupConversationSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "INVALID_GROUP" }, { status: 400 }); + const conversation = await createGroupConversation( + user.id, + parsed.data.title, + parsed.data.memberIds, + ); + return NextResponse.json({ conversationId: conversation.id }, { status: 201 }); + } + + return NextResponse.json({ error: "INVALID_CONVERSATION_TYPE" }, { status: 400 }); + } catch (error) { + return chatErrorResponse(error); + } +} diff --git a/app/api/chat/inbox/route.ts b/app/api/chat/inbox/route.ts new file mode 100644 index 0000000..8b8befb --- /dev/null +++ b/app/api/chat/inbox/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { chatErrorResponse, unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { listPendingChatMessages } from "../../../../lib/chat-service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + try { + const cursor = new URL(request.url).searchParams.get("cursor")?.trim() || undefined; + return NextResponse.json(await listPendingChatMessages(user.id, cursor)); + } catch (error) { + return chatErrorResponse(error); + } +} diff --git a/app/api/chat/messages/route.ts b/app/api/chat/messages/route.ts new file mode 100644 index 0000000..862737b --- /dev/null +++ b/app/api/chat/messages/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { sendChatMessageSchema } from "../../../../lib/chat"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { chatErrorResponse, unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { publishChatEnvelope } from "../../../../lib/chat-realtime"; +import { enqueueChatMessage } from "../../../../lib/chat-service"; + +export async function POST(request: Request) { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + try { + const parsed = sendChatMessageSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: "INVALID_MESSAGE" }, { status: 400 }); + } + + const result = await enqueueChatMessage({ ...parsed.data, senderId: user.id }); + const realtime = + result.envelope && result.recipientIds.length > 0 + ? await publishChatEnvelope(result.recipientIds, result.envelope) + : { attempted: 0, delivered: 0, unavailable: false }; + + return NextResponse.json( + { + messageId: result.messageId, + conversationId: result.conversationId, + createdAt: result.createdAt, + duplicate: result.duplicate, + realtime, + }, + { status: result.duplicate ? 200 : 202 }, + ); + } catch (error) { + return chatErrorResponse(error); + } +} diff --git a/app/api/chat/read/route.ts b/app/api/chat/read/route.ts new file mode 100644 index 0000000..95825c6 --- /dev/null +++ b/app/api/chat/read/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { markChatReadSchema } from "../../../../lib/chat"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { chatErrorResponse, unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { markChatRead } from "../../../../lib/chat-service"; + +export async function POST(request: Request) { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + try { + const parsed = markChatReadSchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "INVALID_READ" }, { status: 400 }); + await markChatRead(user.id, parsed.data.conversationId); + return NextResponse.json({ ok: true }); + } catch (error) { + return chatErrorResponse(error); + } +} diff --git a/app/api/chat/report/route.ts b/app/api/chat/report/route.ts new file mode 100644 index 0000000..4574e34 --- /dev/null +++ b/app/api/chat/report/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { chatReportSchema } from "../../../../lib/chat"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { chatErrorResponse, unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { reportChatMessage } from "../../../../lib/chat-service"; + +export async function POST(request: Request) { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + try { + const parsed = chatReportSchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "INVALID_REPORT" }, { status: 400 }); + const report = await reportChatMessage({ ...parsed.data, reporterId: user.id }); + return NextResponse.json({ reportId: report.id }, { status: 201 }); + } catch (error) { + return chatErrorResponse(error); + } +} diff --git a/app/api/chat/token/route.ts b/app/api/chat/token/route.ts new file mode 100644 index 0000000..1540803 --- /dev/null +++ b/app/api/chat/token/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { activeChatUser } from "../../../../lib/chat-auth"; +import { unauthorizedChatResponse } from "../../../../lib/chat-http"; +import { createChatSocketToken } from "../../../../lib/chat-realtime"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + const user = await activeChatUser(); + if (!user) return unauthorizedChatResponse(); + + const token = await createChatSocketToken(user.id); + return NextResponse.json({ token, socketUrl: process.env.NEXT_PUBLIC_CHAT_WS_URL ?? null }); +} diff --git a/app/api/cron/chat-cleanup/route.ts b/app/api/cron/chat-cleanup/route.ts new file mode 100644 index 0000000..1117df9 --- /dev/null +++ b/app/api/cron/chat-cleanup/route.ts @@ -0,0 +1,27 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { cleanupExpiredChatData } from "../../../../lib/chat-service"; + +function authorized(request: Request) { + const expected = process.env.CRON_SECRET; + const authorization = request.headers.get("authorization"); + const provided = authorization?.startsWith("Bearer ") ? authorization.slice(7) : ""; + + if (!expected) return false; + const expectedBuffer = Buffer.from(expected); + const providedBuffer = Buffer.from(provided); + return ( + expectedBuffer.length === providedBuffer.length && + timingSafeEqual(expectedBuffer, providedBuffer) + ); +} + +export async function GET(request: Request) { + if (!authorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await cleanupExpiredChatData(); + console.info("Chat cleanup completed", result); + return NextResponse.json({ ok: true, ...result }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 0679c1a..f537798 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -17,6 +17,12 @@ export async function GET() { googleClientSecret: isConfigured(process.env.AUTH_GOOGLE_SECRET), judgeBaseUrl: process.env.JUDGE_PROVIDER === "fake" || isConfigured(process.env.JUDGE_BASE_URL), judgeApiKey: process.env.JUDGE_PROVIDER === "fake" || isConfigured(process.env.JUDGE_API_KEY), + chatRealtime: + process.env.NODE_ENV !== "production" || + (isConfigured(process.env.CHAT_REALTIME_TOKEN_SECRET) && + isConfigured(process.env.CHAT_REALTIME_PUBLISH_SECRET) && + isConfigured(process.env.CHAT_REALTIME_INTERNAL_URL) && + isConfigured(process.env.NEXT_PUBLIC_CHAT_WS_URL)), }; let database = false; @@ -44,6 +50,8 @@ export async function GET() { prisma.problem.count(), prisma.testCase.count(), prisma.submission.count(), + prisma.chatConversation.count(), + prisma.chatDelivery.count(), ]); schema = true; } catch (error) { diff --git a/app/dev-login/route.ts b/app/dev-login/route.ts index ca7f247..477f66d 100644 --- a/app/dev-login/route.ts +++ b/app/dev-login/route.ts @@ -36,10 +36,11 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } + const requestedRole = new URL(request.url).searchParams.get("role"); const role: LocalDevRole = - new URL(request.url).searchParams.get("role") === "admin" ? "admin" : "member"; + requestedRole === "admin" ? "admin" : requestedRole === "active" ? "active" : "member"; await signIn(localDevProviderId(role), { - redirectTo: role === "admin" ? "/admin/cohort" : "/apply", + redirectTo: role === "admin" ? "/admin/cohort" : role === "active" ? "/dashboard" : "/apply", }); } diff --git a/app/globals.css b/app/globals.css index 21a2e3f..41d4f2b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -319,7 +319,13 @@ img { margin-left: 8px; } -.notification-bell { +.message-indicator-wrap { + display: inline-flex; + margin-left: 8px; +} + +.notification-bell, +.message-indicator { position: relative; display: inline-grid; place-items: center; @@ -333,10 +339,16 @@ img { cursor: pointer; } -.notification-bell:hover { +.notification-bell:hover, +.message-indicator:hover { background: var(--surface-hover); } +.message-indicator.is-unread { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 20%, var(--surface)); +} + .notification-bell-badge { position: absolute; top: -6px; @@ -3117,6 +3129,418 @@ body.is-resizing-panes { } } +/* Ephemeral messaging workspace. */ +.chat-page { + width: min(1180px, calc(100% - 32px)); + min-height: calc(100vh - 112px); + margin: 0 auto; + padding: 24px 0; +} + +.chat-page + .feedback-button { + display: none; +} + +.chat-workspace { + display: grid; + grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); + height: min(760px, calc(100vh - 136px)); + min-height: 560px; + border: 2px solid var(--ink); + background: var(--surface); + box-shadow: 7px 7px 0 var(--shadow); + overflow: hidden; +} + +.chat-sidebar { + position: relative; + display: flex; + min-width: 0; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--paper); +} + +.chat-sidebar-header, +.chat-thread-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px; + border-bottom: 1px solid var(--line); +} + +.chat-sidebar-header h1, +.chat-thread-header h2 { + margin: 0; + font-size: 1.45rem; + line-height: 1.1; + letter-spacing: 0; +} + +.chat-sidebar-header .section-label, +.chat-thread-header p { + margin: 0 0 4px; + color: var(--muted); + font-size: 0.78rem; +} + +.chat-connection { + padding: 4px 8px; + border: 1px solid var(--line); + color: var(--muted); + font-family: var(--font-main); + font-size: 0.72rem; + font-weight: 800; +} + +.chat-connection.is-live { + border-color: var(--accent); + color: var(--ink); +} + +.chat-new-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--line); +} + +.chat-new-actions button, +.chat-compose-actions button, +.chat-back { + min-height: 36px; + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink); + font-family: var(--font-main); + font-weight: 800; + cursor: pointer; +} + +.chat-compose-panel { + position: absolute; + z-index: 4; + top: 143px; + right: 12px; + left: 12px; + display: grid; + gap: 10px; + max-height: calc(100% - 156px); + padding: 16px; + overflow-y: auto; + border: 1px solid var(--ink); + background: var(--paper); + box-shadow: 5px 5px 0 var(--shadow); +} + +.chat-compose-panel input, +.chat-compose-panel select, +.chat-send-form textarea { + width: 100%; + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink); + font: inherit; +} + +.chat-compose-panel input, +.chat-compose-panel select { + min-height: 42px; + padding: 8px 10px; +} + +.chat-member-picker { + max-height: min(220px, calc(100vh - 390px)); + overflow: auto; + border: 1px solid var(--line); +} + +.chat-member-picker label { + display: flex; + gap: 8px; + align-items: center; + min-height: 42px; + padding: 8px 10px; + border-bottom: 1px solid var(--line); + cursor: pointer; +} + +.chat-member-picker label:last-child { + border-bottom: 0; +} + +.chat-member-picker label:hover { + background: var(--surface-hover); +} + +.chat-member-picker input { + width: 18px; + height: 18px; + min-height: 18px; + flex: 0 0 18px; + margin: 0; + accent-color: var(--accent); +} + +.chat-compose-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.chat-compose-actions .button { + min-height: 38px; + padding: 0 14px; + box-shadow: 3px 3px 0 var(--shadow); +} + +.chat-conversation-list, +.chat-message-list { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.chat-conversation-list > button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; +} + +.chat-conversation-list > button:hover, +.chat-conversation-list > button.is-active { + background: var(--surface-hover); +} + +.chat-conversation-list > button.is-active { + box-shadow: inset 4px 0 0 var(--accent); +} + +.chat-conversation-copy { + min-width: 0; +} + +.chat-conversation-copy strong, +.chat-conversation-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-conversation-copy small { + margin-top: 4px; + color: var(--muted); +} + +.chat-unread-badge { + min-width: 22px; + height: 22px; + display: inline-grid; + place-items: center; + padding: 0 6px; + background: var(--accent); + border: 1px solid var(--ink); + font-size: 0.72rem; + font-weight: 900; +} + +.chat-thread { + min-width: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + background: var(--surface); +} + +.chat-back { + display: none; +} + +.chat-message-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px; +} + +.chat-message { + width: min(76%, 620px); + align-self: flex-start; + padding: 11px 13px; + border: 1px solid var(--line); + background: var(--paper); +} + +.chat-message.is-own { + align-self: flex-end; + border-color: var(--accent); + background: var(--surface-hover); +} + +.chat-message-meta { + display: flex; + justify-content: space-between; + gap: 14px; + color: var(--muted); + font-size: 0.75rem; +} + +.chat-message p { + margin: 7px 0 0; + color: var(--ink); + line-height: 1.45; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.chat-message small { + display: block; + margin-top: 5px; + color: var(--muted); +} + +.chat-message-error, +.chat-form-error { + color: var(--danger, #b42318) !important; +} + +.chat-send-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; + padding: 14px; + border-top: 1px solid var(--line); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.chat-send-form textarea { + min-height: 48px; + max-height: 150px; + padding: 10px 12px; + resize: vertical; +} + +.chat-send-form .button { + min-height: 48px; + padding: 0 18px; + box-shadow: 3px 3px 0 var(--shadow); +} + +.chat-form-error, +.chat-empty-copy { + margin: 0; + padding: 0 14px 12px; + font-size: 0.85rem; +} + +.chat-thread-empty { + align-self: center; + justify-self: center; + padding: 24px; + color: var(--muted); + text-align: center; +} + +.chat-thread-empty strong { + color: var(--ink); +} + +.chat-thread-empty p { + margin: 8px 0 0; +} + +@media (max-width: 720px) { + .chat-page { + width: 100%; + height: calc(100dvh - 184px); + min-height: 0; + padding: 0; + } + + .chat-workspace { + display: block; + height: 100%; + min-height: 0; + border-right: 0; + border-left: 0; + box-shadow: none; + } + + .chat-sidebar, + .chat-thread { + height: 100%; + } + + .chat-sidebar.has-selection { + display: none; + } + + .chat-thread:not(.is-open) { + display: none; + } + + .chat-thread.is-open { + display: grid; + } + + .chat-back { + display: inline-flex; + align-items: center; + padding: 0 10px; + } + + .chat-thread-header { + justify-content: flex-start; + } + + .chat-message { + width: 88%; + } + + .chat-compose-panel { + top: 143px; + max-height: calc(100% - 156px); + } + + .chat-member-picker { + max-height: min(280px, calc(100dvh - 440px)); + } + + .chat-send-form { + grid-template-columns: minmax(0, 1fr) auto; + padding: 10px; + } + + .chat-send-form .button { + min-width: 72px; + padding: 0 12px; + } +} + /* Application portal: sections, status tabs, and the cohort question editor. */ .portal-section { margin-top: 40px; diff --git a/app/members/[id]/page.tsx b/app/members/[id]/page.tsx index 7b5ecd8..3489d4b 100644 --- a/app/members/[id]/page.tsx +++ b/app/members/[id]/page.tsx @@ -52,6 +52,7 @@ export default async function MemberProfilePage({ session?.user?.id === member.id || (session?.user?.role === Role.ADMIN && session.user.status === UserStatus.ACTIVE); const canNudge = session?.user?.status === UserStatus.ACTIVE && session.user.id !== member.id; + const canMessage = session?.user?.status === UserStatus.ACTIVE && session.user.id !== member.id; const isOwnProfile = session?.user?.status === UserStatus.ACTIVE && session.user.id === member.id; const isAdmin = session?.user?.role === Role.ADMIN && session.user.status === UserStatus.ACTIVE; const badges = isAdmin ? await prisma.badge.findMany({ orderBy: { name: "asc" } }) : []; @@ -116,6 +117,11 @@ export default async function MemberProfilePage({ /> ) : null} + {canMessage ? ( + + Message + + ) : null}
diff --git a/app/message-indicator.tsx b/app/message-indicator.tsx new file mode 100644 index 0000000..d0e1f53 --- /dev/null +++ b/app/message-indicator.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { listLocalChatMessages } from "../lib/chat-client-db"; + +const REFRESH_INTERVAL_MS = 15_000; + +async function pendingMessageIds() { + const messageIds = new Set(); + let cursor: string | null = null; + + do { + const url = cursor ? `/api/chat/inbox?cursor=${encodeURIComponent(cursor)}` : "/api/chat/inbox"; + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) return messageIds; + + const page = (await response.json()) as { + messages?: Array<{ id?: unknown }>; + nextCursor?: string | null; + }; + + for (const message of page.messages ?? []) { + if (typeof message.id === "string") messageIds.add(message.id); + } + cursor = page.nextCursor ?? null; + } while (cursor); + + return messageIds; +} + +export default function MessageIndicator({ userId }: Readonly<{ userId: string }>) { + const [unreadCount, setUnreadCount] = useState(0); + + const refresh = useCallback(async () => { + if (typeof indexedDB === "undefined") return; + + try { + const [localMessages, pendingIds] = await Promise.all([ + listLocalChatMessages(userId), + pendingMessageIds(), + ]); + const unreadIds = new Set( + localMessages.filter((message) => message.unread).map((message) => message.id), + ); + for (const messageId of Array.from(pendingIds)) unreadIds.add(messageId); + setUnreadCount(unreadIds.size); + } catch { + // Realtime and the messages inbox remain usable if a badge refresh fails. + } + }, [userId]); + + useEffect(() => { + void refresh(); + const channel = + typeof BroadcastChannel === "undefined" + ? null + : new BroadcastChannel(`shardup-chat-${userId}`); + if (channel) channel.onmessage = () => void refresh(); + const interval = window.setInterval(() => void refresh(), REFRESH_INTERVAL_MS); + const onFocus = () => void refresh(); + window.addEventListener("focus", onFocus); + window.addEventListener("online", onFocus); + + return () => { + channel?.close(); + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + window.removeEventListener("online", onFocus); + }; + }, [refresh, userId]); + + const label = unreadCount > 0 ? `Messages, ${unreadCount} unread` : "Messages"; + + return ( +
+ 0 ? " is-unread" : ""}`} + href="/messages" + aria-label={label} + title={label} + > + + {unreadCount > 0 ? ( + + ) : null} + +
+ ); +} diff --git a/app/messages/layout.tsx b/app/messages/layout.tsx new file mode 100644 index 0000000..1560b38 --- /dev/null +++ b/app/messages/layout.tsx @@ -0,0 +1 @@ +export { default } from "../public-section-layout"; diff --git a/app/messages/messages-client.tsx b/app/messages/messages-client.tsx new file mode 100644 index 0000000..2953fdc --- /dev/null +++ b/app/messages/messages-client.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + createOptimisticChatMessage, + failOptimisticChatMessage, + listLocalChatMessages, + markChatAcknowledged, + markLocalConversationRead, + pendingChatAcknowledgements, + reconcileOptimisticChatMessage, + storeIncomingMessage, + type IncomingChatEnvelope, + type LocalChatMessage, + type LocalChatSender, +} from "../../lib/chat-client-db"; + +type MemberOption = LocalChatSender; +type ClientConversationType = "DIRECT" | "GROUP"; +type ClientMemberRole = "OWNER" | "MEMBER"; + +type ConversationSummary = { + id: string; + type: ClientConversationType; + title: string | null; + lastActivityAt: string; + members: Array; +}; + +type Props = { + currentUser: LocalChatSender; + conversations: ConversationSummary[]; + members: MemberOption[]; + initialConversationId: string | null; + initialRecipientId: string | null; +}; + +function conversationName(conversation: ConversationSummary, currentUserId: string) { + if (conversation.type === "GROUP") return conversation.title || "Group"; + return ( + conversation.members.find((member) => member.id !== currentUserId)?.name || "Direct message" + ); +} + +function isIncomingEnvelope(value: unknown): value is IncomingChatEnvelope { + if (!value || typeof value !== "object") return false; + const envelope = value as Partial; + return ( + typeof envelope.id === "string" && + typeof envelope.conversationId === "string" && + typeof envelope.body === "string" && + typeof envelope.createdAt === "string" && + typeof envelope.signature === "string" && + Boolean(envelope.sender && typeof envelope.sender.id === "string") + ); +} + +export default function MessagesClient({ + currentUser, + conversations, + members, + initialConversationId, + initialRecipientId, +}: Props) { + const router = useRouter(); + const [selectedId, setSelectedId] = useState( + initialConversationId && + conversations.some((conversation) => conversation.id === initialConversationId) + ? initialConversationId + : (conversations[0]?.id ?? null), + ); + const [localMessages, setLocalMessages] = useState([]); + const [connection, setConnection] = useState<"connecting" | "live" | "polling">("connecting"); + const [sendError, setSendError] = useState(null); + const [composeMode, setComposeMode] = useState<"closed" | "direct" | "group">("closed"); + const [creating, setCreating] = useState(false); + const startedRecipient = useRef(false); + const broadcastRef = useRef(null); + const selectedIdRef = useRef(selectedId); + + const refreshLocal = useCallback(async () => { + setLocalMessages(await listLocalChatMessages(currentUser.id)); + }, [currentUser.id]); + + const notifyLocalChange = useCallback(() => { + broadcastRef.current?.postMessage("changed"); + void refreshLocal(); + }, [refreshLocal]); + + const flushAcknowledgements = useCallback(async () => { + const messageIds = await pendingChatAcknowledgements(currentUser.id); + if (messageIds.length === 0) return; + + for (let offset = 0; offset < messageIds.length; offset += 100) { + const batch = messageIds.slice(offset, offset + 100); + const response = await fetch("/api/chat/ack", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messageIds: batch }), + }); + if (response.ok) await markChatAcknowledged(currentUser.id, batch); + } + }, [currentUser.id]); + + const storeAndAcknowledge = useCallback( + async (envelopes: IncomingChatEnvelope[]) => { + for (const envelope of envelopes) await storeIncomingMessage(currentUser.id, envelope); + const openConversationId = selectedIdRef.current; + if ( + openConversationId && + envelopes.some((envelope) => envelope.conversationId === openConversationId) + ) { + await markLocalConversationRead(currentUser.id, openConversationId); + } + await flushAcknowledgements(); + notifyLocalChange(); + }, + [currentUser.id, flushAcknowledgements, notifyLocalChange], + ); + + const recoverInbox = useCallback(async () => { + let cursor: string | null = null; + do { + const url = cursor + ? `/api/chat/inbox?cursor=${encodeURIComponent(cursor)}` + : "/api/chat/inbox"; + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) return; + const page = (await response.json()) as { + messages?: unknown[]; + nextCursor?: string | null; + }; + const envelopes = (page.messages ?? []).filter(isIncomingEnvelope); + if (envelopes.length > 0) await storeAndAcknowledge(envelopes); + cursor = page.nextCursor ?? null; + } while (cursor); + await flushAcknowledgements(); + }, [flushAcknowledgements, storeAndAcknowledge]); + + useEffect(() => { + void refreshLocal(); + const channel = new BroadcastChannel(`shardup-chat-${currentUser.id}`); + channel.onmessage = () => void refreshLocal(); + broadcastRef.current = channel; + return () => channel.close(); + }, [currentUser.id, refreshLocal]); + + useEffect(() => { + let socket: WebSocket | null = null; + let stopped = false; + let retryTimer: ReturnType | null = null; + let retryDelay = 1000; + + const connect = async () => { + setConnection("connecting"); + try { + const response = await fetch("/api/chat/token", { method: "POST" }); + if (!response.ok) throw new Error("Token unavailable"); + const credentials = (await response.json()) as { token: string; socketUrl: string | null }; + if (!credentials.socketUrl) { + setConnection("polling"); + await recoverInbox(); + return; + } + + socket = new WebSocket(credentials.socketUrl, ["shardup-chat", credentials.token]); + socket.onopen = () => { + retryDelay = 1000; + setConnection("live"); + void recoverInbox(); + }; + socket.onmessage = (event) => { + try { + const payload = JSON.parse(String(event.data)) as { type?: string; envelope?: unknown }; + if (payload.type === "message" && isIncomingEnvelope(payload.envelope)) { + void storeAndAcknowledge([payload.envelope]); + } + } catch { + // Ignore malformed transport frames; the inbox remains authoritative. + } + }; + socket.onclose = () => { + if (stopped) return; + setConnection("polling"); + retryTimer = setTimeout(() => { + retryDelay = Math.min(retryDelay * 2, 30_000); + void connect(); + }, retryDelay); + }; + } catch { + setConnection("polling"); + await recoverInbox(); + } + }; + + void connect(); + const recovery = setInterval(() => void recoverInbox(), 15_000); + const onOnline = () => { + void recoverInbox(); + if (!socket || socket.readyState === WebSocket.CLOSED) void connect(); + }; + window.addEventListener("online", onOnline); + + return () => { + stopped = true; + clearInterval(recovery); + if (retryTimer) clearTimeout(retryTimer); + window.removeEventListener("online", onOnline); + socket?.close(); + }; + }, [currentUser.id, recoverInbox, storeAndAcknowledge]); + + useEffect(() => { + selectedIdRef.current = selectedId; + if (!selectedId) return; + void markLocalConversationRead(currentUser.id, selectedId).then(notifyLocalChange); + void fetch("/api/chat/read", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ conversationId: selectedId }), + }); + }, [currentUser.id, notifyLocalChange, selectedId]); + + useEffect(() => { + if (!initialRecipientId || startedRecipient.current) return; + startedRecipient.current = true; + void (async () => { + const response = await fetch("/api/chat/conversations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "DIRECT", + recipientId: initialRecipientId, + }), + }); + if (!response.ok) return; + const result = (await response.json()) as { conversationId: string }; + setSelectedId(result.conversationId); + router.replace(`/messages?conversation=${encodeURIComponent(result.conversationId)}`); + router.refresh(); + })(); + }, [initialRecipientId, router]); + + const selected = conversations.find((conversation) => conversation.id === selectedId) ?? null; + const selectedMessages = localMessages.filter((message) => message.conversationId === selectedId); + const latestByConversation = useMemo(() => { + const latest = new Map(); + for (const message of localMessages) latest.set(message.conversationId, message); + return latest; + }, [localMessages]); + + const selectConversation = (conversationId: string) => { + setSelectedId(conversationId); + router.replace(`/messages?conversation=${encodeURIComponent(conversationId)}`, { + scroll: false, + }); + }; + + const sendMessage = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedId) return; + const form = event.currentTarget; + const formData = new FormData(form); + const body = String(formData.get("body") ?? "").trim(); + if (!body) return; + + setSendError(null); + form.reset(); + const clientMessageId = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + await createOptimisticChatMessage(currentUser.id, { + conversationId: selectedId, + clientMessageId, + sender: currentUser, + body, + createdAt, + }); + notifyLocalChange(); + + try { + const response = await fetch("/api/chat/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ conversationId: selectedId, clientMessageId, body }), + }); + if (!response.ok) throw new Error("Message was not accepted"); + const result = (await response.json()) as { messageId: string; createdAt: string }; + await reconcileOptimisticChatMessage( + currentUser.id, + clientMessageId, + result.messageId, + result.createdAt, + ); + notifyLocalChange(); + router.refresh(); + } catch { + await failOptimisticChatMessage(currentUser.id, clientMessageId); + setSendError("Message was not sent. Check your connection and try again."); + notifyLocalChange(); + } + }; + + const createConversation = async (event: React.FormEvent) => { + event.preventDefault(); + setCreating(true); + const form = event.currentTarget; + const formData = new FormData(form); + const payload = + composeMode === "direct" + ? { type: "DIRECT", recipientId: String(formData.get("recipientId")) } + : { + type: "GROUP", + title: String(formData.get("title")), + memberIds: formData.getAll("memberIds").map(String), + }; + + const response = await fetch("/api/chat/conversations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + setCreating(false); + if (!response.ok) { + setSendError("Conversation could not be created."); + return; + } + const result = (await response.json()) as { conversationId: string }; + setComposeMode("closed"); + setSelectedId(result.conversationId); + router.push(`/messages?conversation=${encodeURIComponent(result.conversationId)}`); + router.refresh(); + }; + + return ( +
+
+ + +
+ {selected ? ( + <> +
+ +
+

{conversationName(selected, currentUser.id)}

+

+ {selected.type === "GROUP" + ? `${selected.members.length} members` + : "Private conversation"} +

+
+
+
+ {selectedMessages.length ? ( + selectedMessages.map((message) => ( +
+
+ {message.outgoing ? "You" : message.sender.name} + +
+

{message.body}

+ {message.status === "sending" ? Sending... : null} + {message.status === "failed" ? ( + Not sent + ) : null} +
+ )) + ) : ( +
+ No messages on this browser +

Delivered history stays on the device that received it.

+
+ )} +
+
+ +