Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions InterlinedList/Models/MutualFollowCounts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace InterlinedList.Models;

/// <summary>
/// How many follows two accounts have in common, from
/// <c>GET /api/follow/{userId}/mutual</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Counts only — there is no endpoint that lists the mutual users.</b>
/// Verified live 2026-09-16 across both documented parameter forms:
/// </para>
/// <code>
/// GET /api/follow/{userId}/mutual -> {"mutualFollowers":1,"mutualFollowing":1}
/// GET /api/follow/{userId}/mutual?otherUserId={other} -> {"mutualFollowers":1,"mutualFollowing":1}
/// </code>
/// <para>
/// So clickable "mutual connections" chips cannot be built — don't reinstate
/// them against this endpoint. The previous <c>GetMutualAsync</c> asked for an
/// array under a <c>mutual</c> 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.
/// </para>
/// </remarks>
public sealed class MutualFollowCounts
{
/// <summary>Accounts that follow both the viewer and the profile.</summary>
public int MutualFollowers { get; init; }

/// <summary>Accounts both the viewer and the profile follow.</summary>
public int MutualFollowing { get; init; }

public bool HasAny => MutualFollowers > 0 || MutualFollowing > 0;

/// <summary>A single human line, or null when there is nothing to say.</summary>
public string? Summary => HasAny
? $"{MutualFollowing} mutual following · {MutualFollowers} mutual followers"
: null;

public static MutualFollowCounts None => new();
}
39 changes: 34 additions & 5 deletions InterlinedList/Services/InterlinedApiClient.Follow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,31 @@ public Task<List<FollowUser>> GetFollowersAsync(string userId, CancellationToken
public Task<List<FollowUser>> GetFollowingAsync(string userId, CancellationToken ct = default)
=> GetUserArrayAsync($"api/follow/{userId}/following", "following", ct);

/// <summary>
/// Mutual-follow <b>counts</b> between the caller and <paramref name="userId"/>.
/// </summary>
/// <remarks>
/// <para>
/// This replaces a <c>Task&lt;List&lt;FollowUser&gt;&gt;</c> signature that the API
/// cannot satisfy. The endpoint returns
/// <c>{"mutualFollowers":n,"mutualFollowing":n}</c> — never a user array —
/// so the old call asked <see cref="GetUserArrayAsync"/> for an absent
/// <c>mutual</c> key and silently received an empty list, which is why the
/// profile's "Mutual connections" chips never rendered. See #160.
/// </para>
/// <para>
/// There is <b>no</b> endpoint that lists the mutual users — both this
/// route's parameter forms (bare, and with the spec-documented
/// <c>otherUserId</c>) return the same counts object.
/// </para>
/// </remarks>
public async Task<MutualFollowCounts> GetMutualCountsAsync(string userId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/follow/{userId}/mutual", ct);
return json.ValueKind == JsonValueKind.Object
? json.Deserialize<MutualFollowCounts>(JsonOptions) ?? MutualFollowCounts.None
: MutualFollowCounts.None;
}
/// <summary>One page of a user's followers.</summary>
/// <param name="status">
/// Optional server-side filter on the follow edge's state. Live values seen:
Expand All @@ -62,11 +87,15 @@ public Task<FollowUserPage> 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<List<FollowUser>> 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` -> {"<key>":[…],"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<List<FollowUser>> GetUserArrayAsync(string path, string property, CancellationToken ct)
{
var json = await GetElementAsync(path, ct);
Expand Down
19 changes: 13 additions & 6 deletions InterlinedList/ViewModels/ProfileViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ public partial class ProfileViewModel : ObservableObject

public ObservableCollection<FollowUser> FollowRequests { get; } = new();
public ObservableCollection<MessageItemViewModel> Messages { get; } = new();
public ObservableCollection<FollowUser> Mutuals { get; } = new();
/// <summary>
/// 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).
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMutuals))]
[NotifyPropertyChangedFor(nameof(MutualsSummary))]
private MutualFollowCounts? mutuals;

/// <summary>The profile's followers, paged. Server default page is 50.</summary>
public ObservableCollection<FollowUser> Followers { get; } = new();
Expand Down Expand Up @@ -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"
Expand All @@ -97,7 +107,6 @@ public ProfileViewModel(SessionService session)
{
_session = session;
FollowRequests.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRequests));
Mutuals.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasMutuals));
}

[RelayCommand]
Expand Down Expand Up @@ -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;

Expand Down
50 changes: 16 additions & 34 deletions InterlinedList/Views/PeopleView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -428,40 +428,22 @@
</StackPanel>
</Border>

<!-- Mutual connections -->
<StackPanel Margin="0,0,0,12"
Visibility="{Binding HasMutuals, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Mutual connections"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}" Margin="0,0,0,6"/>
<ItemsControl ItemsSource="{Binding Mutuals}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Command="{Binding DataContext.OpenUserCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Margin="0,0,6,6" Padding="0">
<Border Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
CornerRadius="3" Padding="8,4">
<StackPanel Orientation="Horizontal">
<Border Width="18" Height="18" CornerRadius="999" ClipToBounds="True"
Background="{DynamicResource Surface3Brush}" Margin="0,0,6,0">
<Image Source="{Binding Avatar}" Stretch="UniformToFill"/>
</Border>
<TextBlock Text="{Binding DisplayNameOrUsername}" FontSize="11"
Foreground="{DynamicResource TextBodyBrush}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Mutual follows. Counts only: the API has no endpoint
that lists the mutual users, so the clickable chips that
used to be here could never render anything (#160). -->
<Border Margin="0,0,0,12"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="3"
Padding="10,6"
Visibility="{Binding HasMutuals, Converter={StaticResource BoolToVis}}">
<TextBlock Text="{Binding MutualsSummary}"
FontFamily="JetBrains Mono, Consolas"
FontSize="10"
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"/>
</Border>

<!-- Followers / following. Paged: the server's default page
is 50, so anyone with more had the rest silently dropped.
Expand Down
Loading