diff --git a/apps/admin/src/features/inbox/inbox-feed.test.ts b/apps/admin/src/features/inbox/inbox-feed.test.ts new file mode 100644 index 0000000..2ffc7fa --- /dev/null +++ b/apps/admin/src/features/inbox/inbox-feed.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs" + +import { describe, expect, it } from "vitest" +import { INBOX_FEED_FILTER_LABELS, InboxFeedFilterSchema } from "@civfix/shared" + +import { + INBOX_EMPTY_COPY, + INBOX_FEED_FILTER_ORDER, + emailFocusKey, + feedKey, + isInboxFeedFilter, + parseFeedKey, + replyOriginLabel, + resolveFeedSelection, +} from "./inbox-feed" + +describe("inbox feed keys", () => { + it("keys an item by source and id and parses it back", () => { + expect(feedKey({ source: "reply", id: "m1" })).toBe("reply:m1") + expect(parseFeedKey("reply:m1")).toEqual({ source: "reply", id: "m1" }) + expect(parseFeedKey(emailFocusKey("e1"))).toEqual({ source: "email", id: "e1" }) + }) + + it("rejects keys that name no feed source or no id", () => { + for (const key of ["e1", "thread:t1", "email:", ":e1"]) expect(parseFeedKey(key)).toBeNull() + }) +}) + +describe("selected feed item", () => { + const picked = { item: { source: "reply" as const, id: "m1", unread: true }, view: "unread:" } + const listed = { ...picked.item, unread: false } + + it("reads the listed row, and keeps the picked row once marking it read drops it", () => { + expect(resolveFeedSelection(new Map([["reply:m1", listed]]), picked, "reply:m1", "unread:")).toBe(listed) + expect(resolveFeedSelection(new Map(), picked, "reply:m1", "unread:")).toBe(picked.item) + }) + + it("resolves nothing for an unlisted key unless it was picked under the same filter and search", () => { + expect(resolveFeedSelection(new Map(), picked, "reply:m1", "replies:")).toBeUndefined() + expect(resolveFeedSelection(new Map(), picked, "reply:m1", "unread:city")).toBeUndefined() + expect(resolveFeedSelection(new Map(), picked, "reply:m2", "unread:")).toBeUndefined() + expect(resolveFeedSelection(new Map(), null, "email:e1", "unread:")).toBeUndefined() + }) +}) + +describe("inbox feed filters", () => { + it("offers every contract filter, in contract order, with a label and empty copy", () => { + expect(INBOX_FEED_FILTER_ORDER).toEqual(InboxFeedFilterSchema.options) + for (const filter of INBOX_FEED_FILTER_ORDER) { + expect(INBOX_FEED_FILTER_LABELS[filter]).toBeTruthy() + expect(INBOX_EMPTY_COPY[filter].title).toBeTruthy() + expect(INBOX_EMPTY_COPY[filter].sub).toBeTruthy() + } + }) + + it("does not treat an Outreach chip as an inbox filter", () => { + expect(isInboxFeedFilter("review")).toBe(true) + for (const chip of ["in", "out", "attn"]) expect(isInboxFeedFilter(chip)).toBe(false) + }) +}) + +describe("reply rows", () => { + it("names what the reply answers", () => { + expect(replyOriginLabel({ reportId: "r1", cleanupId: null })).toBe("Report reply") + expect(replyOriginLabel({ reportId: null, cleanupId: "c1" })).toBe("Event reply") + expect(replyOriginLabel({ reportId: null, cleanupId: null })).toBe("Thread reply") + }) +}) + +describe("inbound mail rendering", () => { + it("renders sender-controlled mail as text, never as markup", () => { + const sources = [ + "./inbox-views.tsx", + "../mail/mail-page.tsx", + "../mail/mail-badges.tsx", + ].map((path) => readFileSync(new URL(path, import.meta.url), "utf8")) + for (const source of sources) { + expect(source).not.toMatch(/dangerouslySetInnerHTML|innerHTML|bodyHtml/) + } + }) +}) diff --git a/apps/admin/src/features/inbox/inbox-feed.ts b/apps/admin/src/features/inbox/inbox-feed.ts new file mode 100644 index 0000000..0bea9c5 --- /dev/null +++ b/apps/admin/src/features/inbox/inbox-feed.ts @@ -0,0 +1,55 @@ +import { + InboxFeedFilterSchema, + type InboxFeedFilter, + type InboxFeedItemDTO, + type InboxFeedReplyItemDTO, +} from "@civfix/shared" + +type FeedSource = InboxFeedItemDTO["source"] + +export const INBOX_FEED_FILTER_ORDER: readonly InboxFeedFilter[] = InboxFeedFilterSchema.options + +export const INBOX_EMPTY_COPY: Record = { + all: { title: "Inbox is empty", sub: "City replies and other mail sent to civfix show up here." }, + unread: { title: "All caught up", sub: "No unread mail." }, + replies: { title: "No replies yet", sub: "Replies to report and event forwards show up here." }, + review: { title: "Nothing to review", sub: "Replies held back from a report chat or event show up here." }, + unmatched: { title: "No other mail", sub: "Mail that isn't a reply to a forward shows up here." }, + archived: { title: "Nothing archived", sub: "Archived mail shows up here." }, +} + +export function feedKey(item: { source: FeedSource; id: string }): string { + return `${item.source}:${item.id}` +} + +export function emailFocusKey(id: string): string { + return feedKey({ source: "email", id }) +} + +export function parseFeedKey(key: string): { source: FeedSource; id: string } | null { + const at = key.indexOf(":") + const source = key.slice(0, at) + const id = key.slice(at + 1) + if (at < 1 || !id || (source !== "email" && source !== "reply")) return null + return { source, id } +} + +export function resolveFeedSelection( + feedByKey: ReadonlyMap, + picked: { item: T; view: string } | null, + selKey: string | null, + view: string, +): T | undefined { + const listed = selKey ? feedByKey.get(selKey) : undefined + return listed ?? (picked?.view === view && feedKey(picked.item) === selKey ? picked.item : undefined) +} + +export function isInboxFeedFilter(value: string): value is InboxFeedFilter { + return InboxFeedFilterSchema.safeParse(value).success +} + +export function replyOriginLabel(item: Pick): string { + if (item.reportId) return "Report reply" + if (item.cleanupId) return "Event reply" + return "Thread reply" +} diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx index b8f7b9e..a92a518 100644 --- a/apps/admin/src/features/inbox/inbox-views.tsx +++ b/apps/admin/src/features/inbox/inbox-views.tsx @@ -3,16 +3,19 @@ import * as React from "react" import { INBOUND_EMAIL_STATUS_LABELS, + MAIL_STATUS_LABELS, relativeAgo, - type InboundEmailListItemDTO, type InboundEmailStatus, + type InboxFeedItemDTO, } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { useInboxMessage, useSetInboxStatus } from "@/features/inbox/use-inbox" -import { AuthVerdictBadge } from "@/features/mail/mail-badges" +import { replyOriginLabel } from "@/features/inbox/inbox-feed" +import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" +import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" import { useToast } from "@/store/ui-store" @@ -27,10 +30,11 @@ export function InboxRow({ selected, onClick, }: { - item: InboundEmailListItemDTO + item: InboxFeedItemDTO selected: boolean onClick: () => void }) { + const isEmail = item.source === "email" return (
- {item.from || "(unknown sender)"} + + {(isEmail ? item.from : item.org || item.from) || "(unknown sender)"} + {relativeAgo(item.ts)} @@ -56,14 +62,26 @@ export function InboxRow({
- {item.localPart || item.recipient} + {isEmail ? item.localPart || item.recipient : replyOriginLabel(item)} {" "} {item.preview}
- - {INBOUND_EMAIL_STATUS_LABELS[item.status]} - + {isEmail ? ( + + {INBOUND_EMAIL_STATUS_LABELS[item.status]} + + ) : item.publication ? ( + + ) : ( + + {MAIL_STATUS_LABELS[item.threadStatus]} + + )}
) } diff --git a/apps/admin/src/features/inbox/use-inbox.test.ts b/apps/admin/src/features/inbox/use-inbox.test.ts new file mode 100644 index 0000000..a3a4b8b --- /dev/null +++ b/apps/admin/src/features/inbox/use-inbox.test.ts @@ -0,0 +1,65 @@ +import { QueryClient } from "@tanstack/react-query" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { InboxFeedResponse } from "@civfix/shared" + +import type * as apiModule from "@/lib/api" +import { api } from "@/lib/api" +import { invalidateMail } from "@/features/mail/use-mail" + +import { inboxFeedQueryOptions } from "./use-inbox" + +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: { listInboxFeed: vi.fn() }, +})) + +const listInboxFeed = vi.mocked(api.listInboxFeed) + +const reply = { + source: "reply", + id: "m1", + threadId: "t1", + reportId: "r1", + cleanupId: null, + org: "City of Springfield", + from: "clerk@springfield.gov", + subject: "Re: pothole", + preview: "Crew is scheduled", + ts: "2026-09-22T10:00:00.000Z", + unread: true, + threadStatus: "needs_action", + hasAttachments: false, + authVerdict: "fail", + publication: "withheld", +} as const satisfies InboxFeedResponse["items"][number] + +describe("inbox feed query", () => { + beforeEach(() => listInboxFeed.mockReset()) + + it("pages the feed with its filter and search until the server cursor runs out", async () => { + listInboxFeed + .mockResolvedValueOnce({ items: [reply], nextCursor: "c1" }) + .mockResolvedValueOnce({ items: [{ ...reply, id: "m2" }], nextCursor: null }) + const qc = new QueryClient() + const params = { filter: "review", q: "pothole" } as const + + const data = await qc.fetchInfiniteQuery({ ...inboxFeedQueryOptions(params), pages: 3 }) + + expect(listInboxFeed.mock.calls.map(([input]) => input)).toEqual([ + { filter: "review", q: "pothole" }, + { filter: "review", q: "pothole", cursor: "c1" }, + ]) + expect(data.pages.flatMap((p) => p.items.map((item) => item.id))).toEqual(["m1", "m2"]) + }) + + it("refreshes when a thread changes, since reply rows carry thread state", async () => { + listInboxFeed.mockResolvedValue({ items: [reply], nextCursor: null }) + const qc = new QueryClient() + const options = inboxFeedQueryOptions({ filter: "all" }) + await qc.fetchInfiniteQuery(options) + + invalidateMail(qc, "t1") + + expect(qc.getQueryState(options.queryKey)?.isInvalidated).toBe(true) + }) +}) diff --git a/apps/admin/src/features/inbox/use-inbox.ts b/apps/admin/src/features/inbox/use-inbox.ts index d7c6536..22914d4 100644 --- a/apps/admin/src/features/inbox/use-inbox.ts +++ b/apps/admin/src/features/inbox/use-inbox.ts @@ -1,8 +1,15 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + infiniteQueryOptions, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import type { GetInboxMessageResponse, + InboxFeedQuery, InboxListQuery, InboxListResponse, SetInboxStatusRequest, @@ -32,6 +39,23 @@ export function useInboxListInfinite(params: InboxListQuery) { }) } +export function inboxFeedQueryOptions(params: InboxFeedQuery) { + return infiniteQueryOptions({ + queryKey: queryKeys.inbox.feed(params), + queryFn: ({ pageParam }) => + api.listInboxFeed({ + ...params, + ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }) +} + +export function useInboxFeedInfinite(params: InboxFeedQuery) { + return useInfiniteQuery(inboxFeedQueryOptions(params)) +} + export function useInboxMessage(id: string | null) { return useQuery({ queryKey: queryKeys.inbox.detail(id ?? ""), diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index fd9e3ff..0ae47a0 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -4,8 +4,10 @@ import * as React from "react" import { DEFAULT_FORWARD_BODY_TEMPLATE, DEFAULT_FORWARD_SUBJECT_TEMPLATE, + INBOX_FEED_FILTER_LABELS, MAIL_STATUS_LABELS, relativeAgo, + type InboxFeedItemDTO, type MailMessageDTO, type MailThreadDTO, type MailThreadListItemDTO, @@ -32,8 +34,17 @@ import { useSetMailStatus, } from "@/features/mail/use-mail" import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" -import { useInboxListInfinite, useSetInboxStatus } from "@/features/inbox/use-inbox" +import { useInboxFeedInfinite, useSetInboxStatus } from "@/features/inbox/use-inbox" import { InboxRow, InboxReader } from "@/features/inbox/inbox-views" +import { + INBOX_EMPTY_COPY, + INBOX_FEED_FILTER_ORDER, + emailFocusKey, + feedKey, + isInboxFeedFilter, + parseFeedKey, + resolveFeedSelection, +} from "@/features/inbox/inbox-feed" import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" import { useNav, useToast } from "@/store/ui-store" @@ -49,8 +60,6 @@ const BOX_LABEL: Record = { in: "Inbound", out: "Outbound", attn: "Needs attention", - unread: "Unread", - archived: "Archived", } const DELIVERY_BADGE = { @@ -78,7 +87,9 @@ function DeliveryBadge({ delivery }: { delivery: MailMessageDTO["delivery"] }) { function parseFocus(focusId: string | null): { folder: Folder; id: string | null } { if (!focusId) return { folder: "outreach", id: null } - if (focusId.startsWith("inbox:")) return { folder: "inbox", id: focusId.slice("inbox:".length) } + if (focusId.startsWith("inbox:")) { + return { folder: "inbox", id: emailFocusKey(focusId.slice("inbox:".length)) } + } return { folder: "outreach", id: focusId } } @@ -230,7 +241,7 @@ function MailRow({ ) } -function MailReader({ threadId }: { threadId: string }) { +function MailReader({ threadId, eventId = null }: { threadId: string; eventId?: string | null }) { const q = useMailThread(threadId) const nav = useNav() const toast = useToast() @@ -317,7 +328,7 @@ function MailReader({ threadId }: { threadId: string }) { {MAIL_STATUS_LABELS[sel.status]} - {(sel.jurisdictionGeoid || sel.reportId) && ( + {(sel.jurisdictionGeoid || sel.reportId || eventId) && (
{sel.jurisdictionGeoid && ( )} + {eventId && ( + + )}
)} @@ -462,11 +483,21 @@ function MailReader({ threadId }: { threadId: string }) { ) } +function InboxFeedReader({ selKey, item }: { selKey: string; item: InboxFeedItemDTO | undefined }) { + if (item?.source === "reply") { + return + } + const parsed = parseFeedKey(selKey) + if (parsed?.source === "email") return + return } /> +} + export function MailPage({ focusId }: SectionPageProps) { const initial = parseFocus(focusId) const [folder, setFolder] = React.useState(initial.folder) const [box, setBox] = React.useState("all") const [selId, setSelId] = React.useState(initial.id) + const [selItem, setSelItem] = React.useState<{ item: InboxFeedItemDTO; view: string } | null>(null) const [composeOpen, setComposeOpen] = React.useState(false) const [templateOpen, setTemplateOpen] = React.useState(false) const outreach = folder === "outreach" @@ -495,34 +526,31 @@ export function MailPage({ focusId }: SectionPageProps) { } : {}, ) - const inboxListQuery = useInboxListInfinite( - !outreach - ? { - status: - box === "unread" - ? ("unread" as const) - : box === "archived" - ? ("archived" as const) - : ("all" as const), - q, - } - : { status: "all" as const }, + const feedFilter = isInboxFeedFilter(box) ? box : "all" + const feedView = `${feedFilter}:${q ?? ""}` + const inboxFeedQuery = useInboxFeedInfinite( + !outreach ? { filter: feedFilter, q } : { filter: "all" }, ) const mailItems = React.useMemo( () => mailListQuery.data?.pages.flatMap((p) => p.items) ?? [], [mailListQuery.data], ) - const inboxItems = React.useMemo( - () => inboxListQuery.data?.pages.flatMap((p) => p.items) ?? [], - [inboxListQuery.data], + const feedItems = React.useMemo( + () => inboxFeedQuery.data?.pages.flatMap((p) => p.items) ?? [], + [inboxFeedQuery.data], ) - const activeListQuery = outreach ? mailListQuery : inboxListQuery - const activeCount = outreach ? mailItems.length : inboxItems.length + const feedByKey = React.useMemo( + () => new Map(feedItems.map((item) => [feedKey(item), item])), + [feedItems], + ) + const activeListQuery = outreach ? mailListQuery : inboxFeedQuery + const activeCount = outreach ? mailItems.length : feedItems.length const activeIds = React.useMemo( - () => (outreach ? mailItems.map((t) => t.id) : inboxItems.map((i) => i.id)), - [outreach, mailItems, inboxItems], + () => (outreach ? mailItems.map((t) => t.id) : [...feedByKey.keys()]), + [outreach, mailItems, feedByKey], ) + const selFeedItem = outreach ? undefined : resolveFeedSelection(feedByKey, selItem, selId, feedView) const statsQuery = useMailStats() const stats = statsQuery.data @@ -536,9 +564,11 @@ export function MailPage({ focusId }: SectionPageProps) { } }, [focusId]) React.useEffect(() => { - if (!selId && activeIds.length) setSelId(activeIds[0]!) - if (selId && activeIds.length && !activeIds.includes(selId)) setSelId(activeIds[0]!) - }, [activeIds, selId]) + const listed = selId ? feedByKey.get(selId) : undefined + if (listed) setSelItem({ item: listed, view: feedView }) + if (!activeIds.length || (selId && (activeIds.includes(selId) || selFeedItem))) return + setSelId(activeIds[0]!) + }, [activeIds, selId, selFeedItem, feedByKey, feedView]) const switchFolder = (next: Folder) => { if (next === folder) return @@ -555,8 +585,10 @@ export function MailPage({ focusId }: SectionPageProps) { const row = mailItems.find((t) => t.id === id) if (row?.unread) markRead.mutate({ id }) } else { - const row = inboxItems.find((i) => i.id === id) - if (row?.unread) setInboxStatus.mutate({ id, status: "read" }) + const item = feedByKey.get(id) + if (!item?.unread) return + if (item.source === "email") setInboxStatus.mutate({ id: item.id, status: "read" }) + else markRead.mutate({ id: item.threadId }) } } @@ -582,11 +614,7 @@ export function MailPage({ focusId }: SectionPageProps) { { value: "out", label: "Outbound" }, { value: "attn", label: "Needs attention" }, ] - : [ - { value: "all", label: "All" }, - { value: "unread", label: "Unread" }, - { value: "archived", label: "Archived" }, - ] + : INBOX_FEED_FILTER_ORDER.map((f) => ({ value: f, label: INBOX_FEED_FILTER_LABELS[f] })) return ( <> @@ -594,8 +622,8 @@ export function MailPage({ focusId }: SectionPageProps) { title="Mail" subtitle={ - Two-way outreach with municipal contacts, plus catch-all inbound to{" "} - *@civfix.org. + Two-way outreach with municipal contacts. The Inbox collects city replies and all other + mail sent to civfix. } > @@ -694,7 +722,7 @@ export function MailPage({ focusId }: SectionPageProps) { setQuery(e.target.value)} /> @@ -704,7 +732,7 @@ export function MailPage({ focusId }: SectionPageProps) {
-

{BOX_LABEL[box] ?? "All"}

+

{outreach ? (BOX_LABEL[box] ?? "All") : INBOX_FEED_FILTER_LABELS[feedFilter]}

{activeCount}
@@ -720,11 +748,13 @@ export function MailPage({ focusId }: SectionPageProps) { sub="Try a different search." icon={} /> + ) : outreach ? ( + } /> ) : ( : } + title={INBOX_EMPTY_COPY[feedFilter].title} + sub={INBOX_EMPTY_COPY[feedFilter].sub} + icon={} /> ) ) : ( @@ -738,14 +768,17 @@ export function MailPage({ focusId }: SectionPageProps) { onClick={() => select(t.id)} /> )) - : inboxItems.map((i) => ( - select(i.id)} - /> - ))} + : feedItems.map((item) => { + const key = feedKey(item) + return ( + select(key)} + /> + ) + })} {activeListQuery.hasNextPage && (