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 a65263f..65781ce 100644 --- a/InterlinedList/Services/InterlinedApiClient.Follow.cs +++ b/InterlinedList/Services/InterlinedApiClient.Follow.cs @@ -39,6 +39,31 @@ public Task> GetFollowersAsync(string userId, CancellationToken public Task> GetFollowingAsync(string userId, CancellationToken ct = default) => GetUserArrayAsync($"api/follow/{userId}/following", "following", 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; + } /// One page of a user's followers. /// /// Optional server-side filter on the follow edge's state. Live values seen: @@ -62,11 +87,15 @@ public Task GetFollowingPageAsync( string userId, int limit = 50, int offset = 0, string? status = null, CancellationToken ct = default) => GetUserPageAsync($"api/follow/{userId}/following", "following", limit, offset, status, ct); - public Task> GetMutualAsync(string userId, CancellationToken ct = default) - => GetUserArrayAsync($"api/follow/{userId}/mutual", "mutual", ct); - - // 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 d34726d..2241f34 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; /// The profile's followers, paged. Server default page is 50. public ObservableCollection Followers { get; } = new(); @@ -82,7 +90,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" @@ -97,7 +107,6 @@ public ProfileViewModel(SessionService session) { _session = session; FollowRequests.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRequests)); - Mutuals.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasMutuals)); } [RelayCommand] @@ -158,9 +167,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); IsOwnProfile = profile.Id == _session.CurrentUser?.Id; diff --git a/InterlinedList/Views/PeopleView.xaml b/InterlinedList/Views/PeopleView.xaml index d04cb2b..388441d 100644 --- a/InterlinedList/Views/PeopleView.xaml +++ b/InterlinedList/Views/PeopleView.xaml @@ -428,40 +428,22 @@ - - - - - - - - - - - - - - + + + +