Summary
A member can set a profile photo on the Edit profile screen
(scout://edit-profile, board ticket 1c035f64, 2026-09-28). The photo replaces
the brass initial on these surfaces (and only these): their own Profile screen and
app drawer, the bylines on their community posts and comments, the comment
composer, the @-mention picker, the leaderboard (rows, podium and the pinned
"You" row), the blocked-accounts list, and their public web profile page
(/u/:slug) and its share card. Every upload is checked by an automated
moderation model before it is accepted; if the check cannot run, the photo is
refused rather than accepted unchecked.
Before this there was no photo anywhere: every in-app avatar was an initial on a
brass disc, drawn by three separate BrassAvatar copies, and the public page
showed a generated pattern.
Status
Shipped, unconditional, members only. No feature flag gates it. Guests
cannot set a photo. It needs OPENAI_API_KEY on the server — see "Configuration
and flags": without it no one can set a photo.
User-facing surfaces
- Edit profile (
mobile/app/edit-profile.tsx,scout://edit-profile), reached from Profile's Edit profile button or by tapping the Profile avatar (which carries a camera badge). "Choose photo" (library), "Take photo" (camera) and "Remove" sit under the avatar; tapping the avatar opens the library. A picked photo is staged: it shows in the avatar and in the "How you'll appear" byline preview with "New photo. Tap Save to use it.", and nothing is uploaded until Save. Cancel discards it. - Everywhere a person is drawn — one shared component,
UserAvatar(mobile/src/components/ui/UserAvatar.tsx);Scout.Avataris a thin adapter over it. - Public profile page
/u/:slugand its/u/:slug/card.pngshare card.
How it works
- Pick.
pickImages({ source, allowsEditing: true })(mobile/src/lib/image-pick.ts) after the library/camera permission check; the image is compressed to JPEG on the device. It is staged in screen state. - Save.
accountApi.savesends one multipartPATCH /api/account/profile(photo, andhandleif it changed) through the FormData-aware client (mobile/src/api/client.ts). - Moderate, then store.
ProfileEditService.save(backend/src/account/profile-edit.service.ts):- runs
ImageModerationService.check(backend/src/moderation/image-moderation.service.ts, OpenAIomni-moderation-latest) on the raw bytes before anything is uploaded, so a rejected image never reaches the public bucket. Flagged → 422 "That photo can't be used. Try a different one." Cannot decide (no key, network or API error, an empty answer) → 503 "Couldn't check that photo. Try again." — fail-closed; ImageUploadService.uploadResized(..., 'avatars', { maxEdge: 512, square: true })cover-crops to a 512×512 webp (sharpattentioncrop) and writes a public S3 object underavatars/;- writes
Profile.avatarUrl(and the handle) in one row update. If that write fails — e.g. the handle was taken in a race — the object this request just uploaded is deleted; after a successful write, the photo it replaced is deleted.
- runs
- Show. The app reads its own photo from
GET /api/account/profile(useMyProfile), not the JWT, which cannot carry it. The byline DTOs behind the surfaces above carryavatarUrlbeside the name (the notification, patch-photo and admin payloads do not — see "does NOT do"), from one shared lookup,profileIdentityMap(backend/src/prisma/profile-names.ts). Save writes the result into themyProfilecache and invalidates the byline queries (community boards, leaderboard, messages) so the new photo appears at once. - Remove.
removePhoto=truesetsavatarUrlto null (the initial returns) and deletes the object. - Public page.
GET /u/:sluguses the photo in place of the generated avatar. The share card embeds it as a PNG data URI clipped to a circle (backend/src/public-profile/avatar-embed.ts,og-card.ts), with a 3 s fetch timeout; a slow, missing or undecodable photo falls back to the generated avatar rather than breaking the card.
Data model
Profile.avatarUrl(backend/prisma/schema.prisma, columnavatar_url, migration20260928120000_profile_avatar) — nullable text, the public S3 URL. Null renders the initial.Profileis not a content table, so it is not part of content publishing.
API surface
All JwtAuthGuard; a guest gets 403 on writes.
| Method & path | Throttle | Purpose |
|---|---|---|
GET /api/account/profile |
— | { id, handle, avatarUrl, email, isAnonymous } |
PATCH /api/account/profile |
10/min | Multipart: optional photo (jpeg/png/webp/heic, ≤10 MB), handle, removePhoto=true. Errors: 400 handle-invalid, 409 handle-taken, 422 photo-rejected, 503 moderation-unavailable |
Byline payloads that now carry avatarUrl: community posts and comments,
message comments, leaderboard entries, blocked accounts.
Key files
backend/src/account/profile-edit.service.ts— the save, in order.backend/src/moderation/image-moderation.service.ts— the fail-closed check.backend/src/uploads/image-upload.service.ts—squarecover-crop option.backend/src/prisma/profile-names.ts—profileIdentityMap(name + photo).backend/src/account/account.service.ts— account deletion deletes the photo first and aborts if it cannot.backend/src/public-profile/{avatar-embed,og-card,public-profile.controller}.ts— the public page and share card.mobile/app/edit-profile.tsx— the editor.mobile/src/components/ui/UserAvatar.tsx— the one avatar (photo or initial).mobile/src/hooks/useMyProfile.ts,mobile/src/api/account.ts.
Configuration and flags
OPENAI_API_KEY(backend) is required. The moderation check fails closed: with the key missing or the API down, every photo upload returns 503 and no photo can be set. Nothing else in this feature is configurable.
Edge cases and known limits
- A photo that fails to load (object deleted, network) falls back to the initial in the app — never an empty ring.
- A handle race during a photo save returns 409 and withdraws the just-uploaded photo; the member's previous photo is untouched.
- Account deletion deletes the photo, before the rows; if the S3 delete fails the whole deletion aborts (same rule as the cloud album).
- The share card waits at most 3 s for the photo, then uses the generated avatar.
- The server crops to a square whatever the picker returns.
What this feature does NOT do
- Guests cannot set a photo.
- It does not reach every place a person appears. The notifications list
still draws initials for the people who replied or reacted, the patch-photo
viewer credits a photo by
@handlewith no avatar, and admin screens are unchanged. Only the surfaces listed under "User-facing surfaces" show photos. - There is no admin tool to remove a member's photo (or reset their handle). Taking one down today means a manual database change or deleting the account.
- There is no human review queue. The check is automated only; nothing an admin approves. A photo that passes the model is live at once.
- Photos cannot be reported. Community reporting covers posts, comments and patch photos, not profile photos.
- No per-photo privacy setting. The photo shows everywhere the member appears, including the public web page and share card; the only way to keep it off the web is the existing public-profile switch in Settings, which hides the whole page.
- No non-square photos, video, GIFs or animated images. Everything is cropped to a 512px square still.
- Old photos are not kept. Replacing or removing deletes the previous one.
- The public page still does not show the handle — only the photo is new there (see user-handles).
Tests that cover it
backend/src/account/profile-edit.service.spec.ts— flagged → 422 with nothing uploaded; moderation unavailable → 503; a lost handle race deletes the new photo and keeps the old one (falsified); the old photo is deleted only after the write; remove; a photo-only save for a grandfathered handle.backend/src/moderation/image-moderation.service.spec.ts— ok / flagged, and fail-closed on no key, API error and an empty result.backend/src/uploads/image-upload.service.spec.ts— the square crop (real sharp).backend/src/account/account.service.spec.ts— deletion removes the photo first and aborts when it cannot.backend/src/public-profile/{avatar-embed,og-card,public-profile.page,public-profile.service}.spec.ts— the embed, its PNG re-encode, 404 and timeout fallbacks, and the page model.- Byline services (
community-posts,messages-social,leaderboard,moderationlistBlocked) — each DTO carriesavatarUrl, null when absent. mobile/src/components/ui/__tests__/UserAvatar.test.tsx— photo vs initial, load-failure fallback, a unique gradient id per instance (falsified).mobile/screen-tests/edit-profile.test.tsx— staging, rejection revert (falsified), 503, Cancel sends nothing, load failure, guest.mobile/screen-tests/profile.test.tsx,custom-drawer.test.tsx— photo vs initial, paired.
Open questions
- Whether
OPENAI_API_KEYis set on the production server was not verified from code; it must be before this ships, or photos cannot be set. - Behaviour on a real device (picker crop, camera permission, Android glow) is handed to on-device review; it is not covered by the screen tests.