Summary
Scout has a Reddit-shaped community layer bolted onto its patch catalog: a
general discussion feed, one discussion "board" per patch, threaded comments,
upvotes, and a per-patch "recommend" vouch. A patch's photos from scouts are
board posts: the patch's Gallery tab shows the board's posts that have
photos ("Scout posts"), and a scout posts one of their own visit photos to the
board straight from the photo lightbox ("Post from My Visit", built
2026-10-11, shipped in v1.1.106 — see Status). Since the quick photo post
(built 2026-10-25, not yet in a store release — see Status) adding photos is
one tap with no title or description: Gallery's Add photos posts library
picks, and the lightbox's + Post posts the visit photo on screen. Each add
is still a board post, so it collects upvotes and comments. The older per-patch
photo gallery (PatchPhoto) is retired from the new patch screen, not
deleted. All of
it is user-generated content (UGC), so it ships with the App Store Review
Guideline 1.2 UGC trio built directly into the backend: a pre-publication text
filter, a shared report/block action sheet on every content surface, and an
admin moderation queue that can delete content and suspend an author in one
action. Symmetric blocking is enforced in SQL on every read path (feed,
thread, photo list, board summary, search) and on every write path (vote,
comment, reply, like) — not just hidden client-side. None of this is behind a
feature flag. Everything except parent boards is shipped to every user (guests
read; posting asks for an account — see Status); parent boards are built but not
yet in a live store release (see Status).
Parent boards (built 2026-10-13; in the v1.1.108 build, not live yet): every campaign has a
board too — Route 66, National Parks, Civil War… A parent board shows the posts
made to the campaign itself and every human post on its member patch boards
(campaign → its non-admin-only collections → patch_collections). A post is
one row, so a post on a stop that sits in two campaigns (Petrified Forest is
in Route 66 and National Parks) appears on both boards with the same votes and
comments — posting to a stop is how you reach more people. The hub's feed is
now every board, with a Boards rail (your pinned boards first, then six
featured) and Sort & filter: My Patches' glass pull-down, with a shared
persisted sort (Newest / Top / Most comments) replacing the New/Top toggle on
every community feed. Design: mobile/research/community-boards-lab.html; spec:
docs/superpowers/specs/2026-10-13-community-parent-boards-design.md.
Post.isPinned is now also how announcements reach users. Release notes
were pinned official posts here (npm run release:announce) until 2026-09-27;
they now live on the What's new sheet, and no new RELEASE NOTES
posts are created (the old ones stay). There is still no admin UI
that toggles a pin — the endpoint and togglePin hook remain uncalled — but
pinning is no longer reachable only through auto-generated content.
Status (shipped / beta-badged / flagged off)
-
Post from My Visit — built 2026-10-11 (
62082888), shipped in v1.1.106 and live in the App Store's 1.1.107. Posting a visit photo to its board from the lightbox, Gallery's "Scout posts" tiles, the post lightbox with its comments sheet, and theposts.image_source_hashescolumn. The backend migration (backend/prisma/migrations/20261011120000_post_image_source_hashes) is deployed (the column exists in prod). It had to deploy before the app, because the globalValidationPipe(whitelist: true, noforbidNonWhitelisted,backend/src/app.module.ts:86) would silently stripimageSourceHasheson an older backend and the post would go up without them. No feature flag — it changes existing screens. Store builds before 1.1.106 still show the old patch gallery and its "Add to gallery" button. Its title-and-description sheet was replaced by the one-tap quick photo post on 2026-10-25 (next bullet). -
Quick photo post — built 2026-10-25, NOT in any store release yet, no feature flag (it changes the lightbox's + Post and Gallery's lead tile in place; design picked by Alan in
mobile/research/quick-photo-post-lab.html). A photo post needs no title:CreatePostDto.titleis optional (backend/src/community-posts/dto/index.ts:9-15) andcreatePostrequires "a title OR a photo" (backend/src/community-posts/community-posts.service.ts:486-491). The backend must deploy BEFORE the app release that sends empty titles — the backend in prod today rejects a post without a title (the old DTO's required@IsNotEmpty() @MinLength(1)title, a 400), so a new build against an old backend would fail every Add photos and + Post with the server's validation message and no Retry. Old app builds keep working against the new backend; they just draw a blank title line on a photo-only post (see Edge cases). -
Parent boards — built 2026-10-13 (
c3102564), in the v1.1.108 build but NOT yet live (the App Store has 1.1.107), no feature flag (they replace the hub, the patch board's sort and the post screen's board label in place). The backend migration (backend/prisma/migrations/20261013120000_post_campaign_id) and the four new routes must deploy before the app — the app callsGET /community/feed,GET /community/campaigns/summaryand the campaign board routes, which an older backend 404s.
Everything else below is fully shipped, unconditionally on. No feature flag gates any part of this —
absent from both the mobile flag registry (mobile/src/config/feature-flags.ts)
and the backend's flag definitions
(backend/src/admin/feature-flag-definitions.ts); grepped for
community/moderat/report/block/recommend/photo in both files and
found no matches. Gating is entirely by auth state (guest vs. signed-in) and by
admin role for pinning and the moderation queue. Guests can read, report and
block (ContentActionsProvider.tsx comment on why guests are deliberately
included); posting, commenting and voting ask a guest to create an account
— every one goes through useRequireAuth, whose isSignedIn is false for a
guest (mobile/src/hooks/useRequireAuth.ts, mobile/src/providers/authState.ts:43;
the composer at mobile/src/components/community/PostComposerScreen.tsx:171).
This doc said guests could post until 2026-10-13; that was wrong. Favoriting is
members-only too, see favorites. Pinning a parent board needs
no account — pins live on the device.
User-facing surfaces
- Community hub — parent boards edition (2026-10-13, in v1.1.108, not live yet) —
mobile/app/(drawer)/community.tsx:203(view model). Under the search box, a Boards rail (community-board-rail,:410): pinned parent boards first in the order pinned, then the six featured — Route 66, National Parks, National Forests, Civil War, 50 States, City Challenges (FEATURED_BOARD_IDS,mobile/src/domain/communityBoards.ts:11; a pinned or featured id that didn't sync is skipped,boardRailIds:20). Each tile is the Campaigns screen's framed medallion (BoardTile.tsx, no progress arc) with "N this week" / "N posts" / "No posts yet" fromGET /community/campaigns/summary; "All N ›" opens All boards. The feed is every board (GET /community/feed): General posts, posts made to parent boards and patch boards' human posts, each naming its board in the byline ("· Tulsa ›" opens the patch board; "· Route 66 ›" in the family accent opens the parent board; General names none). The New/Top tabs are gone: the glass Sort & filter button (community-filter) opens the shared pull-down (CommunityFilterMenu.tsx:15) — Sort Newest / Top / Most comments (persisted on the device and shared by every community feed,communityPrefs.sort,mobile/src/domain/store.ts:3087) and Filter → Boards (General + every campaign with its colour dot and post count; picks reset when the screen unmounts; a dot on the button is the only "filtered" sign). The line above the feed says what you're looking at: "From every board · Newest" or "Route 66 + National Parks · Top". A pick that matches nothing shows "Nothing matches" + Clear filters. The FAB still posts to General. - All boards —
mobile/app/community-boards.tsx:21,scout://community-boards. Every campaign's board as the Campaigns row (medallion, family kicker, name, "N posts · M this week" / "No posts yet"): Pinned, then Featured, then Every campaign in the app's order. - Parent board —
mobile/src/screens/CampaignBoardScreen.tsx:87, routemobile/app/community-board/[id].tsx,scout://community-board/<campaignId>. The patch board's frame one level up: the campaign photo full-bleed withBOARD · 8 STATES(the campaign theme's group label) in the family accent, then "N patch boards roll up here · Campaign ›", the campaign's collections as chips (the Ring's pills,PillRail), the sort line ("286 posts · Newest"), and the roll-up feed — the patch named in each byline, the board's own posts named nowhere. Top right: the glass filter (Filter → Boards: "<Campaign> itself" then every stop in collection order, one flat list) and the pin (glass off, filled with the family accent on): pinning puts the board first on the hub's rail and shows "Pinned. It's first in Boards now." with Undo (:157). States: "Nothing on <Campaign> yet / Ask about the whole trip, or post from one of its N patches. Posts on any of them show up here.", "Couldn’t load posts" + Try again, "Nothing matches" + Clear filters, and a not-found frame for an unknown campaign. FAB (family accent) → the parent-board composer. - Parent-board composer —
mobile/app/community-board-new/[id].tsx:19,scout://community-board-new/<campaignId>. The composer's chip reads "Posting to Route 66 board" and opensBoardPickerSheet(mobile/src/components/community/BoardPickerSheet.tsx:21): the whole board first, a search, then the stops grouped by collection. The board →POST /community/campaigns/:id/posts; a stop → that patch's board (which still rolls up here). - Cross-post hint on a patch composer —
mobile/app/post-create/[id].tsx:27: "Also shows on the National Parks and Route 66 boards. It's one post, so votes and comments from every board add up here." Absent for a patch in no campaign. - Post screen, parent-board edition — a campaign post's eyebrow is the
campaign name, linking to its board; a patch post in one or more campaigns
gets one quiet line under the thread, "Also on National Parks, Route 66",
each name in its family accent and opening that board
(
mobile/src/screens/PostScreen.tsx:607,AlsoOnLine.tsx). No chips, no explainer. - Patch board and the patch screen's Community tab — the New/Top
SortToggleis deleted; both sort from the glass menu (Sort only — nothing to filter):mobile/src/screens/PatchCommunityScreen.tsx:263,mobile/src/components/patch-detail-v2/tabs/CommunityTab.tsx:75. Their failure copy is now "Couldn’t load posts" + Try again, as on every board. - Community hub (before parent boards) —
mobile/app/(drawer)/community.tsx, deep linkscout://community. General (patch-less) feed sortable new/top and a live search box that queries local patch names instantly and backend posts on a 300ms debounce (mobile/src/hooks/useCommunitySearch.ts). Floating "New post" button. There is no favorited-boards strip — it was removed when favorites became a screen of their own; see favorites. The feed is an editorial stream, not a stack of cards (redesigned 2026-09-28, labmobile/research/community-unboxed-lab.html, feed 2): each post (PostCard.tsx) is a full-width entry on the canvas — aPINNEDeyebrow (PINNED · FROM SCOUTon official posts), the byline, a Fraunces title (post-card-title-<id>), two lines of body, the photos at gutter width, and plain-text actions (upvote count, "N comments", the overflow menu) — with a hairline (PostDivider) between entries. A photo-only post (empty title, quick photo post) has no title line — nothing stands in for it (mobile/src/components/community/PostCard.tsx:104-107). A post with several photos swipes sideways in the list, one 16:10 frame per page, and its1 / Ncounter follows the page (FeedPhotoPager.tsx, 2026-10-14); one photo is a still frame with no counter. A swipe opens nothing; a tap on a photo opens the post (not the lightbox), as do the words (pin, byline, title, body —post-card-open-<id>) and the "N comments" count. The entry itself is NOT one big press target any more: a Pressable around the photos took the touch first and the photos never slid on a device, so the pager sits outside every press target and each photo is a gesture-handler touchable. The ↑ count is its own button (post-card-upvote-<id>): it votes or takes the vote back in place, without opening the post — optimistic, settled on the server's numbers, rolled back silently on failure, a second tap ignored while one is in flight, and the "Sign in required" prompt for a guest (mobile/src/hooks/usePostVote.ts). The same post sits in several cached feeds (the hub, its parent board, its patch board), so the vote is written into every one (patchPostInFeeds,mobile/src/query/queries/community.ts). A vote shows as brass text — on a parent board, that board's family accent (PostCard'saccent, passed byCampaignBoardScreenthroughBoardFeed). Official posts have no vote. Sorting is the glass Sort & filter menu described above (the old New/TopSortToggleis deleted). Like every screen in the app, the community screens use the 16pt screen gutter (space.gutter,mobile/src/theme/space.ts). The patch-detail Community tab uses the same entries and tabs, and opens on the same board banner as the board screen (BoardBanner.tsx, shared by both; see patch-catalog-and-detail). - New general post —
mobile/app/community-new.tsx, deep linkscout://community-new. Thin wrapper aroundPostComposerScreenposting to the general board. - Per-patch board —
mobile/src/screens/PatchCommunityScreen.tsx, behind the routemobile/app/patch-community/[id].tsx(an eight-line re-export), deep linkscout://patch-community/<patchId>. Opens on a full-bleed banner of the patch's own photograph running under the status bar, with aBOARDeyebrow and the patch name over it (PhotoHero.tsx, testIDspatch-community-hero/patch-community-hero-image); a patch with no photo keeps the eyebrow and name and drops the picture. Floating over it: back (community-back) and a bookmark owned by favorites (testIDcommunity-favorite, thechromeIconButtonthe patch hero uses) — favoriting a board IS favoriting its patch, one row for both. Under it theRecommendBar, unboxed: heart, "N scouts recommend it", and the Recommend/Recommended toggle. Then the sort line (sorted from the glass Sort & filter button) and the editorial feed. Floating "New post" button routes to/post-create/<patchId>. - Post composer —
mobile/app/post-create/[id].tsx(patch board) andmobile/app/community-new.tsx(general board), both wrappingPostComposerScreen. Title, optional body, up toMAX_ATTACHED_IMAGES(10) photos, and a context chip naming the board. The Post button's only gate is a non-empty trimmed title — the body and the photos are optional, and nothing on the screen says why the button is grey. A failed post and a failed upload are both a nativeAlert; the screen has no error text, no retry and no confirmation panel. This composer still requires a title even though the server no longer does for a photo post — the untitled path is the quick photo post (Gallery's Add photos and the lightbox's + Post), never this screen. Since 2026-10-25 it is reached from the Community tab/board and the hub only; Gallery no longer links to it. - Post detail —
mobile/src/screens/PostScreen.tsx, behind the routemobile/app/post/[id].tsx(an eight-line re-export), deep linkscout://post/<id>. A post with photos opens on a photo hero (lab post 2): the first photo full-bleed under the status bar with the board as a brass eyebrow and the title over it (post-hero/post-hero-image; tapping it opens the lightbox), then a strip of every photo when there is more than one (post-image-thumb-<i>, each opening the lightbox at that photo). A post without photos keeps the eyebrow and title under a floating back button (post-back). A photo-only post (empty title, quick photo post) draws no title over its photo at all — no stand-in text; the title node (<testID>-title, e.g.post-hero-title) is rendered only for a non-blank title (mobile/src/components/community/PhotoHero.tsx:57-59). The eyebrow is the board's name — "General" for the general board — and for a patch board it links to the board (post-source-board). Then the byline (visited badge and, for the Overall leaderboard top 10, a rank pill — crown in gold/silver/bronze for #1–3,#7for #4–10; see leaderboard; official posts use the same Scout · OFFICIAL byline as the feed), the body, a brass upvote pill, "N comments", the overflow menu (report/block, or delete for the owner/admin), and THE THREAD: the one-level comment thread with reply/like/delete (none of the three on a comment still saving, whose temporary id the API would answer with a 404:CommentCard.tsx), or a dashed "No comments yet / Be the first to reply." row. - Overview tab (patch detail) — the vouch toggle (heart icon, no
visible count) sits in the hero image pager's top chrome
(
mobile/src/components/patch-detail-v2/HeroSwiper.tsx:219), which renders only while the Overview tab is active (mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:117-124).OverviewTabV2.tsxitself renders no recommend UI. The vouch count is only visible on the patch's Community board viaRecommendBar(mobile/src/screens/PatchCommunityScreen.tsx, the board's list header), not on the Overview tab — correct this if reused verbatim from prior copy. - Community tab (patch detail) —
mobile/src/components/patch-detail-v2/tabs/CommunityTab.tsx, the same board content embedded as one of the patch detail screen's tabs. - Photo lightbox — the shared report/block sheet is wired into
mobile/src/components/lightbox/LightboxChrome.tsx:110-124(the flag,lightbox-report), so it appears on photo attachments from posts and comments and on a Scout post's photos. Reporting closes the lightbox first: the sheet is a Modal, and stacking one over the lightbox'sfullScreenModalroute is a presentation race this app has crashed on. - Post from a visit photo (built 2026-10-11, shipped in v1.1.106; made
one tap by the quick photo post, 2026-10-25, not yet released — see Status).
On your own visit photo in the lightbox (opened from the patch's My Visit
tab or from an Album place page), the top bar offers a labelled glass
+ Post pill (
lightbox-post,mobile/src/components/lightbox/LightboxChrome.tsx:131-180) where the old "Add to gallery" button was. One tap posts THAT photo, alone — no sheet, no title, no description, no picking other visit photos (postItem,mobile/src/components/lightbox/LightboxScreen.tsx:95-106). It runs throughusePhotoPost(mobile/src/hooks/usePhotoPost.ts:77-138):requireAuthfirst ("Create an account to post your photos.",:25), a synchronous double-tap guard (:85-92), then the photo is prepared (prepareVisitPhotos) and posted (createPhotoPost) as one untitled post to the photo's own patch board. While it goes up the pill reads Posting with a spinner (lightbox-posting, disabled), and every other photo's pill is locked (postState,LightboxScreen.tsx:108-109;LightboxChrome.tsx:150-174). Once the post is up the pill turns into a filled brass Posted (lightbox-posted, tap = open the post), a toast reads "Posted to the <Patch> board" with View (lightbox-toast/lightbox-toast-view, 4 s,LightboxScreen.tsx:146-159), the photo is recorded as posted, and its bottom block becomes the post itself — keyed on the photo you tapped, even if you swiped away mid-post (handlePosted,:87-93). A failed post puts the pill back to + Post and shows the reason as a toast (LightboxScreen.tsx:50-53,:160-174): a 4xx the server explained (a suspended author's 403, the text filter's 400) shows the server's own message with no Retry, because it would only be refused again; anything else (5xx, offline, a photo that could not be read, a 4xx with no message of its own) shows "Couldn't post. Check your connection and try again." with Retry (lightbox-toast-retry;postFailureFor,usePhotoPost.ts:41-49). Retry re-posts the photo already prepared — it never looks the visit photo up again unless the preparing step itself failed (usePhotoPost.ts:99-101). A photo already in a post opens straight as Posted, and from My Visit (once the board has loaded) with the post block under it; from an Album place page it is the Posted pill over the plain caption. Every photo in a multi-photo post counts as posted. Until 2026-10-25 + Post opened a title-and-description sheet with a strip for adding the visit's other photos; that sheet and its screen mock were deleted (replaced on 2026-10-25 by the one-tap post above). - "Posted" on My Visit — a posted visit photo's tile on the My Visit grid
wears a brass "↑ N Posted" tag (
my-visit-posted-<assetId>, the post's upvotes when the board has loaded,mobile/src/components/patch-detail-v2/tabs/MyVisitTabV2.tsx:159-171), and the caption under the grid reads "Only you can see these. Tap one to post it to the <Patch> board." (MyVisitTabV2.tsx:195; a cloud-album account keeps its own first sentence). See album-and-cloud-backup for the Album place page's Post. - Gallery's "Scout posts" (the new patch screen's Gallery tab) — the
patch board's posts that have at least one photo, newest first, as two-up
4:5 tiles: the first photo, a multi-photo mark, the title (2 lines; a
photo-only post has no title line,
ScoutPosts.tsx:139-142), then the author ("you" on your own), ↑ votes and comment count (mobile/src/components/patch-detail-v3/ScoutPosts.tsx:131-152). A dashed Add photos tile leads (gallery-add-photos, ImagePlus icon,ScoutPosts.tsx:99-111); with no photo posts it stands alone. It asks a guest for an account first (the samerequireAuthmessage), THEN opens the system picker (pickImages({ source: 'library', limit: 10 }),ScoutPosts.tsx:56-66); a cancelled picker does nothing, and the picks go up at once as ONE untitled post throughusePhotoPost— no title, no description. While it posts agallery-postingtile sits after the lead: the first picked photo dimmed, a spinner and "Posting N photos…" (ScoutPosts.tsx:123-130). On success the board is invalidated (the new tile appears) and agallery-posted-toastreads "Posted to the <Patch> board" above the grid for 3 s (POSTED_MS,ScoutPosts.tsx:29-30,:115-120). On failure the lead tile becomesgallery-post-error: "Couldn't post / Tap to retry", which re-posts the same picks without reopening the picker, with a small ✕ (gallery-post-error-dismiss) that gives "Add photos" back for a retry that will never succeed (an nginx 413 page, a photo that always fails); or — for a 4xx the server explained — the server's message, which a tap only clears (ScoutPosts.tsx:68-98). Library picks carry their own EXIF location but no source hash (hash: null,ScoutPosts.tsx:64), so My Visit cannot recognise them as posted (see Edge cases). The New post tile that used to lead here (it opened the titled board composer) was removed on 2026-10-25; that composer is the Community tab's now. Gallery reads the samepostsQuery(patchId, 'new')as the Community tab, filtered client-side bypostsWithImages(mobile/src/domain/visitPost.ts:49,mobile/src/components/patch-detail-v3/Bodies.tsx:258-324), so text posts stay on Community. A failed read with nothing cached shows "Couldn't load posts" with Try again (gallery-posts-error,Bodies.tsx:306-312), never a silent empty. Blocked authors are already filtered out by the API. The previous patch screen (flagnew_patch_detail_screenoff) keeps the oldPatchPhotogallery — see patch-catalog-and-detail. - A post's photo in the lightbox — opening a Scout posts tile (or a post
photo in the row under Gallery's lead photo) pages the lightbox across every
photo of every photo post, in order (
postsToLightboxItems,mobile/src/components/lightbox/mappers.ts:89-108). Under each photo the post block (mobile/src/components/lightbox/PostBlock.tsx:27-73): "Photo i of n in this post" dots for a multi-photo post, the avatar, @handle (or "Your post"), the Visited badge, the date (formatPostDate, "Oct 9"), a map-pin "Taken here" marker when that photo carries GPS (lightbox-post-taken-here,PostBlock.tsx:44-48), the title (lightbox-post-title; none at all on a photo-only post,PostBlock.tsx:50-53), two lines of body, an upvote pill that toggles in place, optimistically, on every photo of the post and rolls back on failure (mobile/src/components/lightbox/useLightboxPostActions.ts:20-40), "N comments" and Open post › (/post/<id>, closing the lightbox first). The flag reports the post; your own post has none — delete it on the post screen (the app has no screen for editing a post). The block's screen mock ismobile/src/dev/mocks/post-block.tsx. The hero pager and Overview's "More photos" also show post photos, but as plain credited photos with no post block (see patch-catalog-and-detail); a post's own photo hero on/post/<id>keeps its plain lightbox too. - The comments half sheet — "N comments" opens the thread as an in-screen
bottom sheet over the dimmed photo
(
mobile/src/components/lightbox/PostCommentsSheet.tsx, mounted byLightboxScreen.tsx:114-123). It loads the post lazily with the post screen's ownusePost, draws the one-level thread withCommentCard(like, reply, delete for the author or an admin), offers the sharedCommentComposer, a skeleton while loading, an error row with retry, "No comments yet. Be the first to reply." when empty, Open post, and "See the whole <Patch> board" (/patch-community/<patchId>). A reply moves the block's comment count. Its mini header names the post by its title; a photo-only post's says "Your post" (yours) or the author's username instead of a blank line (PostCommentsSheet.tsx:143-147). - Blocked accounts —
mobile/app/(drawer)/blocked-accounts.tsx, deep linkscout://blocked-accounts, reached from Settings. Lists everyone the user blocked with an unblock action that confirms first. Split intoBlockedAccountsScreenViewModelImpl(every hook) and a pureBlockedAccountsScreenLayout, the house view-model pattern, with its five branches decided by one exported pure helper,buildBlockedAccountsData('loading' | 'error' | 'signed-out' | 'empty' | 'list'). Every branch is in the DEV Screen mocks gallery —mobile/src/dev/mocks/blocked-accounts.tsx,scout://dev-screen-mock/blocked-accounts— because reaching any of them for real means blocking real people. - Admin moderation queue —
backend/admin-ui/src/pages/ModerationPage.tsx, admin-app-only (out of scope for detail here beyond confirming it exists and what it does).
How it works (end-to-end)
Parent boards (2026-10-13). A parent board's feed is
CommunityPostsService.listCampaignPosts
(backend/src/community-posts/community-posts.service.ts:220): posts with
campaignId = <id> (the board's own) OR patchId IN the campaign's members
(campaignMembers :192 — patch_collections through collections where
campaignId = <id> AND adminOnly = false), the patch posts limited to
ROLLUP_PATCH_POSTS (isOfficial = false AND isAutoGenerated = false,
board-scope.ts:17) so the 1,976 auto-generated Visitor's Guides never bury
human posts. One SQL row per post, so a post on a stop in two campaigns appears
on both boards with identical counts. Blocks filter in SQL as everywhere
(hiddenAuthorIds); Visited is computed per post's OWN patch (visitedPairs);
a pin floats only on the board it was made on (floatHomePinned) — a patch
board's pinned post sorts like any other on a parent board. Optional filters:
collectionId (a chip; drops the board's own posts) and patchIds (Filter →
Boards; the token own is the board's own posts). Sort is orderByFor
(sort.ts:15): Newest, Top (upvotes then newest), Most comments (comment count
then newest). The hub is listHubPosts (:258) — General + campaign posts +
every patch's human posts, or the chosen campaigns and/or general, General's
pins first. Board counts are campaignSummaries (:288): visible posts and
last-7-days per campaign by the same rules, computed in one pass. Posting to a
parent board is createPost({ patchId: null, campaignId }) (:479) — text
filter and suspension check first, 404 for an unknown campaign. On the device,
every board/patch name comes from synced content (useContent); the API adds
only campaignId to each post. Pins (communityPrefs.pinnedBoardIds) and the
sort (communityPrefs.sort) live in the persisted Zustand store; Boards picks
and chips are screen state. Every write calls invalidateCommunityFeeds
(the whole communityPosts key prefix + the summary).
Posting. Client calls POST /patches/:patchId/posts (or
/community/general/posts for the general board) with a JwtAuthGuard.
CommunityPostsService.createPost (backend/src/community-posts/community-posts.service.ts:479-519)
first checks that the post has a title OR a photo (:486-491): since the
quick photo post (2026-10-25) CreatePostDto.title is optional
(backend/src/community-posts/dto/index.ts:9-15), a post with at least one
image may have an empty or missing title (stored as ''), and a post with no
images and a blank/whitespace title is rejected 400 A post needs a title or a photo before any DB work. A titled post is stored exactly as sent. Then it
runs assertPublishable (the text filter, throws BadRequestException before
any DB write) then moderation.assertNotSuspended (throws ForbiddenException
if the author is suspended), validates the image side-arrays, validates the
patch exists, and inserts the Post row. Images are uploaded separately first via
POST /post-images (multipart, resized/re-encoded to webp, EXIF-rotated,
backend/src/community-posts/community-posts.controller.ts:155-169) and
the resulting URLs are attached to the post/comment body along with parallel
GPS coordinates (imageCoords, write-once) and, on a post, parallel
source-asset hashes (imageSourceHashes, write-once, below). Since
2026-10-11 every post path marks the patch board's cached feeds stale after a
create (invalidateBoard, mobile/src/query/queries/community.ts:14,
called from mobile/app/post-create/[id].tsx:32 and from usePhotoPost,
mobile/src/hooks/usePhotoPost.ts:116-119), so the Community tab, the board
and Gallery's Scout posts show the new post on the next read; before that
nothing did, and a new post appeared only after the one-minute staleTime.
The quick photo post (2026-10-25; the visit-photo path built 2026-10-11,
shipped in v1.1.106). Both one-tap entry points — the lightbox's + Post and
Gallery's Add photos — go through one hook, usePhotoPost
(mobile/src/hooks/usePhotoPost.ts:77-138). post(prepare, onPosted) asks a
guest for an account ("Create an account to post your photos.", :25,
:124-127), ignores a second call while one is in flight (a ref, not state,
so a second tap before the re-render is still stopped, :85-92), resolves the
photos with prepare, and hands them to createPhotoPost. Once the server
has the post nothing after it can reach the failure path (a Retry would make
a second post): it fires analytics.trackPostCreated({ board: 'patch', imageCount, patchId }), invalidates the board without waiting on the refetch,
and calls onPosted (:108-120), each wrapped so a throw is only logged. A
failure becomes postFailureFor(e) (:41-49): a 4xx whose JSON carries a
message (a string, or class-validator's array) is shown as written with
canRetry: false; anything else is "Couldn't post. Check your connection and
try again." with canRetry: true. retry() re-runs the last attempt with
the photos it already prepared, so it never reopens the picker or resolves
the visit photos again; only a failure inside prepare prepares again
(:99-101, :129-133).
The two halves live in mobile/src/services/visitPostSubmit.ts.
prepareVisitPhotos (:68-97, lightbox only) turns visit photos into
PreparedPostPhotos: it first resolves each to its library FILE
(resolveAssetFile, mobile/src/lib/assetFile.ts —
getAssetInfoAsync(..., { shouldDownloadFromNetwork: true }).localUri).
A visit photo's uri is a ph:// PhotoKit ref, which the hasher cannot read
and expo-image-manipulator never finishes compressing — until 2026-10-11
on device the post spun forever and no post was made. A photo with no
readable file fails the post before anything is uploaded (the failure toast
and Retry). Then, per photo and in parallel, all from that file: the SHA-256
of the original asset bytes (hashAssetBytes, the same definition as
PatchPhoto.sourceHash), where it was taken (readAssetCoords,
mobile/src/lib/assetLocation.ts:24-31 — PHAsset.location, Android the
EXIF header) and its dimensions (the file's when the photo has none).
Gallery's library picks skip this step: pickImages already returns
compressed files with their own EXIF coords, and they post with
hash: null (mobile/src/components/patch-detail-v3/ScoutPosts.tsx:64).
createPhotoPost (visitPostSubmit.ts:103-119) refuses an empty list,
caps at MAX_ATTACHED_IMAGES (10), uploads each photo to POST /post-images,
then makes ONE POST /patches/:patchId/posts with title: '', body: '',
images, imageCoords and imageSourceHashes. Each photo's lookup, and each
photo's hash and each upload, is bounded by PHOTO_STEP_TIMEOUT_MS (60s,
visitPostSubmit.ts:33): a step that never settles fails the post with the
failure toast/tile and Retry instead of a frozen spinner. A photo whose hash
or location cannot be read still posts with a null entry; an upload failure
aborts before the create, so no post is half made (images already uploaded
stay in S3 — see Edge cases).
The location is public: imageCoords goes back to every client in
PostResponseDto (backend/src/community-posts/dto/index.ts:87); the app
shows it only as the "Taken here" marker. The hashes are not:
normalizeImageSourceHashes (backend/src/common/image-source-hashes.ts:18-37)
requires the same length as images, at most 10 entries, each lowercase
64-hex or null, and stores NULL when every entry is null; postView returns
them only when the viewer is the post's author, null to everyone else
(community-posts.service.ts:140-142). On a lightbox post's success
handlePosted records the photo as posted (markVisitPhotosPosted,
mobile/src/domain/store.ts:3070-3074 — which also fires the
photo_published>=1 feature event, "Going Public", see
achievements); a Gallery Add photos post records nothing
on the device.
Knowing a visit photo is posted. useVisitPhotoPosting
(mobile/src/hooks/useVisitPhotoPosting.ts:42-160) keeps two sources. (1) A
persisted postedVisitPhotos record, patchId → assetId → postId
(mobile/src/domain/postedVisitPhotos.ts, store.ts:858-865), answers on the
first frame after a cold start. (2) The viewer's own posts on the board,
matched by imageSourceHashes against each photo's hash (findPostByHash,
visitPost.ts:59-62), reconcile it once the board loads. The hash is of the
photo's library FILE (resolveAssetFile with download: false — a
background check never pulls iCloud originals, so a photo with no local file
learns nothing), the same bytes the submit hashed; it used to hash the ph://
ref, which always failed, so no post was ever learned by hash. Each lookup
is a getAssetInfoAsync (~200ms of main-thread decode on iOS), so a photo is
resolved only when its hash can change something: an UNrecorded photo while
the loaded board holds a post of the viewer's that carries hashes and is not
yet in the record, or a recorded photo whose post has more than one image (to
find its page). The lookups run one at a time, and resolveAssetFile caches
successful resolutions for the life of the process (a null is retried), so a
board that confirms the record — the usual case once you have posted — reads
no photo at all, and reopening My Visit repeats no lookup
(useVisitPhotoPosting.ts:54-95, assetFile.ts:20-24). A match learns a
record (a post made on another device, or after a reinstall); a record is
dropped only when its stored post is ABSENT from a fresh board — loaded, not
fetching, not stale — (the post was deleted or moderated away), so the photo
can be posted again. The drop needs no hash. A hash miss alone never drops
one: a post can be on the board with a null hash for that photo (the hash
failed at submit, or an older backend sends no imageSourceHashes). A stale
or failed board never drops a record (useVisitPhotoPosting.ts:97-120). Once the board has the post,
fieldsFor also attaches it, built with postsToLightboxItems and paged to
this photo by its hash (photo 0 when the hash is unknown), so a posted photo
opens with the post block under it (useVisitPhotoPosting.ts:134-157).
My Visit runs this hook; the Album place page reads the record alone (Posted
pill, no post block). The
record replaced the gallery-era publishedPhotos key, which a persist
migration deletes (store.ts:3209-3212) because its ids were PatchPhoto
rows, not posts.
Reading a feed. listPosts first calls
moderation.hiddenAuthorIds(viewerId) and folds the result into the Prisma
where clause (userId: { notIn: [...hidden] }) — blocked authors' posts
never leave the database, they are not filtered client-side after the fact.
Comment counts on a filtered feed are recomputed with a second
block-aware query (visibleCommentCounts) rather than trusting Prisma's
_count, so a feed row never claims "3 comments" when a blocked author's
comment would make only 1 visible.
Reading one post. GET /posts/:postId additionally calls
moderation.assertNotBlocked(viewerId, post.userId, 'Post'), which throws a
NotFoundException (not a 403, and not a "you're blocked" message) if either
party has blocked the other — closing the deep-link/stale-list-row bypass a
pure feed filter would leave open.
Voting/commenting/liking. Every mutation that targets someone else's
content (vote, unvote, createComment, likeComment, unlikeComment)
calls assertNotBlocked before writing. A blocked party literally cannot move
the vote count on, or reply to, or like something belonging to the person who
blocked them (or vice versa).
Comment threading. One level deep only, enforced server-side
(createComment throws BadRequestException('Cannot reply to a reply') if
parent.parentId is set) and mirrored client-side in
mobile/src/components/community/commentThread.ts. A reply whose parent was
hidden by a block is promoted to top-level rather than dropped, both server-
side (commentsForPatch) and in the client grouping function, so a visible
reply is never silently swallowed.
Optimistic comment posting. mobile/src/hooks/usePost.ts:52 appends a
locally-constructed temp comment (id temp-<postId>-<timestamp>,
authorVisited: false) to state immediately on submit, then replaces the
whole comment list with the server's authoritative response (correct ids,
real authorVisited) once the request resolves. On failure, the temp comment
is filtered back out and the error is rethrown to the caller.
Reporting. Every report — whether posted through the legacy
POST /posts/:id/report / POST /post-comments/:id/report routes or the
canonical POST /api/moderation/reports — lands in ModerationService.report
(backend/src/moderation/moderation.service.ts:217). It resolves the
content's author, checks the reporter isn't blocked from the author (returns
"not found" rather than confirming a block — a report response was itself a
block-detection oracle before this check existed), rejects self-reports, and
upserts a ContentReport row keyed on (targetType, targetId, reporterId) —
re-reporting the same item is idempotent but reopens a dismissed report.
Resolving a report. resolveReport runs in one Prisma transaction:
content_removed deletes the underlying row; author_suspended also sets
Profile.suspendedAt; either resolution additionally closes every other
pending report against the same target so the queue's pending count doesn't
stay permanently inflated by duplicate reports of already-actioned content.
Suspension enforcement. assertNotSuspended is checked on createPost,
createComment, and patch-photos upload — a suspended account cannot
post, comment, or upload a new photo. It is not checked on voting,
or liking (see Edge cases).
Triage. The boards are a report source for the triage skill
(.claude/skills/triage/), which files board tickets from real user reports.
Post.triage and PostComment.triage are the same nullable admin tag
message_comments and feedback already carry, and null (untriaged) is the
single predicate the collect step selects on across all four sources. Without
the column the run would have only Ticket.sourceRefs to dedupe on, so a post
judged noise — which never gets a ticket — would resurface on every future
run forever. CommunityReportsAdminService
(backend/src/admin/api/community-reports-admin.service.ts) serves the feed and
stamps the tag; it excludes isOfficial/isAutoGenerated posts (prod holds
~2000 posts, nearly all seeded Visitor's Guides) but deliberately does not
filter comments that way, since a reply left on a guide is still a real person.
Triage is read-and-tag only: it never deletes content or suspends an author —
that is the moderation queue's job.
Data model (Prisma models)
All in backend/prisma/schema.prisma, @@schema("public").
-
Post.campaignId(2026-10-13,campaign_id, migration20261013120000_post_campaign_id) — nullable FK tocampaigns, ON DELETE SET NULL (a deleted campaign's posts move to General rather than being destroyed), indexed, and a SQL-only CHECKposts_one_board_check(patch_id IS NULL OR campaign_id IS NULL): a post lives on a patch board, a parent board, or General — never two.postsis not a published content table, so content publishing is unaffected. -
Post(schema.prisma:328) —patchIdnullable (null = general board),title(VarChar 200, NOT NULL;''on a photo-only post since the quick photo post, 2026-10-25 — no migration, the service writes the empty string),body(VarChar 4000),images: String[],imageCoords: Json?(positionally parallel toimages, write-once, public to every reader),imageSourceHashes: Json?(image_source_hashes,schema.prisma:339-343, added 2026-10-11 and not yet deployed — positionally parallel toimages, each the lowercase-hex SHA-256 of the original device asset or null, write-once, returned to the post's author only; it is how the app recognises a visit photo it already posted),upvoteCount(denormalized cache, recomputed fromPostVoterows on every vote/unvote),mentions: String[],isPinned,isOfficial,isAutoGenerated, andtriage(nullable —bug/idea/handled/noise, the vocabulary inbackend/src/common/triage.ts; null means untriaged).posts_official_per_patch_boardreplaced the oldposts_official_per_boardindex. The original allowed exactly one official post per board including the general one, which made a second announcement impossible to insert alongside "Welcome to Scout". The replacement keeps the guarantee where it has teeth — one Visitor's Guide per PATCH board, whichseedVisitorGuide's find-then-create relies on — and lifts it for the general board. -
PostVote(:372) — unique on(postId, userId). -
PostComment(:385) — self-referential one level deep (parentId/replies),textVarChar 500, ownimages/imageCoords, andmentions: String[]— user IDS of people tagged in the text, never the parsed@namestring. Populated only by the composer's mention picker. Also carries the same nullabletriagetag asPost— see "Triage" below. -
PostCommentLike(:420) — unique on(commentId, userId). -
BoardFavorite— deleted. Replaced byUserFavorite/user_favorites, which covers patches, collections and campaigns; existing rows were copied forward as patch favorites. See favorites. -
PatchPhoto(:517) — the per-patch photo gallery, retired from the new patch screen on 2026-10-11 but not deleted (since v1.1.106).url,blurhash,sourceHash(SHA-256 of the original device asset bytes, client-computed, nullable; unique on(patchId, userId, sourceHash)so re-publishing the same camera-roll photo is a no-op, scoped per-uploader so two people photographing the same scene isn't flagged as a duplicate). Prod held 91 rows from 7 users across 48 patches when it was retired (spec count, 2026-10-11); they stay, with their S3 objects, and simply stop being shown by the new build. The previous patch screen (flag off) and builds already in the field still read and write it. -
PatchRecommendation(:288) — unique on(patchId, userId); the "vouch" record. No comment/reason field — it is a bare upvote of the place. -
ContentReport(:672) —targetType(string, not an enum/relation — targets span five unrelated tables),targetId,reporterId,authorId(denormalized at report time so the report survives the content being deleted),reason,details(VarChar 1000),status(pending/actioned/dismissed),resolution(content_removed/author_suspended/no_action). Unique on(targetType, targetId, reporterId). -
UserBlock(:707) —blockerId,blockedId, unique on the pair, indexed both directions. -
Profile.suspendedAt(:772) — nullable timestamp; presence means ejected from community surfaces. A suspension is reversible (no UI to reverse it was found — see Open questions) and does not touch the account's own collection, sign-in, or purchases.
API surface
All community routes are mounted at the root (no global prefix), except
moderation's user-facing routes which are explicitly mounted under /api so
the SPA catch-all in main.ts can never intercept them
(moderation.controller.ts header comment).
Parent boards (2026-10-13): GET /community/feed?sort=&boards= (optional
auth; boards = campaign ids and/or general, absent = every board),
GET /community/campaigns/summary (optional; [{ campaignId, postCount, weekCount }], a board with no posts is absent),
GET /community/campaigns/:campaignId/posts?sort=&collectionId=&patchIds=
(optional; 404 unknown campaign) and POST /community/campaigns/:campaignId/posts
(JWT) — community-posts.controller.ts:24,71,78,85. Every feed's sort now
also takes comments; posts carry campaignId.
| Route | Guard | Purpose |
|---|---|---|
GET /community/general/posts?sort= |
Optional | General feed, new/top/comments |
POST /community/general/posts |
JWT | Create general post |
GET /community/search/posts?q= |
Optional | Search post title/body |
GET /community/collections/:collectionId/board |
Optional | Two-row board summary card for a whole collection |
GET /community/patches/:patchId/board |
Optional | Same card for one patch |
GET /patches/:patchId/posts?sort= |
Optional | Patch board feed |
POST /patches/:patchId/posts |
JWT | Create post on a board (optional imageCoords and, since 2026-10-11, imageSourceHashes, both parallel to images) |
GET /posts/:postId |
Optional | Post + comments |
PATCH /posts/:postId |
JWT | Edit (owner or admin) |
DELETE /posts/:postId |
JWT | Delete (owner or admin) |
POST/DELETE /posts/:postId/vote |
JWT | Upvote/remove |
POST /posts/:postId/pin |
JWT (admin-checked in-service) | Toggle pin — no UI calls this (see Summary) |
POST /posts/:postId/report |
JWT | Legacy report route |
POST /posts/:postId/comments |
JWT | Add comment/reply |
PATCH/DELETE /post-comments/:commentId |
JWT | Edit/delete comment |
POST/DELETE /post-comments/:commentId/like |
JWT | Like/unlike |
POST /post-comments/:commentId/report |
JWT | Legacy report route |
POST /post-images |
JWT | Upload an image for a post/comment |
GET /patches/:patchId/recommendations |
Optional | Vouch count + own status |
POST/DELETE /patches/:patchId/recommendations |
JWT | Add/remove own vouch |
GET /patches/:patchId/photos |
Optional | Retired PatchPhoto gallery, block-filtered — kept for the flag-off screen and builds in the field |
POST /patches/:patchId/photos |
JWT | Upload a gallery photo — same, kept |
DELETE /photos/:photoId |
JWT | Delete own gallery photo — same, kept |
GET /api/moderation/report-reasons |
None | Served reason list (wording can change without an app release) |
POST /api/moderation/reports |
JWT | Canonical report endpoint |
GET /api/moderation/blocks |
JWT | List own blocks |
POST/DELETE /api/moderation/blocks/:userId |
JWT | Block/unblock |
GET /api/admin/moderation/reports?status= |
AdminGuard | Queue |
GET /api/admin/moderation/reports/pending-count |
AdminGuard | Badge count |
PATCH /api/admin/moderation/reports/:id/resolve |
AdminGuard | Resolve a report |
GET /api/admin/community/reports?filter= |
AdminGuard | Triage feed: user-authored posts + all post comments |
PATCH /api/admin/community/posts/:id/triage |
AdminGuard | Set/clear a post's triage tag |
PATCH /api/admin/community/post-comments/:id/triage |
AdminGuard | Set/clear a comment's triage tag |
"Optional" = OptionalJwtAuthGuard — a signed-out reader still sees content;
the guard only decides whose blocks apply.
Key files
Parent boards (2026-10-13)
backend/src/community-posts/sort.ts(parseSort,orderByFor),backend/src/community-posts/board-scope.ts(floatHomePinned,parseIdList,ROLLUP_PATCH_POSTS).mobile/src/domain/communityBoards.ts— featured ids, rail order, membership,campaignsForPatch, byline labels, tile line.mobile/src/hooks/useCommunityFeeds.ts,mobile/src/query/queries/community.ts(hubPostsQuery,campaignPostsQuery,campaignSummaryQuery,invalidateCommunityFeeds).mobile/src/components/community/—CommunityFilterMenu.tsx,BoardFeed.tsx(shared by patch and parent boards),BoardTile.tsx,PinBoardButton.tsx,BoardPickerSheet.tsx,AlsoOnLine.tsx;PillRailinmobile/src/components/campaign/ring/BoardPills.tsx;ActionToastinmobile/src/components/feedback/ActionToast.tsx(shared with the lightbox).
Backend
backend/src/community-posts/community-posts.service.ts— all post/comment/ board-summary/official-content logic. Favorites moved out tobackend/src/favorites/.backend/src/common/image-source-hashes.ts—normalizeImageSourceHashes, the validator for a post'simageSourceHashes(modelled onimage-coords.ts'snormalizeImageCoords).backend/prisma/migrations/20261011120000_post_image_source_hashes/— the hand-written migration addingposts.image_source_hashes(not yet deployed).backend/src/community-posts/official-content.ts— the fixed welcome post copy and thebuildVisitorGuidetemplate (patch metadata → plain-text guide, capped at 4000 chars).backend/src/moderation/moderation.service.ts—hiddenAuthorIds(the one method the rest of the app depends on),assertNotBlocked,assertNotSuspended, report/resolve logic.backend/src/moderation/text-filter.ts— the pre-publication filter (see Configuration below).backend/src/moderation/report-reasons.ts— shared vocabulary; mobile carries a mirrored copy atmobile/src/domain/moderation-types.tsthat must agree by hand (no shared package).backend/src/patch-photos/patch-photos.service.ts— the retired gallery's upload/list/delete and sourceHash dedupe, kept for the flag-off screen and older builds.backend/src/patch-recommendations/patch-recommendations.service.ts— the vouch add/remove/status.backend/src/admin/api/community-reports-admin.service.ts+community-reports-admin.controller.ts— the triage feed and tag writer. Insrc/admin/api/rather thancommunity-posts/becauseAdminModulealready importsCommunityPostsModule; the reverse import would be a DI cycle.backend/scripts/seed-official-posts.ts— idempotent backfill for the welcome post + per-patch Visitor's Guides; run on every backend deploy (.github/workflows/deploy.yml:120, non-fatal on failure) and also triggered per-patch on patch creation (backend/src/admin/api/patches-api.controller.ts:415).
Mobile
mobile/src/components/moderation/ContentActionsProvider.tsx— the single report/block bottom sheet mounted once at the app root; every content surface callsuseContentActions().showContentActions(...).mobile/src/components/moderation/contentActionsContext.ts— the context/ hook, deliberately import-light so render-free unit tests don't need to mockModal/Alert/theme.mobile/src/hooks/usePost.ts,usePosts.ts,useRecommendations.ts,useBoardFavorites.ts,useCommunitySearch.ts.mobile/src/screens/PostScreen.tsx— the post detail screen, split intoPostScreenViewModelImpl(every hook) and the purePostScreenLayout, with the derived state in the exported purebuildPostScreenData(the three-wayPostScreenStatus, the threading, the per-comment delete rights, the board label, the @-mention roster).mobile/src/dev/mocks/post.tsxrenders that layout in the gallery — see screen mocks.mobile/src/screens/PatchCommunityScreen.tsx— one patch's board, split intoPatchCommunityScreenViewModelImpl(every hook) and the purePatchCommunityScreenLayout, with the derived state in the exported purebuildPatchCommunityScreenData(the four-wayPatchCommunityBoardStatusand the singular/plural count line).mobile/src/dev/mocks/patch-community.tsxrenders that layout in the gallery — see screen mocks.mobile/app/(drawer)/community.tsx— the general-board hub, split intoCommunityScreenViewModelImpl(every hook) and the pureCommunityScreenLayout, with the derived state in the exported purebuildCommunityScreenData(the five-wayCommunityFeedStatus, plusCommunitySearchResults.noMatches, which is the one condition the search half's empty copy renders under).mobile/src/dev/mocks/community.tsxrenders that layout in the gallery — see screen mocks.mobile/src/components/community/PostComposerScreen.tsx— the new-post composer both routes render, split intoPostComposerScreenViewModelImpl(every hook; it takes the two routes' props as arguments) and the purePostComposerScreenLayout, with the Post gate in the exported purepostComposerFormState. It stays insrc/components/community/rather than moving tosrc/screens/because it has TWO consumers, andcontextKindis a view-model field so one gallery entry renders both the general-board and the patch-board face.mobile/src/dev/mocks/post-composer.tsxrenders that layout in the gallery — see screen mocks.- Post from My Visit (built 2026-10-11, shipped in v1.1.106) and the quick
photo post (2026-10-25):
mobile/src/components/lightbox/LightboxChrome.tsx(the + Post / Posting / Posted pill, the post block slot),LightboxScreen.tsx(one-tap post of the photo on screen, mounts the comments sheet, the posted/failed toast with View/Retry, records a new post),useLightboxStore.ts(the toast is{ kind: 'posted' … } | { kind: 'failed' … }; the sheet is comments-only),mobile/src/hooks/usePhotoPost.ts(auth, double-tap guard, analytics, board refresh,postFailureForand Retry — shared by the lightbox and Gallery),PostBlock.tsx(the post under a photo),useLightboxPostActions.ts(upvote, comments, Open post, the board),PostCommentsSheet.tsx(the comments half sheet),mappers.ts(postsToLightboxItems);mobile/src/domain/visitPost.ts(the pure half:postsWithImages,findPostByHash,visitPostFields,postScoutImages),mobile/src/domain/postedVisitPhotos.ts(the posted record's reducers),mobile/src/hooks/useVisitPhotoPosting.ts(record + board reconcile),mobile/src/services/visitPostSubmit.ts(prepareVisitPhotos: file, hash, location, dimensions;createPhotoPost: upload, one untitled create),mobile/src/lib/assetLocation.ts(the asset's own GPS, shared with camera-roll import),mobile/src/components/patch-detail-v3/ScoutPosts.tsx(Gallery's tiles and its Add photos lead tile),mobile/src/query/queries/community.ts(invalidateBoard). mobile/src/components/community/commentThread.ts— one-level thread grouping, orphan-promotion logic.mobile/src/components/community/PostCard.tsx,CommentCard.tsx,VisitedBadge.tsx,RecommendBar.tsx.mobile/src/api/moderation.ts,mobile/src/domain/moderation-types.ts.
Admin
backend/admin-ui/src/pages/ModerationPage.tsx— the queue UI (filter by status, view reported content/attachments, remove / remove+suspend / dismiss).
Configuration and flags
No feature flags (see Status). Two environment-shaped configuration points:
OFFICIAL_AUTHOR_PROFILE_ID(optional env var) — the profile id official posts (welcome, Visitor's Guides) are authored under. Falls back to the firstProfilewithisAdmin: true; throws if none exists.- The text filter (
text-filter.ts) has no runtime configuration — the term lists are hardcoded constants, changed only by editing and redeploying the file.
No rate limiting is applied to posting, commenting, voting, or reporting: grepped community-posts, moderation, patch-photos, and
patch-recommendations for Throttle/ThrottlerModule and found none (other
modules — auth, trips, album, store-analytics — do use
@nestjs/throttler; these do not).
Edge cases and known limits
-
Parent boards: no pagination. Like every community feed, the hub and a parent board return every matching post in one response. Fine at today's volume (a handful of human posts); City Challenges rolls up 1,000+ patches.
-
Parent boards: an admin sees admin-only collections in "Also on" and on the rail's membership. The device trusts synced content, and the content sync sends admin-only collections to admins (unflagged); the backend roll-up never includes them, so for an admin the line can name a board the post isn't actually rolled up to. Non-admins never receive those collections.
-
Parent boards: pins and the sort are per device. Reinstalling or a new phone starts from Newest with nothing pinned.
-
Parent boards: chip and Boards counts come from the last unfiltered feed and are held while a chip/pick narrows it.
-
A failed community SEARCH is indistinguishable from no results.
useCommunitySearchcatches a rejectedpostsApi.searchPostsand sets an empty array (mobile/src/hooks/useCommunitySearch.ts), and it exposes noerrorat all — so the hub renders "No boards or posts match “…”" whether nothing matched or the request failed. The board half is unaffected: it comes from the locally-synced catalog and never touches the network. -
A failed FEED refetch over posts you already have is silent. The hub's
CommunityFeedStatus(mobile/app/(drawer)/community.tsx) puts anyposts.length > 0inready, so a failure is only ever visible on a board with nothing in it. This is deliberate — a stale feed beats an error page over content that is already on screen — but it means "the board is out of date" has no indicator. -
The hub's
refetchingframe is unreachable.ListEmptyComponenthas a spinner branch forloading && error, andusePostsreportsloadingas TanStack'sisPending, which is already false once the query has an error. The branch is dead code, kept rather than deleted. -
Suspension does not block voting or liking (nor favoriting, which now lives in favorites) —
assertNotSuspendedis called fromcreatePost,createComment, and photouploadonly. A suspended account can still upvote posts, like comments, recommend patches, and save favorites. -
Usernames are the email local-part, not a chosen handle.
displayNameFor(backend/src/patch-photos/username.ts) rendersalansax@gmail.comasalansaxfor any signed-in member; only guest profiles (no email) fall back to a generatedanon-<animal>-<n>handle, and only when even that is absent does it showGuest. There is no UI or API to set a custom display name for a real account (searched forupdateDisplayName/changeDisplayName/setDisplayNameacross both codebases — none found). This means every post/comment/photo byline for a signed-in member leaks their email's local part. -
Reporting your own content, or reporting/blocking while blocked, is rejected — self-reports throw
BadRequestException; a report against content whose author has a block relationship with the reporter throwsNotFoundException(not a "you're blocked" message — see next point). -
A block never announces itself. Interacting across a block — fetching a blocked author's post by id, reporting their content, replying, voting, liking — always returns/throws as if the target does not exist (404 / "Post not found" / "Comment not found"), never a message that reveals a block relationship exists. This is deliberate (
moderation.service.ts:88-90comment: "telling someone they've been blocked is an invitation to make a second account"). -
A FAILED read of the block list is a distinct frame, deliberately. On the Blocked accounts screen
'error'outranks'empty'(buildBlockedAccountsData), so a failedGET /api/moderation/blocksshows "Couldn't load your blocked accounts… your blocks are still in effect" and a Try again, never "You haven't blocked anyone." The screen used to set an empty list on failure, which rendered a network error byte-for-byte identically to the real empty state — on a Guideline 1.2 compliance surface that told someone being harassed their block had not taken. The status enum is what keeps the two apart. -
A FAILED unblock has no on-screen state at all. The catch in
confirmUnblockreports to Sentry and raises a nativeAlert("Couldn't unblock"), then clears the busy flag — so the screen returns to a roster identical to the one before the tap, and the row that failed to unblock is not marked in any way. The confirmation prompt is likewise an OSAlert. Neither is a frame the screen draws, which is why neither appears in the screen-mock gallery. -
A patch board whose PATCH did not resolve renders no name and says so nowhere.
PatchCommunityScreenViewModelImplreads onlypatchoffusePatch(patchId)and drops that hook'sisLoadinganderror, so a patch still syncing, anadminOnlypatch reached by deep link, and a lookup that outright failed all produce the same frame: the header reads "Community" under theBOARDeyebrow, with no banner photo, and the feed below is unaffected (the posts come from a different request that does not need the patch row). The same collapse PostScreen documents for its board label. Not a data-loss case — the feed, the bookmark and the composer all work off the route id alone — but there is no state that says the name is missing rather than merely late. -
The board feed's "loading over an empty list" branch is unreachable. The route file this screen came from rendered an
ActivityIndicatorinListEmptyComponentforloading && !posts.length && error.usePosts.loadingis TanStack'sisPending, which is false whenever the query is in itserrorstate, soloadinganderrorare never both set. A retry after a failed board read therefore re-renders the failure card, not a spinner.PatchCommunityBoardStatushas four values because the screen reaches four states. -
Blocking is symmetric by construction, not by two rows. One
UserBlock(blockerId, blockedId)row makes both directions invisible to each other; there is no separate "mute" or one-directional hide. -
Pin is effectively unreachable from any UI.
POST /posts/:postId/pinrequiresisAdminand togglesPost.isPinned;usePost.tsexposestogglePin, but no screen (mobile or admin) renders a control that calls it. The only pinned posts in production are the auto-generated welcome post and Visitor's Guides, which are created withisPinned: truedirectly by the seed service — never through this endpoint. -
Vouch count visibility is board-only, not Overview-tab. See "User-facing surfaces" above — the Overview tab shows only a countless toggle heart in the hero chrome; the numeric vouch count lives on the Community board screen.
-
A hand-edited official post stops the backfill from touching it. Editing an auto-generated Visitor's Guide as admin clears
isAutoGenerated; the seed script's--refreshmode only rewrites guides still flagged auto-generated. -
Non-place patches never get a Visitor's Guide.
seedVisitorGuideskips any patch with nocityand nostate(e.g. the Animals collection) — "parking, best time to go" text would be nonsensical for it. -
UGC is excluded from the content-publish pipeline. Posts, comments, votes, reports, blocks, and photos are per-environment runtime data, not authored/published content — they are absent from
backend/src/content-publish/content-entities.tsand never move through the local→prod publish flow that patches/collections use. -
Post from My Visit (built 2026-10-11, shipped in v1.1.106) and the quick photo post (2026-10-25) — known limits.
- Only a 4xx WITH a server message is shown as the server wrote it.
The lightbox toast and Gallery's error tile show a 4xx's
message(a suspension's 403, the text filter's 400) with no Retry; a 4xx with no JSON message (an nginx 413 page, for one) and every 5xx or network failure fall back to "Couldn't post. Check your connection and try again." with Retry (postFailureFor,mobile/src/hooks/usePhotoPost.ts:41-49). The board composer and the comment composers do NOT do this: they show a generic "Couldn't post / Please try again."Alertwhatever the server said (mobile/src/components/community/PostComposerScreen.tsx:223-227). - The backend must deploy before the app release that sends empty
titles. The prod backend before this change requires a non-empty title
(old
CreatePostDto:@IsNotEmpty() @MinLength(1)), so a new build against it gets a 400 on every Add photos and + Post, shown as the server's validation message with no Retry. - Old app builds show a blank title line on a photo-only post. Builds
from before the quick photo post render
post.titleunconditionally, so an empty title draws an empty title row on their feed cards, tiles and post screens. Accepted; it fixes itself as people update. - A photo added with Gallery's Add photos never reads "Posted" on My
Visit. Library picks go up with
imageSourceHashes: null(ScoutPosts.tsx:64) and write no device record, so the same photo still offers + Post on the My Visit tab and the Album, and posting it there makes a second post. - Orphaned uploads. Photos uploaded before a failed create stay in S3, the same as with the board composer.
- "Posted" after a reinstall needs a hash. A photo whose hash could not be computed posted with a null entry; once the device record is gone (reinstall, new phone) nothing can match it back, and it offers Post again.
- The Album place page trusts the device record alone — it can span several patches and runs no board query, so a post deleted elsewhere still reads as Posted there until My Visit's reconcile drops the record.
- The My Visit grid tag reads the record without checking sign-in, so a signed-out viewer can still see a "Posted" tag on a photo whose lightbox no longer says Posted.
- Old builds keep publishing to the retired gallery. Builds already in
the field still show "Add to gallery" and still write
PatchPhotorows, which the new build never shows. - Going Public fires on a reconcile too.
markVisitPhotosPostedrecords thephoto_published>=1event whenever it writes — including when the board reconcile learns a post made on another device. The event is idempotent, so it only matters for a first earn.
- Only a 4xx WITH a server message is shown as the server wrote it.
The lightbox toast and Gallery's error tile show a 4xx's
What this feature does NOT do
-
The hidden fixture board does NOT reach real users. Automated runs post to
e2e-test-patchon prod.NOT_ON_FIXTURE_BOARD(backend/src/community-posts/board-scope.ts) keeps those posts out of the hub feed, post search, parent boards, campaign summaries and collection board cards, so the board is reachable only by deep link. That holds by id, even if a catalog edit ever adds it to a collection. -
Parent boards do NOT sync pins. Pinning is device-only; there is no server table, and pins don't follow you to another phone.
-
Patch boards can NOT be pinned. Only parent (campaign) boards; a patch board keeps its bookmark (Favorites), which is account-bound.
-
There is NO manual cross-posting. You can't tick extra boards: a post on a stop shows on that stop's parent boards automatically, and nowhere else.
-
Parent boards have NO welcome / official posts. An empty board says so.
-
Pinning a board sends NO notifications — nothing tells you about new posts on a board you pinned.
-
Community search does NOT return parent boards — search is unchanged (patch boards from local names, posts from the backend); a post made to a parent board is labelled with that board (
campaignName), never "General". -
"Most comments" counts every comment, including a blocked author's, while the counts shown on a post exclude them — so the order can disagree with the numbers for someone who blocks a commenter (spec D6 "where cheap").
-
A parent board is NOT a separate copy of a post. Votes and comments are shared everywhere the post appears; deleting it removes it from every board.
-
It is not anonymous. A signed-in member's posts and comments are attributed to their email's local-part by default, with no way to change it. (See Edge cases.)
-
It does not rate-limit posting, commenting, voting, or reporting. There is no throttle on any of these endpoints beyond requiring authentication and (for posting/commenting/uploading) not being suspended. A determined bad actor with one account can post, comment, or file reports as fast as the network allows.
-
The text filter does not catch general profanity, harassment, spam, or misinformation. By explicit design (
text-filter.tsheader comment) it matches only a fixed list of slurs and explicit sexual-solicitation terms, whole-word, with leetspeak/spacing evasion handling. It will not stop "you are an idiot," a spam link, or a lie about a location's hours — those rely entirely on the report → moderator-review → removal/suspension path, which is manual and not guaranteed within any particular time window by the code itself (the admin UI's copy claims a 24-hour turnaround commitment, but that is a stated policy, not an enforced SLA in code). -
There is no automated moderation — no ML classifier, no image-content scanning of uploaded photos, no third-party moderation API integration. Every
content_removed/author_suspendedresolution is a human admin clicking a button inModerationPage.tsx. -
A "suspended" author is not deleted, banned from sign-in, or stripped of their collection.
Profile.suspendedAtonly blocks posting, commenting, and photo upload (see Edge cases); the account still authenticates, keeps its collected patches, and can still buy from the store. -
Suspension has no found reversal path. No UI or endpoint clears
suspendedAtback to null was found in either app (see Open questions) — as implemented, it functions as permanent unless someone runs a manual database update. -
There is no way to appeal a moderation decision, edit a resolved report, or view moderation history as the affected user. The moderated user is never notified in-app that their content was removed or that they were suspended; they discover it the next time they try to post. Only the quick photo post (the lightbox's + Post toast and Gallery's Add photos error tile) shows the server's suspension message (
postFailureFor,mobile/src/hooks/usePhotoPost.ts:41-49); the board composer and the comment composers show a generic "Couldn't post / Please try again." alert, so a suspended author posting there is never told why. (An admin CAN now send them a direct message — see messages-and-notifications — but nothing in the moderation flow does so automatically.) -
Triage is not moderation, and has no admin UI. Tagging a post or comment
noise/handledrecords that an operator read it; the content stays live on the board and its author is untouched. Nothing rendersPost.triageorPostComment.triagein the admin app — the column is written only bynpm run triage:applyand read only bynpm run triage:collect. -
Comment threads do not nest beyond one level. A reply to a reply is rejected by the API (
BadRequestException('Cannot reply to a reply')). -
@-mentions reach only people already in the thread. The composer's picker offers the post author and existing commenters and nobody else; there is no global handle search. Typing@someoneby hand notifies nobody — only choosing a person from the picker records the id that drives a notification. -
There is no photo-specific report/delete-by-id API beyond the gallery list and lightbox action sheet — reporting a gallery photo goes through the generic
patch_phototarget type in the moderation reports endpoint, not a dedicated/photos/:id/reportroute. A Scout post's photo is reported as its post (type: 'post',mappers.ts:105): there is no way to report one photo of a multi-photo post on its own. -
Post from My Visit does not show where a photo was taken on a map, or anywhere beyond the "Taken here" pin. The photo's own GPS is stored in
Post.imageCoordsand is public through the API to any client, but the app's only use of it is the map-pin marker in the lightbox post block (PostBlock.tsx:44-48). No map, no address, no distance. The coordinates are kept for future features. Since the quick photo post (2026-10-25) nothing on screen says the location goes with the photo — the deleted sheet's footer ("Where each photo was taken goes with it") was the only notice, and the one-tap + Post and Add photos carry none. -
It does not let you choose the board, or post one photo to another board. A visit photo posts to its own patch's board, and Gallery's Add photos posts to the patch you are on.
-
It does not crop or edit the photo (cropping would drop the EXIF the location comes from), and it does not change the board composer screen (
/post-create/<id>) beyond the cache refresh. -
A quick photo post has NO caption, and it can NEVER get a title later. Neither + Post nor Add photos asks for a title or description; the post goes up with
title: ''andbody: ''. The app has no screen for editing a post (postsApi.updatePostexists inmobile/src/api/community.ts:179but nothing calls it), so a photo-only post stays photo-only. Want words? Use the titled composer on the Community tab instead. -
The lightbox no longer bundles several visit photos into one post. One tap on + Post posts that ONE photo; the old sheet's strip for adding the visit's other photos is gone (
VisitPostDraftholds a singlephoto,mobile/src/domain/visitPost.ts:19-23). Several photos in one post come only from Gallery's Add photos (up to 10 library picks) or the board composer. -
Gallery has no "New post" any more. The titled composer is reached from the Community tab/board and the hub only.
-
It does not delete or migrate any
PatchPhoto. The retired gallery's rows and S3 objects stay; the new patch screen just stops showing them, and nothing notifies their authors. The/patches/:id/photosendpoints stay for the flag-off screen and older builds. -
Gallery's Scout posts are not paginated and have no server-side "has photos" filter. They are the board's whole unpaginated feed, filtered on the device.
-
Your own post shows no report flag and no delete in the lightbox — deleting it happens on the post screen; there is no edit anywhere in the app.
-
The moderation queue's "content" preview is best-effort, not guaranteed. If content was already deleted (by its author or an earlier resolution) before a moderator opens the report,
contentrendersnulland the admin UI shows "This content is no longer available."
Tests that cover it
Parent boards (2026-10-13)
backend/src/community-posts/__tests__/campaign-boards.db.spec.ts(real Postgres): roll-up own + member posts and nothing else; adminOnly collections never leak; guides excluded and patch pins not floated; own pins float; a post on a patch in two campaigns on both boards as the same row; blocks; collection andown/patch filters; Most comments; Visited per patch; 404; hub every-board / narrowed / blocked; summaries.backend/src/community-posts/sort.spec.ts; campaign create incommunity-posts.service.spec.ts;community-reports-admin.service.spec.ts(campaign board name).mobile/src/domain/__tests__/communityBoards.test.ts,mobile/src/domain/__tests__/store.communityPrefs.test.ts.- Screen tests:
community.test.tsx(rail order + pins, every-board feed bylines, remembered sort, Boards filter + dot, General count, empty),community-boards.test.tsx,community-board-id.test.tsx(header, chips, bylines, remembered sort, pin + Undo, Boards filter, empty, error, not-found),community-board-new-id.test.tsx(board vs stop, search, failure keeps the draft),post-create-id.test.tsx(hint vs none),post-id.test.tsx(campaign eyebrow, Also on under the thread, none),patch-community-id.test.tsx+patch-detail-new.test.tsx(glass sort),components/community-filter-menu.test.tsx.
Backend (Jest)
backend/src/community-posts/community-posts.service.spec.ts— post CRUD, voting/recount, pin-requires-admin, sort order, official-post identity rendering, image-coordinate validation, comment reply-depth rejection, a dedicateddescribe('block enforcement')block covering feed exclusion, search exclusion, direct-fetch 404 on a blocked author's post, comment exclusion + orphan-reply handling, block-aware comment counts, and blocked vote/comment/like rejection; adescribe('content filter is wired into every write path')block asserting the filter is checked on create/update for both posts and comments; extensivecollectionBoard/patchBoardcoverage (empty states, ranking rules, block exclusion).backend/src/community-posts/official-content.spec.ts— welcome post and Visitor's Guide template content.backend/src/admin/api/community-reports-admin.service.spec.ts— the triage feed's default untriaged bucket, the official/auto-generated exclusion on posts and its deliberate absence on comments, board-name and author rendering, the Guest fallback, reply flagging, single-lookup author resolution, truncation on either list, andNotFoundon tagging a post or comment deleted between collect and apply.backend/src/moderation/moderation.service.spec.ts—hiddenAuthorIdssymmetry (blocked-by-me, blocking-me, mutual collapse), block idempotency/self-block rejection,assertNotBlocked's not-found-not-reveal behavior, report validation/self-report rejection/author denormalization/ re-report-reopens-dismissed,assertNotSuspendedenforcement,resolveReport(deletion, sibling-report closure, already-gone handling, transactional atomicity, block-oracle prevention, suspension + removal, dismissal without content change).backend/src/moderation/text-filter.spec.ts— ordinary posts pass; exact/ stem/plural slur matching; leetspeak folding; punctuation-in-token collapsing; letter-run squeezing (with an explicit "ordinary doubled letters are not squeezed" case); spaced-out letter evasion (with a false-positive-prevention case: only consecutive single-letter runs join); explicit-solicitation terms; returns the matched term, not user text.backend/src/patch-photos/patch-photos.service.spec.ts,backend/src/patch-photos/username.spec.ts.- Post from My Visit (2026-10-11):
backend/src/common/__tests__/image-source-hashes.spec.ts— no hashes → null, a valid parallel array passes, all-null → null, a length mismatch (including hashes with no images), more than 10 and non-64-lowercase-hex entries are rejected;community-posts.service.spec.ts—createPoststores the hashes, rejects a bad one, andpostViewreturns them to the author and null to everyone else (list and single post). - Quick photo post (2026-10-25):
community-posts.service.spec.tsdescribe('a photo post needs no title …')(:299-353) — an empty title accepted on a post with a photo, a missing title stored as'', a blank title on a post with no photo rejected before any write, a titled post kept exactly as sent, andCreatePostDtovalidating a photo post with no title.
Mobile (Jest screen tests, mobile/screen-tests/)
-
community.test.tsx— general feed render, empty state, single-fetch-per- visit. -
patch-community-id.test.tsx— board header/feed render, empty state. The screen's seven gallery states (empty board · read failed · first load · patch never resolved · one post · a busy board · a 61-character patch name) are additionally mounted and content-asserted byscreen-mocks.test.tsx's rot guard. -
post-id.test.tsx— general-board post with no comments, patch-board post with a real comment, 404/unavailable state. -
blocked-accounts.test.tsx— blocked row render for signed-in user, sign-in prompt instead of a fetch when signed out. Opened before the session token arrives, it waits for the refresh and shows the list, or shows the error state with a retry if no token comes. The screen's eight gallery states (nobody blocked · one · a long roster · unblock in flight · long handle · loading · read failed · signed out) are additionally mounted and content-asserted byscreen-mocks.test.tsx's rot guard. -
community-new.test.tsx,post-create-id.test.tsx— composer context chip rendering (the general board's hardcoded label; the patch board's name resolved throughusePatch, with a paired test for the patch that never resolved). The composer's ten gallery states (empty general form · a whitespace-only title · the one-character minimum · ready · both fields at the character ceiling · an upload in flight · one photo · the ten-photo cap · a post in flight · a 63-character board name) are additionally mounted and content-asserted byscreen-mocks.test.tsx's rot guard. -
mobile/src/components/community/__tests__/commentThread.test.ts,PostCard.test.tsx,VisitedBadge.test.tsx— unit tests for the thread grouper, card rendering, and badge visibility.PostCard.test.tsxalso pins the editorial entry: no card treatment, the PINNED eyebrow paired against an unpinned post,PINNED · FROM SCOUT, "1 comment"/"N comments", and that the card hands every photo toFeedPhotoPager(none for a photo-less post), plus the title line for a titled post paired with no title line at all for a photo-only post (who and votes still drawn);PhotoHero.test.tsxthe 16pt gutter, the photo / no-photo pair, and the title over the photo paired with no title node for an empty title. -
mobile/screen-tests/community.test.tsx— acting on a post from the list: the ↑ button votes (POST) and takes back (DELETE) in place without opening the post, the vote lands in a second cached feed holding the same post, a failed vote puts the count back, a guest gets the sign-in prompt and no request, brass when voted paired with the plain text colour when not; the photos swipe with the counter moving1 / 2→2 / 2without opening the post, no touch responder sits between the pager and the entry (the device bug, RED before the restructure), a photo tap opens it, and a single photo is a still frame with no counter.community-board-id.test.tsxpins the vote in the board's accent, paired with an unvoted count. The rollback and accent tests were checked by breaking the code (rollback removed; accent ignored) and watching them fail. -
mobile/screen-tests/patch-community-id.test.tsxalso asserts the banner carries the patch's own name and photo and "4 scouts recommend it", paired with a photo-less patch (name kept, picture dropped, "1 scout recommends it");post-id.test.tsxthe photo hero (first photo, title and board link over it, one thumb per photo) paired with a photo-only post that has no title over its photo, no strip for a single photo, no hero without photos, "General" rendered once, and the official byline paired against a user post. -
Post from My Visit (2026-10-11) and the quick photo post (2026-10-25):
mobile/screen-tests/patch-gallery-id.test.tsx(the lightbox route) — Post on your visit photo and no gallery button, none on a photo with no draft, one tap posting the photo at once (no sheet, no title) with its GPS and hash then Posted and the record written (hashed and compressed from the library file, never theph://ref), the locked "Posting" pill mid-post, a failed post saying so with Retry and the pill coming back, Retry posting after a photo could not be read from the library, the server's suspension message with no Retry, a post-create side-effect failure treated as posted (never a second post), the post landing on the photo you tapped even after swiping away mid-post, an already-posted photo opening as Posted, and a signed-out viewer sent to sign-in with nothing posted;lightbox.test.tsx— the post block from the seed (who, title, votes, comments, photo 1 of 2, taken here) paired with a photo-only post drawn with no title line, no block on an official photo, the optimistic upvote and its rollback on every photo, a signed-out upvote going to sign-in with no request, the comments sheet with the seeded thread paired with the empty thread, the sheet headed by the post's title paired with a photo-only post headed by who posted it, the generic board name, Open post, and a reply moving the count;album-place.test.tsx— Post on an album photo naming its place's board, and Posted from the device record;patch-modal-id.test.tsx— the My Visit "Posted" tag with its votes paired with none, a record from another patch ignored, both captions, and the lightbox offering Post;patch-detail-new.test.tsx— the lone Add photos tile (and no New post), tiles from the seed, a tile opening the lightbox across every post photo, anddescribe('"Add photos"'): the picks posted as ONE untitled post with the posting tile while it goes up, the board refreshed after posting, nothing posted when the picker is cancelled, a failed post becoming Retry that re-posts the same picks without reopening the picker, the server's message that a tap clears rather than retries, and a signed-out viewer asked for an account with no picker opened; then Try again, the row under the lead photo, and post photos in the hero and More photos credited to their author;mobile/hook-tests/usePhotoPost.test.tsx— one post even when tapped twice before the first settles,isPostingwhile in flight, Retry posting the SAME photos without preparing them again, a 4xx with a server message and no Retry, a photo that fails to prepare being a failed post that Retry prepares again, and a signed-out caller asked for an account with nothing posted;post-create-id.test.tsx— the board marked stale after a post and left alone when it fails. Unit:mobile/hook-tests/useVisitPhotoPosting.test.tsx(the record on the first render, learning a post from another device, dropping a record a fresh board contradicts, keeping it while loading, on a failed load, with no hash, on a stale board whose refetch failed and while a refetch is in flight, never claiming someone else's post, hashing the resolved file not theph://ref, no photo lookup when there is nothing to match; and the lookup cost: none when the board confirms the record, a drop with no lookup, only unrecorded photos looked up to learn, a recorded photo looked up only for its page in a multi-photo post, no repeat lookup on a second mount, one lookup in flight at a time),mobile/src/services/__tests__/visitPostSubmit.test.ts(one UNTITLED post with parallel coords and hashes in order, null entries when a photo cannot be hashed or located, nothing created when an upload fails, no photos refused before the network, library picks posted as they are with no lookup or hash, the 10-photo cap, the library file being hashed and uploaded, an unreadable photo uploading nothing, and a never-settling lookup or upload rejecting at the 60s limit),mobile/src/lib/__tests__/assetFile.test.ts(including the per-process cache, keyed by the download flag, never caching a null),mobile/src/domain/__tests__/visitPost.test.ts,postedVisitPhotos.test.ts,store.postedVisitPhotos.test.ts(Going Public fires, the oldpublishedPhotoskey is dropped on rehydrate, the record is forgotten on an account change), andmobile/src/components/lightbox/__tests__/mappers.test.ts(postsToLightboxItems). The post block is also a screen-mock state mounted byscreen-mocks.test.tsx. The one Maestro E2E the spec plans for this (My Visit → Post → Posted → on the board, against a prod build) has not been run.
Maestro E2E (mobile/maestro/tests/community.yaml, tags community,
moderation, fixture, smoke, runs against PROD) — a single consolidated
flow (absorbed four earlier separate flows on 2026-08-26): reads the real
general board (read-only, since prod's general board is real user content),
then exercises upvote, comments, replies, and delete on one post created on a
hidden, uncollectable fixture patch (e2e-test-patch) it cleans up after
itself. It opens the moderation action sheet and asserts the Report/Block
rows are present and can be canceled, and separately checks the
blocked-accounts screen's empty/error rendering. It does not exercise a
successful report submission end-to-end: on a single-account fixture board the
account only ever sees its own content (Report is hidden on your own posts,
API rejects self-reports) and @Scout's pinned, overflow-less Visitor's Guide
— so there is nothing a lone test account can legally report. The flow's own
header comment states this gap explicitly and says covering the report happy
path needs a second provisioned author. Blocking-between-two-accounts is
likewise not exercised end-to-end in Maestro.
Open questions
- Is there any path that clears
Profile.suspendedAt? No admin UI control or endpoint was found. If reversal exists only as a raw SQL update run manually, that should be stated explicitly rather than implied as a normal admin action. - Does the 24-hour response commitment referenced in the admin UI's copy ("We commit to a 24-hour turnaround in our Terms") correspond to an actual Terms of Service clause, and is anything enforcing it operationally (e.g. an alert if the queue goes stale)? Not verified in code — the queue has a pending-count badge but no code-level SLA enforcement was found.
- Is
POST /posts/:postId/pinreachable from anywhere at all (e.g. a direct API call from a support workflow, or truly fully dead)? Confirmed no UI caller exists; did not check for external/manual usage. - Is there any server-side image-content moderation on uploaded photos (patch photos, post/comment attachments) beyond the file-type/size validators? Nothing found, but a dedicated image-moderation service outside these five directories was not exhaustively ruled out.