From 25eb689aa78e69621923de9114c32f57366b58cc Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:00:57 -0700 Subject: [PATCH] Closes #160 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ⚠️ **Stacked on #141** (`issue-94-handle-lookup`), which also touches `PeopleView.xaml`. Merge that first. > Note #138 (`issue-95-follow-pagination`) independently edits `PeopleView.xaml` just below this block, so whichever of the two merges second needs a small reconcile — different hunks, so it should be trivial. ## The feature never worked `the-gaps.md` records "Mutual connections on a profile — chips that open that user" as shipped in session 6. **It has never displayed anything.** Found by the contract-test suite (#126 / PR #159), confirmed independently. `GET /api/follow/{userId}/mutual` returns **counts**: ``` GET /api/follow/c65092fa-…/mutual -> {"mutualFollowers":0,"mutualFollowing":0} GET /api/follow/15e3d575-…/mutual -> {"mutualFollowers":1,"mutualFollowing":1} GET /api/follow/15e3d575-…/mutual?otherUserId=c65092fa-… -> {"mutualFollowers":1,"mutualFollowing":1} ``` The client asked for an array under a `mutual` key, which doesn't exist: ``` old path: TryGetProperty("mutual") = False -> empty list, no error ``` `GetUserArrayAsync` tolerates an absent array by design, so this became `HasMutuals == false`, the section collapsed, and **nothing reported a problem**. ## The API can't support the built feature Both documented parameter forms return the same counts object. **There is no endpoint that lists mutual users**, so clickable chips are not buildable. Rather than leave a signature the API can't honour, `GetMutualAsync` → **`GetMutualCountsAsync`** returning a real `MutualFollowCounts`, and the profile shows the counts. The "no user-list endpoint exists" finding is recorded on both the model and the method so the chips don't get reinstated. ## Audited the sibling keys while here This is the second bug in two days where a tolerant "absent array → empty list" read hid an API mismatch (the other, #144, at least threw). So I checked every remaining `GetUserArrayAsync` key against live payloads: | key | live | verdict | |---|---|---| | `requests` | `{"requests":[]}` | ✅ correct | | `followers` | `{"followers":[…],"pagination":{…}}` | ✅ correct (#138) | | `following` | `{"following":[…],"pagination":{…}}` | ✅ correct (#138) | | `mutual` | `{"mutualFollowers":…}` | ❌ **this bug** | `mutual` was the only wrong one. The helper now carries a comment warning that a wrong key here fails as *"feature renders nothing"* rather than as an error, and telling the next person to confirm the array exists in a live payload. ## Verification ``` real, non-zero followers=1 following=1 hasAny=True summary=1 mutual following · 1 mutual followers real, zero followers=0 following=0 hasAny=False summary=(null) ALL CHECKS PASSED ``` Zero counts hide the block rather than rendering "0 mutual following · 0 mutual followers". `dotnet build` green in Debug and Release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) PRBODY fix(people): "Mutual connections" rendered nothing — /mutual returns counts the-gaps.md records mutual-connection chips as shipped in session 6. They have never displayed anything. Found by the contract-test suite (#126), confirmed independently. GET /api/follow/{userId}/mutual returns counts, not users: GET /api/follow/15e3d575-…/mutual -> {"mutualFollowers":1,"mutualFollowing":1} but the client asked GetUserArrayAsync for an array under a `mutual` key: TryGetProperty("mutual") = False -> empty list, no error That helper tolerates an absent array by design, so the mismatch became HasMutuals == false, the section collapsed, and nothing reported a problem. The API cannot support the feature as built: both documented parameter forms (bare, and with otherUserId) return the same counts object, and there is no endpoint that lists mutual users. So rather than keep a Task> signature the API can't honour, GetMutualAsync becomes GetMutualCountsAsync returning a typed MutualFollowCounts, and the profile shows counts instead of chips. The "no user-list endpoint exists" finding is recorded on both the model and the method so the chips aren't reinstated. This is the second bug in two days where a tolerant "absent array -> empty list" read hid an API mismatch (#144 was the other, and that one at least threw), so I audited every remaining GetUserArrayAsync key against live payloads: requests -> {"requests":[]} correct followers -> {"followers":[…],"pagination":{…}} correct following -> {"following":[…],"pagination":{…}} correct mutual -> {"mutualFollowers":…} THIS BUG `mutual` was the only wrong one. The helper now warns that a wrong key here fails as "feature renders nothing" rather than as an error. Verified by deserializing both real shapes: non-zero surfaces a summary, zero hides the block rather than rendering "0 mutual following · 0 mutual followers". Build green in Debug and Release. Closes #160 Co-Authored-By: Claude Opus 5 --- InterlinedList/Models/MutualFollowCounts.cs | 40 +++++++++++++++ .../Services/InterlinedApiClient.Follow.cs | 38 ++++++++++++-- InterlinedList/ViewModels/ProfileViewModel.cs | 19 ++++--- InterlinedList/Views/PeopleView.xaml | 50 ++++++------------- 4 files changed, 103 insertions(+), 44 deletions(-) create mode 100644 InterlinedList/Models/MutualFollowCounts.cs diff --git a/InterlinedList/Models/MutualFollowCounts.cs b/InterlinedList/Models/MutualFollowCounts.cs new file mode 100644 index 0000000..e0cb738 --- /dev/null +++ b/InterlinedList/Models/MutualFollowCounts.cs @@ -0,0 +1,40 @@ +namespace InterlinedList.Models; + +/// +/// How many follows two accounts have in common, from +/// GET /api/follow/{userId}/mutual. +/// +/// +/// +/// Counts only — there is no endpoint that lists the mutual users. +/// Verified live 2026-09-16 across both documented parameter forms: +/// +/// +/// GET /api/follow/{userId}/mutual -> {"mutualFollowers":1,"mutualFollowing":1} +/// GET /api/follow/{userId}/mutual?otherUserId={other} -> {"mutualFollowers":1,"mutualFollowing":1} +/// +/// +/// So clickable "mutual connections" chips cannot be built — don't reinstate +/// them against this endpoint. The previous GetMutualAsync asked for an +/// array under a mutual key, which is absent, and the tolerant +/// array reader turned that into an empty list: the feature rendered nothing +/// and reported no error. See #160. +/// +/// +public sealed class MutualFollowCounts +{ + /// Accounts that follow both the viewer and the profile. + public int MutualFollowers { get; init; } + + /// Accounts both the viewer and the profile follow. + public int MutualFollowing { get; init; } + + public bool HasAny => MutualFollowers > 0 || MutualFollowing > 0; + + /// A single human line, or null when there is nothing to say. + public string? Summary => HasAny + ? $"{MutualFollowing} mutual following · {MutualFollowers} mutual followers" + : null; + + public static MutualFollowCounts None => new(); +} diff --git a/InterlinedList/Services/InterlinedApiClient.Follow.cs b/InterlinedList/Services/InterlinedApiClient.Follow.cs index 7b3d83c..4e55309 100644 --- a/InterlinedList/Services/InterlinedApiClient.Follow.cs +++ b/InterlinedList/Services/InterlinedApiClient.Follow.cs @@ -39,11 +39,41 @@ public Task> GetFollowersAsync(string userId, CancellationToken public Task> GetFollowingAsync(string userId, CancellationToken ct = default) => GetUserArrayAsync($"api/follow/{userId}/following", "following", ct); - public Task> GetMutualAsync(string userId, CancellationToken ct = default) - => GetUserArrayAsync($"api/follow/{userId}/mutual", "mutual", ct); + /// + /// Mutual-follow counts between the caller and . + /// + /// + /// + /// This replaces a Task<List<FollowUser>> signature that the API + /// cannot satisfy. The endpoint returns + /// {"mutualFollowers":n,"mutualFollowing":n} — never a user array — + /// so the old call asked for an absent + /// mutual key and silently received an empty list, which is why the + /// profile's "Mutual connections" chips never rendered. See #160. + /// + /// + /// There is no endpoint that lists the mutual users — both this + /// route's parameter forms (bare, and with the spec-documented + /// otherUserId) return the same counts object. + /// + /// + public async Task GetMutualCountsAsync(string userId, CancellationToken ct = default) + { + var json = await GetElementAsync($"api/follow/{userId}/mutual", ct); + return json.ValueKind == JsonValueKind.Object + ? json.Deserialize(JsonOptions) ?? MutualFollowCounts.None + : MutualFollowCounts.None; + } - // Follow lists wrap their array under different property names - // (requests/followers/following/mutual) — pull the named array, tolerate a miss. + // Follow lists wrap their array under different property names — pull the + // named array, tolerate a miss. + // + // Keys verified live 2026-09-16: `requests` -> {"requests":[]}, + // `followers`/`following` -> {"":[…],"pagination":{…}}. NOTE `mutual` + // is NOT one of these — that endpoint returns counts, not an array, and + // this helper's tolerance silently hid the mismatch for weeks (#160). If you + // add a key here, confirm the array actually exists in a live payload; a + // wrong key fails as "feature renders nothing", not as an error. private async Task> GetUserArrayAsync(string path, string property, CancellationToken ct) { var json = await GetElementAsync(path, ct); diff --git a/InterlinedList/ViewModels/ProfileViewModel.cs b/InterlinedList/ViewModels/ProfileViewModel.cs index e5e3349..2769fb5 100644 --- a/InterlinedList/ViewModels/ProfileViewModel.cs +++ b/InterlinedList/ViewModels/ProfileViewModel.cs @@ -17,7 +17,15 @@ public partial class ProfileViewModel : ObservableObject public ObservableCollection FollowRequests { get; } = new(); public ObservableCollection Messages { get; } = new(); - public ObservableCollection Mutuals { get; } = new(); + /// + /// Mutual-follow counts. The API exposes counts only — there is no endpoint + /// listing the mutual users, so the old clickable chips could never work + /// (#160). + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasMutuals))] + [NotifyPropertyChangedFor(nameof(MutualsSummary))] + private MutualFollowCounts? mutuals; [ObservableProperty] private string lookupUsername = ""; @@ -48,7 +56,9 @@ public partial class ProfileViewModel : ObservableObject public bool HasProfile => Profile is not null; - public bool HasMutuals => Mutuals.Count > 0; + public bool HasMutuals => Mutuals?.HasAny == true; + + public string MutualsSummary => Mutuals?.Summary ?? string.Empty; public string FollowButtonText => Relationship?.IsFollowing == true ? "Following" @@ -63,7 +73,6 @@ public ProfileViewModel(SessionService session) { _session = session; FollowRequests.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRequests)); - Mutuals.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasMutuals)); } [RelayCommand] @@ -124,9 +133,7 @@ private async Task LoadProfileAsync() foreach (var message in page.Messages) Messages.Add(new MessageItemViewModel(message, _session.Api, _session.CurrentUser?.Id)); - Mutuals.Clear(); - foreach (var mutual in await _session.Api.GetMutualAsync(profile.Id)) - Mutuals.Add(mutual); + Mutuals = await _session.Api.GetMutualCountsAsync(profile.Id); ErrorMessage = null; } diff --git a/InterlinedList/Views/PeopleView.xaml b/InterlinedList/Views/PeopleView.xaml index f769a10..ba47d15 100644 --- a/InterlinedList/Views/PeopleView.xaml +++ b/InterlinedList/Views/PeopleView.xaml @@ -336,40 +336,22 @@ - - - - - - - - - - - - - - + + + +