Community — The Town Hall
District-scoped discussion boards. Membership isn't chosen — it's derived from the user's ZIP code, which is why the ZIP locks for 30 days once set.
Hubs: geography as membership
A hub is a constituency: the user's congressional district (cd:CA-52), state house and senate districts, state, and city. GET /community/hubs returns the set the signed-in user belongs to, derived server-side from their profile ZIP. That derivation is the reason for the two gates in front of the forum — signed out, or signed in without a ZIP, each shows an explanatory gate that routes to the account page — and for the 30-day ZIP lock described on the Accounts page: letting users hop ZIPs freely would let them hop into arbitrary district discussions.
Feeds
GET /community/posts?hub=…&cursor=… pages each hub's feed with an opaque cursor. CommunityService keeps a per-hub in-memory cache (feed list plus next cursor) with single-flight loading per hub, so switching hub chips back and forth doesn't refetch, while pull-to-refresh replaces the first page. Infinite scroll appends via a sentinel row that schedules the next page load outside the build phase — loadPosts notifies listeners, which must never run during build. All community state is memory-only; nothing is persisted, so a restart starts fresh.
Posting and anchors
Posts carry a title, body, up to five tags, and optionally one anchor: a bill or a representative the discussion is about. Anchors are chosen from local caches only (the reps already loaded on the home page, and each rep's cached bill highlights) — a deliberate "no new network surface" decision. A representative anchor is validated client-side for hub containment: a rep must actually represent the target hub's constituency for the post to link them, computed from seat kind and the user's own districts (senators reach every hub in their state; a house member reaches their district and its city; a governor reaches statewide). The server stores anchors as opaque display metadata, so this containment check is a UX guarantee, not a security boundary.
In the feed, a bill anchor renders as an informational chip, while a representative anchor is tappable: it resolves the rep against the local cache and opens their full card in a sheet — with every action wired, including Email. That sheet is how forum discussion loops back into the contact flow.
Interaction mechanics
Likes (PUT /community/likes) are optimistic: the cached post flips immediately, and a failed request rolls it back before rethrowing. Comments (GET/POST /community/posts/{id}/comments) come back as a flat list ordered oldest-first with a depth field; the UI indents by depth rather than reconstructing a tree. Posting a comment bumps the cached post's comment count without refetching the feed.
Moderation surfaces are minimal by design. A removed comment keeps its place in the thread — author renders as "[removed]", the body as a placeholder — so replies stay coherent, and its action menu disappears entirely. Users can report posts and comments (POST /community/reports) and delete their own; deletion of a post is confirmed with an explicit "removes the post and its comments for everyone" dialog. Reporting currently sends an empty reason string; there is no reason picker yet.
Where enforcement lives
Every community endpoint requires both device attestation and a bearer token, and the server enforces hub membership (not_hub_member), authorship (not_author), and input validity on every write. The client-side gates and containment checks exist to produce good error messages and prevent doomed requests, not to be the security boundary.
| Aspect | Decision |
|---|---|
| Hub kinds | cd, sh, ss, state, city |
| Pagination | Opaque server cursor; no page-size parameter |
| Feed cache | In-memory per hub; single-flight loads; no persistence |
| Post limits | Title ≤ 200 chars; ≤ 5 tags; body optional |
| Anchor id forms | Bill: bill number; rep: 7-char short UUID |
| Likes | Optimistic with rollback; comment likes parsed but not yet rendered |
| Removed comments | Redacted placeholder, position preserved |
Dormant surfaces: CommunityService.reset() is documented "call on sign-out" but nothing calls it yet, and setCommentLike has no UI caller — comment like counts are parsed and ready for a future release.