Repository navigation
feat(profile): My Profile — land on your own account, five stat tiles, and both content columns (G32) - #90
Merged
Merged
Conversation
…tat tiles and both content columns
Opening Profile put up an "enter a username" prompt even though the session
already knew who the user was, so the one profile everybody wants to see was the
one that took the most typing to reach. It now lands on your own account, and
the lookup field stays as the way to visit someone else.
Most of this was already on the wire and being thrown away. `GET /api/users/
{username}` returns publicMessageCount and publicListCount, and the kit has
decoded both since the endpoint shipped — the domain model dropped them at the
boundary, so the header could only ever show two of the web's five tiles. The
carry-through matters in one non-obvious place: `withCounts` rebuilds the profile
after the follow-counts follow-up lands, and not carrying them there would blank
two tiles every time that call returned.
The public-lists column is the piece the issue recorded as still needing a probe.
`GET /api/users/{username}/lists` answers `{lists, pagination}` — the public
browse collection, not `/api/lists/watching`, which is the caller's own watched
surface and a different thing. The column mirrors PublicUserDocumentsView's
self-contained shape so the profile view adds it in one line.
Watch uses the route's self-subscribe branch — POST watchers with no userId —
which is free, because the subscription gates granting someone else access, not
following a list that is already public to you. It is modelled as its own
`watch(listId:)` rather than an optional parameter on `addWatcher`: those are
different intents that happen to share a URL, and addWatcher's empty-id guard has
to keep erroring rather than quietly becoming a self-subscribe.
Two judgement calls worth stating. A stat tile whose count is unknown is omitted
rather than rendered as zero — "0 posts" and "we could not find out how many
posts" look identical and mean opposite things. And ownership is compared on id,
not handle, because a handle comparison goes wrong exactly where it matters: on
a rename, when the session's cached handle and the profile's disagree.
The `profileUnavailable` empty state is now suppressed for your own account.
It means "this user has no public messages, so there is nothing to project a
profile from" — a statement about other people's public content. Shown for
yourself it tells a brand-new user their own profile does not exist. Tested in
both directions, because suppressing it universally would hide an accurate
explanation for someone else's empty profile.
Handles are normalised at the single load entry point, so a deep link, a typed
handle and the self-landing all agree that "adron", "@Adron" and "/user/ADRON"
are the same person.
Refs #44
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #44 (G32). Unblocks #45, which needed this page to exist before the sidebar could point at it.
Opening Profile put up an "enter a username" prompt even though the session already knew who the user was — so the one profile everybody wants to see was the one that took the most typing to reach. It now lands on your own account, and the lookup field stays as the way to visit someone else.
Most of this was already on the wire and being thrown away
GET /api/users/{username}returns everything four of the five tiles need, andPublicProfileDTOhas decoded it since the endpoint shipped:{"followerCount":1,"followingCount":1,"publicMessageCount":31,"publicListCount":0, "headerImage":null,"bio":"…","joinedAt":"…","isPrivate":false}publicMessageCountandpublicListCountwere dropped at the domain boundary. So this is a carry-through, not an integration — and it matters in one non-obvious place:withCountsrebuilds the profile after the follow-counts follow-up lands, and not carrying them there would blank two tiles every time that call returned. There is a test for exactly that.The probe the issue asked for
Found:
GET /api/users/{username}/lists→{lists[], pagination}, confirmed live. It is the public browse collection — distinct fromGET /api/lists/watching, which is the caller's own watched surface and a different thing, exactly as the issue suspected.Watch is the free branch of a gated route
POST /api/lists/{id}/watcherswith nouserIdis the self-subscribe branch, and it is free: the subscription gates granting someone else access, not following a list that is already public to you. Gating it would make the Watch button on a public profile an upsell for something the web gives away.It is modelled as its own
watch(listId:)rather than an optional parameter onaddWatcher, because those are different intents that happen to share a URL — andaddWatcher's empty-id guard has to keep erroring rather than quietly becoming a self-subscribe. A test pins both halves.The
profileUnavailableguardThe issue flagged this and it has teeth. Decision 0002's empty state means "this user has no public messages, so there is nothing to project a profile from" — a statement about other people's public content. Shown for your own account, it tells a brand-new user with nothing posted yet that their own profile does not exist.
Suppressed for self, still fires for everyone else, tested in both directions — suppressing it universally would hide an accurate explanation for someone else's empty profile.
Two judgement calls worth stating
A stat tile whose count is unknown is omitted, not rendered as zero. "0 posts" and "we could not find out how many posts" look identical and mean opposite things, and a profile that claims zero when a call merely failed is worse than one showing four tiles.
Ownership is compared on
id, not handle. A handle comparison goes wrong exactly where it matters — on a rename, when the session's cached handle and the profile's disagree.One request, one number
PublicUserDocumentsViewgains anonCountChangecallback so the Documents tile reuses the fetch the column already makes. Asking again would be a duplicate request whose answer could disagree with what is on screen.Handles
Normalised at the single load entry point, so a deep link, a typed handle and the self-landing all agree that
adron,@Adronand/user/ADRONare the same person. Legal username punctuation ([A-Za-z0-9_.-]) passes through untouched rather than being sanitised against a charset the client would get subtly wrong.Verification
xcodebuild build→** BUILD SUCCEEDED **xcodebuild test(App) →Executed 984 tests, with 0 failures·** TEST SUCCEEDED **(was 968)swift test InterlinedDomain→Executed 1020 tests, with 0 failures(was 1012)swift test InterlinedPersistence→Executed 140 tests, with 0 failuresswift test InterlinedKit --skip ContractTests→Executed 477 tests, with 0 failures— the liveContractTestsare skipped here because the account is rate-limited from this session's API recon; they are unmodified by this PR.New tests:
ProfileViewModelTests(8),PublicUserListsViewModelTests(8),ProfileContentCountsTests(4), plus 4 self-watch cases inOwnedListsServiceTests.Acceptance
🤖 Generated with Claude Code
https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9