import { data, redirect, useFetcher } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import { inArray, eq, and } from "drizzle-orm"; import type { Route } from "./+types/notifications"; import { getDb } from "~/lib/db"; import { getSessionUser } from "~/lib/auth/session.server"; import { listForUser } from "~/lib/notifications.server"; import { linkFor } from "~/lib/notifications/link-for"; import { readPayload } from "~/lib/notifications/payload"; import { countPendingFollowRequests, listPendingFollowRequests, } from "~/lib/follow.server"; import { ClientDate } from "~/components/ClientDate"; import { activities } from "@trails-cool/db/schema/journal"; type Tab = "activity" | "requests"; export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) throw redirect("/auth/login"); const url = new URL(request.url); const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity"; // Pending count drives the Requests tab dot regardless of which tab is // currently active, so we always fetch it. It's a single COUNT(*) query. const pendingCount = await countPendingFollowRequests(user.id); if (tab === "requests") { const requests = await listPendingFollowRequests(user.id); return data({ tab: "requests" as const, pendingCount, requests: requests.map((r) => ({ id: r.id, followerUsername: r.followerUsername, followerDisplayName: r.followerDisplayName, followerDomain: r.followerDomain, createdAt: r.createdAt.toISOString(), })), notifications: [] as NotificationRow[], nextCursor: null as string | null, }); } const before = url.searchParams.get("before") ?? undefined; const { rows, nextCursor } = await listForUser(user.id, { before }); // Renderer guard: drop activity_published rows whose subject is gone // or no longer public (visibility flipped from public → private/unlisted). // Fetch the still-public subject IDs in one query, then filter. const activitySubjectIds = rows .filter((r) => r.type === "activity_published" && r.subjectId) .map((r) => r.subjectId as string); let publicActivityIds = new Set(); if (activitySubjectIds.length > 0) { const db = getDb(); const visible = await db .select({ id: activities.id }) .from(activities) .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); publicActivityIds = new Set(visible.map((v) => v.id)); } const visibleRows = rows.filter((r) => { if (r.type !== "activity_published") return true; if (!r.subjectId) return false; return publicActivityIds.has(r.subjectId); }); return data({ tab: "activity" as const, pendingCount, requests: [] as RequestRow[], notifications: visibleRows.map((r) => { const link = linkFor({ type: r.type, subjectId: r.subjectId, payload: r.payload, payloadVersion: r.payloadVersion, }); const payload = readPayload(r.type, r.payloadVersion, r.payload); return { id: r.id, type: r.type, readAt: r.readAt?.toISOString() ?? null, createdAt: r.createdAt.toISOString(), link: link.web, payload, }; }), nextCursor, }); } export function meta(_args: Route.MetaArgs) { return [{ title: "Notifications — trails.cool" }]; } interface NotificationRow { id: string; type: string; readAt: string | null; createdAt: string; link: string; payload: Record | null; } interface RequestRow { id: string; followerUsername: string; followerDisplayName: string | null; followerDomain: string; createdAt: string; } function summary(t: (key: string, opts?: Record) => string, n: NotificationRow): string { const p = n.payload as { followerUsername?: string; followerDisplayName?: string | null; targetUsername?: string; targetDisplayName?: string | null; activityName?: string; ownerUsername?: string; ownerDisplayName?: string | null } | null; const someone = t("notifications.someone"); switch (n.type) { case "follow_request_received": { const name = p?.followerDisplayName ?? p?.followerUsername ?? someone; return t("notifications.summary.followRequestReceived", { name }); } case "follow_received": { const name = p?.followerDisplayName ?? p?.followerUsername ?? someone; return t("notifications.summary.followReceived", { name }); } case "follow_request_approved": { const name = p?.targetDisplayName ?? p?.targetUsername ?? someone; return t("notifications.summary.followRequestApproved", { name }); } case "activity_published": { const owner = p?.ownerDisplayName ?? p?.ownerUsername ?? someone; const activity = p?.activityName ?? ""; return t("notifications.summary.activityPublished", { owner, activity }); } default: return n.type; } } function NotificationItem({ row }: { row: NotificationRow }) { const { t } = useTranslation("journal"); const fetcher = useFetcher(); const inFlight = fetcher.state !== "idle"; const onClick = (e: React.MouseEvent) => { if (row.readAt) return; // already read; let the link navigate normally // Mark read in the background; navigation proceeds via the anchor. fetcher.submit(null, { method: "post", action: `/api/notifications/${row.id}/read`, }); void e; }; return (
  • {!row.readAt && (
    {inFlight && marking read}
  • ); } function RequestItem({ row }: { row: RequestRow }) { const { t } = useTranslation("journal"); const approve = useFetcher(); const reject = useFetcher(); const inFlight = approve.state !== "idle" || reject.state !== "idle"; return (
  • {row.followerDisplayName ?? row.followerUsername}

    @{row.followerUsername}@{row.followerDomain} ·{" "}

  • ); } function TabLink({ to, active, label, badge, }: { to: string; active: boolean; label: string; badge: number; }) { return ( {label} {badge > 0 && ( {badge} )} ); } export default function Notifications({ loaderData }: Route.ComponentProps) { const { tab, pendingCount, notifications, nextCursor, requests } = loaderData; const { t } = useTranslation("journal"); const markAll = useFetcher(); const hasUnread = notifications.some((n) => !n.readAt); return (

    {t("notifications.title")}

    {tab === "activity" && hasUnread && ( )}
    {tab === "activity" ? ( notifications.length === 0 ? (

    {t("notifications.empty")}

    ) : ( <>
      {notifications.map((n) => ( ))}
    {nextCursor && (
    {t("notifications.loadOlder")}
    )} ) ) : requests.length === 0 ? (

    {t("social.requests.empty")}

    ) : (
      {requests.map((r) => ( ))}
    )}
    ); }