Skip to content

feat(profile): My Profile — the signed-in user's own page (G32) #44

Description

@Adron

Summary

The macOS app has no view of your own account. ProfileRootView opens on an empty "Enter a username to view their public profile." prompt (ProfileRootView.swift:169) even though currentUserStore.currentUserID is already known — so the one profile every user wants to see is the one that takes the most typing to reach.

What the web shows at /user/{handle}

  • Avatar, display name, @handle, bio
  • Five stat tiles: Followers, Following, Posts, Documents, Lists
  • A Public Lists column (a tree, with a Watch button per list when you are signed in and it is not your own profile)
  • A Public Documents column
  • A Follow button, and a Message button once you and the other person follow each other

The native header shows followers/following plus mutuals, and no content columns at all.

Profile URL details worth honouring in any deep-link handling: handles are case-insensitive (/user/adron == /user/Adron), /@username is a shortcut, and usernames are [A-Za-z0-9_.-] (anything else typed at signup becomes _ in the URL while the display name keeps what was typed).

Data available (probed live 2026-09-07)

GET /api/users/{username}/documents → 200, and it returns folders too:

{"documents":[{"id":"026028c0-…","title":"Railroad Apps to Build for Fun",
               "folderId":null,"relativePath":"railroad-passenger-seating.md",
               "createdAt":"…","updatedAt":"…"}],
 "folders":[]}

The public-lists equivalent still needs probing — find the route the web profile page calls for another user's public list tree before designing that column. GET /api/lists/watching (also verified live) is the watched lists surface and is a different thing; it is covered in the Lists issue.

Division of labor

Kit

  • GET /api/users/{username}/documents request builder + DTO (documents and folders).
  • The public-lists-by-user route, once probed.

Domain

  • Extend the public-profile model with post/document/list counts and the two content collections.
  • A selfProfile path that loads from the signed-in account rather than the public-author fallback. ⚠️ Decision 0003 / Decision 0002 constraint: the profileUnavailable empty state must never fire for your own account.

App

  • ProfileRootView: when no username is entered and a user is signed in, land on your own profile. Keep the lookup field as a way to visit someone else.
  • Five stat tiles; Public Lists and Public Documents columns.
  • A Watch action per list when viewing someone else's profile (needs POST /api/lists/{id}/watchers — shared with the Lists issue; coordinate so it is built once).
  • Keep the existing Follow and Message buttons; hide Follow on your own profile.
  • Handle deep links case-insensitively and accept the @handle form.

Tests

  • happy — signed in, no input → own profile with all five counts
  • invalid — unknown username → the existing profileUnavailable state, and assert it cannot fire for the signed-in user
  • upstream-failure — the documents call fails while the profile succeeds → the profile still renders with that column in an error state, not a whole-page failure
  • boundary — zero lists / zero documents → empty columns, not missing columns; mixed-case and @-prefixed handles resolve

Acceptance criteria

  • Opening Profile while signed in shows your own account with no typing.
  • Public lists and public documents are visible for yourself and for others.
  • Full E2E gate green.

Notes

work-consolidation.md tracks this as G32. Size M. Pairs with the sidebar-IA issue — build the page first, then re-home it.


Implementation plan (added 2026-09-15)

The probes the issue asked for

The public-lists route is found. GET /api/users/{username}/lists → {lists[], pagination}, confirmed live 2026-09-15. It is the public browse collection, distinct from GET /api/lists/watching (the caller's own watched surface).

Four of the five stat tiles need no new API at all. GET /api/users/{username} already returns them:

{"followerCount":1,"followingCount":1,"publicMessageCount":31,"publicListCount":0,
 "headerImage":null,"bio":"…","joinedAt":"…","isPrivate":false}

PublicProfileDTO has decoded publicMessageCount and publicListCount since the endpoint shipped — they were dropped at the domain boundary. So this is a carry-through, not an integration. The fifth tile (Documents) has no count on that payload and comes from the documents column.

Kit

Nothing. Every route and field needed is already modelled.

Domain

  • UserProfile gains publicMessageCount, publicListCount, headerImageURL, carried in init(from: PublicProfileDTO) and in withCounts(_:) — the follow-counts stitch rebuilds the profile, and not carrying them there would blank two tiles every time that follow-up landed.
  • ListsServicing.watch(listId:) — the route's self-subscribe branch (POST …/watchers with no userId), which is free. Modelled as its own method rather than an optional parameter on addWatcher, because "add this person" and "subscribe me" are different intents that happen to share a URL — and addWatcher's empty-id guard must keep erroring rather than quietly becoming this.

App

  • ProfileRootView lands on the signed-in user's own profile. loadOwnProfileIfNeeded() is a no-op when a profile is already loaded, so re-entering the tab does not yank the user off whoever they were browsing.
  • ProfileStatTilesView — the five tiles. A 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.
  • PublicUserListsView / PublicUserListsViewModel — the lists column, mirroring PublicUserDocumentsView's self-contained shape. Watch is optimistic with rollback and per-row error reporting; it is offered only when the profile is someone else's and a session exists.
  • PublicUserDocumentsView gains an onCountChange callback so the Documents tile reuses the fetch the column already makes — one request, and the tile and the column can never disagree.
  • Handle normalisation (ProfileViewModel.normalizedHandle): strips a leading @ and lowercases, since handles are case-insensitive and /@username is a documented shortcut. Legal username punctuation passes through untouched rather than being sanitised against a charset the client would get subtly wrong.
  • Ownership is compared on id, not handle — a handle comparison would go wrong exactly where it matters, on a rename.

The profileUnavailable guard

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 profile does not exist. It is now suppressed for self and still fires for everyone else, with a test in both directions.

Out of scope

The Message button already exists. The sidebar re-homing is #45, which this unblocks.

Activity

  1. added
    enhancementNew feature or request
    parityWeb-parity gap with the InterlinedList web app
    on Sep 7, 2026
  2. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    Implemented in PR #90 (G32). Work continues on the PR.

    The public-lists route this issue recorded as still needing a probe is GET /api/users/{username}/lists → {lists[], pagination} — the public browse collection, distinct from /api/lists/watching exactly as suspected.

    Four of the five stat tiles needed no new API: publicMessageCount and publicListCount were decoded by the kit since the endpoint shipped and dropped at the domain boundary.

    The profileUnavailable guard this issue flagged is in, tested in both directions — suppressing it universally would hide an accurate explanation for someone else's empty profile.

    This unblocks #45.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestparityWeb-parity gap with the InterlinedList web app

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions