From 4e2098d9b4e470a4c4cd89e23aa27b2cd99f56cc Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 00:40:32 -0700 Subject: [PATCH 1/4] mail reader shows sender verification and reply publication --- apps/admin/src/features/inbox/inbox-views.tsx | 2 + apps/admin/src/features/mail/mail-badges.tsx | 44 +++++++++++++++++++ apps/admin/src/features/mail/mail-page.tsx | 29 ++++++------ .../features/mail/mail-presentation.test.ts | 44 +++++++++++++++++++ .../src/features/mail/mail-presentation.ts | 35 +++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 apps/admin/src/features/mail/mail-badges.tsx create mode 100644 apps/admin/src/features/mail/mail-presentation.test.ts create mode 100644 apps/admin/src/features/mail/mail-presentation.ts diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx index 8f507e3..b8f7b9e 100644 --- a/apps/admin/src/features/inbox/inbox-views.tsx +++ b/apps/admin/src/features/inbox/inbox-views.tsx @@ -12,6 +12,7 @@ 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 { useToast } from "@/store/ui-store" @@ -93,6 +94,7 @@ export function InboxReader({ id }: { id: string }) { {sel.from} · to {sel.recipient} + {INBOUND_EMAIL_STATUS_LABELS[sel.status]} diff --git a/apps/admin/src/features/mail/mail-badges.tsx b/apps/admin/src/features/mail/mail-badges.tsx new file mode 100644 index 0000000..cefd8d7 --- /dev/null +++ b/apps/admin/src/features/mail/mail-badges.tsx @@ -0,0 +1,44 @@ +"use client" + +import { + MAIL_AUTH_VERDICT_LABELS, + MAIL_REPLY_PUBLICATION_LABELS, + type MailAuthVerdict, + type MailReplyPublication, +} from "@civfix/shared" + +import { + AUTH_VERDICT_VIEW, + PUBLICATION_CLS, + publicationTitle, +} from "@/features/mail/mail-presentation" + +export function AuthVerdictBadge({ verdict }: { verdict: MailAuthVerdict | null | undefined }) { + if (!verdict) return null + const view = AUTH_VERDICT_VIEW[verdict] + return ( + + {MAIL_AUTH_VERDICT_LABELS[verdict]} + + ) +} + +export function PublicationBadge({ + publication, + isReport, + className = "", +}: { + publication: MailReplyPublication | null | undefined + isReport: boolean + className?: string +}) { + if (!publication) return null + return ( + + {MAIL_REPLY_PUBLICATION_LABELS[publication]} + + ) +} diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index daf660c..fd9e3ff 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -7,7 +7,6 @@ import { MAIL_STATUS_LABELS, relativeAgo, type MailMessageDTO, - type MailStatus, type MailThreadDTO, type MailThreadListItemDTO, } from "@civfix/shared" @@ -35,6 +34,8 @@ import { import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" import { useInboxListInfinite, useSetInboxStatus } from "@/features/inbox/use-inbox" import { InboxRow, InboxReader } from "@/features/inbox/inbox-views" +import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" +import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" import { useNav, useToast } from "@/store/ui-store" import { toAppError } from "@/lib/api" import { errorMessage } from "@/lib/error-messages" @@ -43,16 +44,6 @@ import type { SectionPageProps } from "@/components/shell/page-registry" type Folder = "outreach" | "inbox" -const STATUS_CLS: Record = { - replied: "status-ok", - delivered: "status-ok", - auto: "status-progress", - opened: "status-progress", - sent: "status-progress", - needs_action: "status-flag", - bounced: "status-flag", -} - const BOX_LABEL: Record = { all: "All", in: "Inbound", @@ -232,7 +223,7 @@ function MailRow({
{item.subject || "(no subject)"}
{item.preview}
- + {MAIL_STATUS_LABELS[item.status]} @@ -322,7 +313,7 @@ function MailReader({ threadId }: { threadId: string }) { )} - + {MAIL_STATUS_LABELS[sel.status]} @@ -366,7 +357,17 @@ function MailReader({ threadId }: { threadId: string }) { {ts(msg.ts)} - {isOut && } + {isOut ? ( + + ) : ( + <> + + + + )}

{msg.body}

{msg.truncated && ( diff --git a/apps/admin/src/features/mail/mail-presentation.test.ts b/apps/admin/src/features/mail/mail-presentation.test.ts new file mode 100644 index 0000000..f1ae311 --- /dev/null +++ b/apps/admin/src/features/mail/mail-presentation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest" +import { + MAIL_AUTH_VERDICT_LABELS, + MAIL_REPLY_PUBLICATION_LABELS, + MailAuthVerdictSchema, + MailReplyPublicationSchema, + MailStatusSchema, +} from "@civfix/shared" + +import { + AUTH_VERDICT_VIEW, + MAIL_STATUS_CLS, + PUBLICATION_CLS, + publicationTitle, +} from "./mail-presentation" + +describe("mail badges", () => { + it("styles every mail status, verdict and publication state", () => { + for (const status of MailStatusSchema.options) expect(MAIL_STATUS_CLS[status]).toBeTruthy() + for (const verdict of MailAuthVerdictSchema.options) { + expect(AUTH_VERDICT_VIEW[verdict].cls).toBeTruthy() + expect(AUTH_VERDICT_VIEW[verdict].title).toBeTruthy() + expect(MAIL_AUTH_VERDICT_LABELS[verdict]).toBeTruthy() + } + for (const publication of MailReplyPublicationSchema.options) { + expect(PUBLICATION_CLS[publication]).toBeTruthy() + expect(MAIL_REPLY_PUBLICATION_LABELS[publication]).toBeTruthy() + } + }) + + it("flags a failed sender check and a withheld reply", () => { + expect(AUTH_VERDICT_VIEW.fail.cls).toBe("status-flag") + expect(AUTH_VERDICT_VIEW.pass.cls).toBe("status-ok") + expect(PUBLICATION_CLS.withheld).toBe("status-flag") + expect(PUBLICATION_CLS.published).toBe("status-ok") + }) + + it("says where a published reply went", () => { + expect(publicationTitle("published", true)).toBe("Posted to the report chat and timeline.") + expect(publicationTitle("published", false)).toBe("Added to the event timeline.") + expect(publicationTitle("withheld", true)).toMatch(/Not posted publicly/) + expect(publicationTitle("pending", false)).toMatch(/posted shortly/) + }) +}) diff --git a/apps/admin/src/features/mail/mail-presentation.ts b/apps/admin/src/features/mail/mail-presentation.ts new file mode 100644 index 0000000..99df5c0 --- /dev/null +++ b/apps/admin/src/features/mail/mail-presentation.ts @@ -0,0 +1,35 @@ +import type { MailAuthVerdict, MailReplyPublication, MailStatus } from "@civfix/shared" + +export const MAIL_STATUS_CLS: Record = { + replied: "status-ok", + delivered: "status-ok", + auto: "status-progress", + opened: "status-progress", + sent: "status-progress", + needs_action: "status-flag", + bounced: "status-flag", +} + +export const AUTH_VERDICT_VIEW: Record = { + pass: { cls: "status-ok", title: "The sender's domain passed authentication." }, + fail: { + cls: "status-flag", + title: "This message failed sender authentication and may be forged.", + }, + unknown: { + cls: "status-progress", + title: "No authentication result was recorded for this message.", + }, +} + +export const PUBLICATION_CLS: Record = { + withheld: "status-flag", + pending: "status-progress", + published: "status-ok", +} + +export function publicationTitle(publication: MailReplyPublication, isReport: boolean): string { + if (publication === "withheld") return "Not posted publicly. Open the thread to review it." + if (publication === "pending") return "Approved. It will be posted shortly." + return isReport ? "Posted to the report chat and timeline." : "Added to the event timeline." +} From d019ec89b7e3716a7cc026a279c2e4f477e0f17e Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 00:41:18 -0700 Subject: [PATCH 2/4] unified mail inbox with city replies --- .../src/features/inbox/inbox-feed.test.ts | 72 ++++++++++ apps/admin/src/features/inbox/inbox-feed.ts | 45 +++++++ apps/admin/src/features/inbox/inbox-views.tsx | 34 +++-- .../src/features/inbox/use-inbox.test.ts | 72 ++++++++++ apps/admin/src/features/inbox/use-inbox.ts | 26 +++- apps/admin/src/features/mail/mail-page.tsx | 123 +++++++++++------- apps/admin/src/features/mail/use-mail.ts | 3 +- apps/admin/src/lib/query.ts | 1 + 8 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 apps/admin/src/features/inbox/inbox-feed.test.ts create mode 100644 apps/admin/src/features/inbox/inbox-feed.ts create mode 100644 apps/admin/src/features/inbox/use-inbox.test.ts 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..679387b --- /dev/null +++ b/apps/admin/src/features/inbox/inbox-feed.test.ts @@ -0,0 +1,72 @@ +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, +} 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(feedKey({ source: "email", id: "e1" }))).toEqual({ source: "email", id: "e1" }) + }) + + it("maps the Home tile's inbox deep link onto an email key", () => { + expect(emailFocusKey("e1")).toBe("email:e1") + }) + + it("rejects keys that name no feed source or no id", () => { + expect(parseFeedKey("e1")).toBeNull() + expect(parseFeedKey("thread:t1")).toBeNull() + expect(parseFeedKey("email:")).toBeNull() + expect(parseFeedKey(":e1")).toBeNull() + }) +}) + +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) + expect(isInboxFeedFilter("in")).toBe(false) + expect(isInboxFeedFilter("out")).toBe(false) + expect(isInboxFeedFilter("attn")).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..ef8e6a4 --- /dev/null +++ b/apps/admin/src/features/inbox/inbox-feed.ts @@ -0,0 +1,45 @@ +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 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..f33c838 --- /dev/null +++ b/apps/admin/src/features/inbox/use-inbox.test.ts @@ -0,0 +1,72 @@ +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 { queryKeys } from "@/lib/query" +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) + }) +}) + +describe("inbox feed cache key", () => { + it("sits under the inbox key every inbox mutation invalidates", () => { + expect(queryKeys.inbox.feed({ filter: "all" }).slice(0, 2)).toEqual(queryKeys.inbox.all) + }) +}) 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..ae7fea2 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,16 @@ 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, +} 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 +59,6 @@ const BOX_LABEL: Record = { in: "Inbound", out: "Outbound", attn: "Needs attention", - unread: "Unread", - archived: "Archived", } const DELIVERY_BADGE = { @@ -78,7 +86,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 +240,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 +327,7 @@ function MailReader({ threadId }: { threadId: string }) { {MAIL_STATUS_LABELS[sel.status]}
- {(sel.jurisdictionGeoid || sel.reportId) && ( + {(sel.jurisdictionGeoid || sel.reportId || eventId) && (
{sel.jurisdictionGeoid && ( )} + {eventId && ( + + )}
)} @@ -462,6 +482,15 @@ 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) @@ -495,33 +524,28 @@ 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 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 statsQuery = useMailStats() @@ -555,8 +579,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 +608,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 +616,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 +716,7 @@ export function MailPage({ focusId }: SectionPageProps) { setQuery(e.target.value)} /> @@ -704,7 +726,7 @@ export function MailPage({ focusId }: SectionPageProps) {
-

{BOX_LABEL[box] ?? "All"}

+

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

{activeCount}
@@ -720,11 +742,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 +762,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 && (