From e2491de16eb8cca1f634f88efc9016a0425a487f Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 02:49:51 +0200 Subject: [PATCH] Lists: made on /lists, read as a tab of the feed A persona's lists (PrivaPub's /api/v1/lists): made, renamed, given a replies policy and made exclusive on /lists, filled from the accounts it follows (accounts/search?following=true), and read as the feed's Lists tab. A followed account's profile has a Lists menu to put it in or take it out. ListService does the calls; the e2e tests check each step against PrivaPub's own answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 8 +- Components/Feed/FeedTabs.razor | 36 +++- Extensions/GenericExtensions.cs | 1 + MastodonJsonContext.cs | 3 + Models/APIs.cs | 8 + Models/FeedFilter.cs | 2 + Models/Mastodon/MastodonList.cs | 29 +++ Models/Types/TimelineType.cs | 3 +- Pages/Index.razor | 9 +- Pages/Lists.razor | 334 ++++++++++++++++++++++++++++++++ Pages/Profile.razor | 62 ++++++ Program.cs | 1 + Resources/AllStrings.it.resx | 81 ++++++++ Resources/AllStrings.resx | 81 ++++++++ Services/ListService.cs | 127 ++++++++++++ Services/TimelineService.cs | 6 +- Shared/NavMenu.razor | 4 + tests/e2e/Actions/ListTests.cs | 128 ++++++++++++ 18 files changed, 914 insertions(+), 9 deletions(-) create mode 100644 Models/Mastodon/MastodonList.cs create mode 100644 Pages/Lists.razor create mode 100644 Services/ListService.cs create mode 100644 tests/e2e/Actions/ListTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 157983f..0cfaf3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,14 +210,18 @@ supplies: `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`; located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow, - block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `MediaService` (uploads), - `GroupService`, `InstanceService` and `ProvenanceService`. + block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the + persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), + `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice. - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. +- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members + leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists + menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub. - **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from `PrivaPub.ClientModels`. - **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), diff --git a/Components/Feed/FeedTabs.razor b/Components/Feed/FeedTabs.razor index 1af6cfb..704aec1 100644 --- a/Components/Feed/FeedTabs.razor +++ b/Components/Feed/FeedTabs.razor @@ -1,7 +1,8 @@ @inherits LocalizableComponentBase @* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the - persona's communities and circles, Tag the hashtag being followed. The stats icon shows how the current feed was + persona's communities and circles, Lists a choice of its lists (made and filled on /lists), Tag the hashtag being + followed. The stats icon shows how the current feed was read; the buttons on the right open the composer and, on narrow screens, the globe. *@
@@ -49,6 +50,28 @@ } } + else if (Filter.TimelineType is TimelineType.Lists) + { +
+ @if (Lists.Count == 0) + { +

@Localizer["This persona has no list yet."]

+ } + else + { + + + + @foreach (var list in Lists) + { + + } + + + } + @Localizer["Manage lists"] +
+ } else if (Filter.TimelineType is TimelineType.Tag) {

#@Filter.Tag

@@ -58,6 +81,7 @@ @code { [Parameter, EditorRequired] public FeedFilter Filter { get; set; } [Parameter] public List Groups { get; set; } = []; + [Parameter] public List Lists { get; set; } = []; [Parameter] public IEnumerable Stats { get; set; } = []; [Parameter] public bool IsComposing { get; set; } [Parameter] public EventCallback OnCompose { get; set; } @@ -67,7 +91,7 @@ static readonly TimelineType[] Always = [ TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct, - TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks + TimelineType.Notifications, TimelineType.Favourites, TimelineType.Bookmarks, TimelineType.Lists ]; List Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always]; @@ -90,6 +114,14 @@ Filter.TimelineType = Types[index]; if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null) Filter.GroupId = Groups.FirstOrDefault()?.Id; + if (Filter.TimelineType is TimelineType.Lists && Lists.All(l => l.Id != Filter.ListId)) + Filter.ListId = Lists.FirstOrDefault()?.Id; + await OnChanged.InvokeAsync(Filter); + } + + async Task SelectList(string listId) + { + Filter.ListId = listId; await OnChanged.InvokeAsync(Filter); } diff --git a/Extensions/GenericExtensions.cs b/Extensions/GenericExtensions.cs index 255fa66..0ce8722 100644 --- a/Extensions/GenericExtensions.cs +++ b/Extensions/GenericExtensions.cs @@ -92,6 +92,7 @@ public static class GenericExtensions TimelineType.Favourites => "ion-md-heart", TimelineType.Bookmarks => "ion-md-bookmark", TimelineType.Tag => "ion-md-pricetag", + TimelineType.Lists => "ion-md-list", _ => default }; diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index 84ef497..c957be5 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -35,6 +35,9 @@ namespace decePubClient; [JsonSerializable(typeof(PostStatusForm))] [JsonSerializable(typeof(Poll))] [JsonSerializable(typeof(PollVoteForm))] +[JsonSerializable(typeof(MastodonList))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ListAccountsForm))] public partial class MastodonJsonContext : JsonSerializerContext { } diff --git a/Models/APIs.cs b/Models/APIs.cs index 5869824..11c3cff 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -62,6 +62,14 @@ public static class APIs $"api/v1/pleroma/statuses/{Uri.EscapeDataString(statusId)}/reactions/{Uri.EscapeDataString(emoji)}"; public static string POST_M_PollVotes(string pollId) => $"api/v1/polls/{Uri.EscapeDataString(pollId)}/votes"; public static string POST_M_FollowRequest(string accountId, string answer) => $"api/v1/follow_requests/{Uri.EscapeDataString(accountId)}/{answer}"; + public const string GET_Lists = "api/v1/lists"; + public const string POST_List = "api/v1/lists"; + public const string GET_AccountSearch = "api/v1/accounts/search"; + public static string PUT_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}"; + public static string DELETE_M_List(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}"; + public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts"; + public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists"; + public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}"; } public static class PrivaPub diff --git a/Models/FeedFilter.cs b/Models/FeedFilter.cs index f121a30..c9e0174 100644 --- a/Models/FeedFilter.cs +++ b/Models/FeedFilter.cs @@ -8,6 +8,8 @@ public class FeedFilter public TimelineType TimelineType { get; set; } = TimelineType.Home; /// The community or circle the Groups tab shows. public string GroupId { get; set; } + /// The persona's list the Lists tab shows. + public string ListId { get; set; } /// The hashtag the Tag tab shows, without #. public string Tag { get; set; } /// Whether the globe is open where it can be closed (narrow screens). diff --git a/Models/Mastodon/MastodonList.cs b/Models/Mastodon/MastodonList.cs new file mode 100644 index 0000000..60fc01a --- /dev/null +++ b/Models/Mastodon/MastodonList.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace decePubClient.Models.Mastodon; + +/// A persona's list of accounts it follows (Mastodon's List): read as its own feed, and when exclusive kept out of +/// the home feed. Lists are the persona's own; PrivaPub never federates them. +public class MastodonList +{ + public string Id { get; set; } + public string Title { get; set; } + public ListRepliesPolicy RepliesPolicy { get; set; } = ListRepliesPolicy.List; + public bool Exclusive { get; set; } +} + +/// Which replies a list's feed shows besides its members' replies to themselves and to the persona. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ListRepliesPolicy +{ + /// Replies to any account the persona follows. + [JsonStringEnumMemberName("followed")] Followed, + /// Replies to the list's members. + [JsonStringEnumMemberName("list")] List, + [JsonStringEnumMemberName("none")] None +} + +public class ListAccountsForm +{ + public List AccountIds { get; set; } = []; +} diff --git a/Models/Types/TimelineType.cs b/Models/Types/TimelineType.cs index 6f3b97c..359affe 100644 --- a/Models/Types/TimelineType.cs +++ b/Models/Types/TimelineType.cs @@ -12,6 +12,7 @@ namespace decePubClient.Models.Types Notifications, Favourites, Bookmarks, - Tag + Tag, + Lists } } diff --git a/Pages/Index.razor b/Pages/Index.razor index 225208c..61f4484 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -16,7 +16,7 @@
-
@@ -103,6 +103,7 @@ [Inject] IJSRuntime JS { get; set; } [Inject] TimelineService TimelineService { get; set; } [Inject] GroupService GroupService { get; set; } + [Inject] ListService ListService { get; set; } [Inject] InstanceService InstanceService { get; set; } [Inject] RequestStats RequestStats { get; set; } @@ -110,6 +111,7 @@ List posts = []; List pending = []; List groups = []; + List lists = []; string maxId; string minId; string loadError; @@ -174,7 +176,7 @@ } FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true, - Filter.Tag, position?.Latitude, position?.Longitude); + Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId); /// Reads the first page of the current feed again, and the persona's groups. async Task Reload() @@ -194,10 +196,13 @@ if (CascadingState.PersonaId != loadedPersonaId) { groups = await GroupService.List(CascadingState.PersonaId, cancellationToken); + lists = await ListService.All(cancellationToken); loadedPersonaId = CascadingState.PersonaId; } if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId)) Filter.GroupId = groups.FirstOrDefault()?.Id; + if (Filter.TimelineType is TimelineType.Lists && lists.All(l => l.Id != Filter.ListId)) + Filter.ListId = lists.FirstOrDefault()?.Id; lastQuery = Query; if (Filter.TimelineType is TimelineType.Nearby && position is null) { diff --git a/Pages/Lists.razor b/Pages/Lists.razor new file mode 100644 index 0000000..004b7c8 --- /dev/null +++ b/Pages/Lists.razor @@ -0,0 +1,334 @@ +@page "/lists" +@attribute [Authorize] +@inherits LocalizableComponentBase +@Localizer["Lists"] + +@* The current persona's lists (/api/v1/lists): made, renamed, set to show which replies and whether their members stay + out of the home feed, and deleted; their members added from the accounts the persona follows (PrivaPub accepts no + others, and drops a member when the follow ends) and removed. A list is read as a tab of the feed page. Lists are + the persona's own and never leave PrivaPub. *@ +
+
+ +
+ +

@Localizer["Lists"]

+
+ + + + + + + @Localizer["Make a list"] + + + + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (lists.Count == 0) + { +

@Localizer["This persona has no list yet."]

+ } + +
+ @foreach (var list in lists) + { +
+
+ @list.Title + @if (list.Exclusive) + { + + @Localizer["Exclusive"] + + } + + + + + + +
+ + @if (open?.Id == list.Id) + { + +
+ + + + + @foreach (var policy in Policies) + { + + } + + +
+ +
+ + @Localizer["Save"] + + @if (isConfirmingDelete) + { + + @Localizer["Delete it for good"] + + @Localizer["Cancel"] + } + else + { + + @Localizer["Delete"] + + } +
+
+ +

@Localizer["Members"] @members.Count

+
+ @if (members.Count == 0) + { +

@Localizer["Nobody is in this list yet."]

+ } + @foreach (var account in members) + { +
+ @* alt="": the name is next to it. *@ + + + @account.Name + @("@" + account.Acct) + + + + +
+ } +
+ + + + + + + @if (isFinding) + { + + } + @Localizer["Search"] + + + @if (found is not null) + { +
+ @if (Candidates.Count == 0) + { +

@Localizer["Only accounts you follow can join a list, and none of them matches."]

+ } + @foreach (var account in Candidates) + { +
+ @* alt="": the name is next to it. *@ + + + @account.Name + @("@" + account.Acct) + + + @Localizer["Add"] + +
+ } +
+ } + } +
+ } +
+
+
+ +@code { + static readonly ListRepliesPolicy[] Policies = [ListRepliesPolicy.List, ListRepliesPolicy.Followed, ListRepliesPolicy.None]; + + [Inject] ListService ListService { get; set; } + [Inject] ILocalStorageService LocalStorage { get; set; } + + List lists = []; + MastodonList draft = new(); + MastodonList edit = new(); + MastodonList open; + List members = []; + List found; + SearchForm find = new(); + string loadedPersonaId; + bool isSaving; + bool isFinding; + bool isConfirmingDelete; + + List Candidates => found.Where(a => members.All(m => m.Id != a.Id)).ToList(); + + string PolicyName(ListRepliesPolicy policy) => policy switch + { + ListRepliesPolicy.Followed => Localizer["Replies to anyone you follow"], + ListRepliesPolicy.None => Localizer["No replies"], + _ => Localizer["Replies to the list's members"] + }; + + // The persona changed (NavMenu's switcher): its lists are another persona's. + protected override async Task OnParametersSetAsync() + { + if (CascadingState.PersonaId is null) + { + IsLoading = false; + return; + } + if (CascadingState.PersonaId == loadedPersonaId) + return; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + } + + async Task LoadData() + { + IsLoading = true; + open = null; + members = []; + found = null; + lists = await ListService.All(); + IsLoading = false; + } + + async Task Create() + { + isSaving = true; + var result = await ListService.Save(new MastodonList { Title = draft.Title }); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var list = (MastodonList)result.Data; + lists.Add(list); + lists = [.. lists.OrderBy(l => l.Title, StringComparer.CurrentCultureIgnoreCase)]; + draft = new(); + Toast.AddResponseSuccess(Localizer["List {0} made.", list.Title]); + await Toggle(list); + } + + async Task Toggle(MastodonList list) + { + isConfirmingDelete = false; + found = null; + find = new(); + if (open?.Id == list.Id) + { + open = null; + members = []; + return; + } + + open = list; + edit = new MastodonList { Id = list.Id, Title = list.Title, RepliesPolicy = list.RepliesPolicy, Exclusive = list.Exclusive }; + members = await ListService.Members(list.Id); + } + + async Task Save() + { + isSaving = true; + var result = await ListService.Save(edit); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var saved = (MastodonList)result.Data; + var index = lists.FindIndex(l => l.Id == saved.Id); + if (index >= 0) + lists[index] = saved; + open = saved; + Toast.AddResponseSuccess(Localizer["List {0} saved.", saved.Title]); + } + + async Task Delete() + { + isSaving = true; + var result = await ListService.Delete(open.Id); + isSaving = false; + isConfirmingDelete = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + lists.RemoveAll(l => l.Id == open.Id); + Toast.AddResponseSuccess(Localizer["List {0} deleted.", open.Title]); + open = null; + members = []; + } + + async Task Find() + { + isFinding = true; + found = await ListService.FindFollowed(find.Query); + isFinding = false; + } + + async Task SetMember(Account account, bool member) + { + isSaving = true; + var result = await ListService.SetMember(open.Id, account.Id, member); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + if (member) + members.Add(account); + else + members.RemoveAll(m => m.Id == account.Id); + Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, open.Title] : Localizer["{0} removed from {1}.", account.Name, open.Title]); + } + + async Task Read(MastodonList list) + { + var filter = await LocalStorage.GetItemAsync(nameof(FeedFilter)) ?? new(); + filter.TimelineType = TimelineType.Lists; + filter.ListId = list.Id; + await LocalStorage.SetItemAsync(nameof(FeedFilter), filter); + Navigation.NavigateTo(""); + } + + sealed class SearchForm + { + public string Query { get; set; } + } +} diff --git a/Pages/Profile.razor b/Pages/Profile.razor index b42e8c4..bcca77a 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -56,6 +56,30 @@ @FollowLabel } + @if (relationship?.Following == true) + { + + + @Localizer["Lists"] + + + @if (allLists.Count == 0) + { +

@Localizer["This persona has no list yet."]

+ } + @foreach (var list in allLists) + { + + } + @Localizer["Manage lists"] +
+
+ } @@ -140,6 +164,7 @@ @code { [Inject] AccountService AccountService { get; set; } + [Inject] ListService ListService { get; set; } [Inject] InstanceService InstanceService { get; set; } [Inject] ILocalStorageService LocalStorage { get; set; } [Inject] IJSRuntime JS { get; set; } @@ -155,6 +180,9 @@ string loadedPersonaId; bool isActing; bool isLoadingMore; + bool isListMenuOpen; + List allLists = []; + HashSet inLists = []; bool IsOwn => account is not null && account.Id == CascadingState.PersonaId; @@ -197,12 +225,44 @@ return; } relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id); + await LoadLists(); posts = []; maxId = null; IsLoading = false; await LoadPage(); } + /// The persona's lists and which of them hold the account, for an account the persona follows. + async Task LoadLists() + { + if (relationship?.Following != true) + { + allLists = []; + inLists = []; + return; + } + allLists = await ListService.All(); + inLists = [.. (await ListService.Of(account.Id)).Select(l => l.Id)]; + } + + async Task SetListMember(MastodonList list, bool member) + { + isActing = true; + var result = await ListService.SetMember(list.Id, account.Id, member); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + if (member) + inLists.Add(list.Id); + else + inLists.Remove(list.Id); + Toast.AddResponseSuccess(member ? Localizer["{0} added to {1}.", account.Name, list.Title] : Localizer["{0} removed from {1}.", account.Name, list.Title]); + } + async Task LoadPage() { isLoadingMore = true; @@ -247,6 +307,8 @@ } relationship = (Relationship)result.Data ?? relationship; + if (action is AccountAction.Follow or AccountAction.Unfollow) + await LoadLists(); Toast.AddResponseSuccess(action switch { AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name], diff --git a/Program.cs b/Program.cs index a135bbc..5782c4a 100644 --- a/Program.cs +++ b/Program.cs @@ -42,6 +42,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 3601fe3..3c0eebf 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -908,4 +908,85 @@ Reagisci + + Liste + + + Gestisci le liste + + + Questa persona non ha ancora liste. + + + Il titolo di una nuova lista + + + Crea una lista + + + I suoi membri restano fuori dal feed principale. + + + Esclusiva + + + Leggi la lista + + + Modifica + + + Salva + + + Eliminala per sempre + + + Membri + + + Nessuno รจ ancora in questa lista. + + + Togli dalla lista + + + Togli + + + Trova account che segui + + + Solo gli account che segui possono entrare in una lista, e nessuno di loro corrisponde. + + + Aggiungi + + + Risposte a chiunque tu segua + + + Nessuna risposta + + + Risposte ai membri della lista + + + Tieni i suoi membri fuori dal feed principale + + + Lista {0} creata. + + + Lista {0} salvata. + + + Lista {0} eliminata. + + + {0} aggiunto a {1}. + + + {0} tolto da {1}. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 3b20160..1399e16 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -908,4 +908,85 @@ React + + Lists + + + Manage lists + + + This persona has no list yet. + + + A new list's title + + + Make a list + + + Its members stay out of the home feed. + + + Exclusive + + + Read the list + + + Edit + + + Save + + + Delete it for good + + + Members + + + Nobody is in this list yet. + + + Remove from the list + + + Remove + + + Find accounts you follow + + + Only accounts you follow can join a list, and none of them matches. + + + Add + + + Replies to anyone you follow + + + No replies + + + Replies to the list's members + + + Keep its members out of the home feed + + + List {0} made. + + + List {0} saved. + + + List {0} deleted. + + + {0} added to {1}. + + + {0} removed from {1}. + diff --git a/Services/ListService.cs b/Services/ListService.cs new file mode 100644 index 0000000..073d5df --- /dev/null +++ b/Services/ListService.cs @@ -0,0 +1,127 @@ +using System.Net; + +using collAnon.Client.Services; + +using decePubClient.Extensions; +using decePubClient.Models; +using decePubClient.Models.Mastodon; + +using PrivaPub.ClientModels; + +namespace decePubClient.Services +{ + /// + /// The current persona's lists (Mastodon's /api/v1/lists): made, renamed and deleted, their members (only accounts the + /// persona follows; PrivaPub drops a member when the follow ends), and the lists an account is in. A list's posts are a + /// feed of their own (TimelineService, TimelineType.Lists). Lists come back empty on failure; nothing throws. + /// + public class ListService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + /// The most members PrivaPub keeps in one list, and so the most read back. + public const int MaxMembers = 500; + const int MembersPerPage = 80; + + public async Task> All(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_Lists, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(All), cancellationToken); + return []; + } + + /// Makes the list when it has no id yet, or saves its title, replies policy and exclusivity; Data is the list. + public async Task Save(MastodonList list, CancellationToken cancellationToken = default) + { + var form = new MastodonList { Title = list.Title?.Trim(), RepliesPolicy = list.RepliesPolicy, Exclusive = list.Exclusive }; + using var response = list.Id is null + ? await httpService.Post(APIs.Mastodon.POST_List, form, cancellationToken: cancellationToken) + : await httpService.Put(APIs.Mastodon.PUT_M_List(list.Id), form, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + public async Task Delete(string listId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_List(listId), cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult(); + } + + /// Every member of the list, page by page, up to MaxMembers. + public async Task> Members(string listId, CancellationToken cancellationToken = default) + { + var members = new List(); + var maxId = default(string); + while (members.Count < MaxMembers) + { + string[] parameters = maxId is null ? [$"limit={MembersPerPage}"] : [$"limit={MembersPerPage}", $"max_id={Uri.EscapeDataString(maxId)}"]; + using var response = await httpService.Get(APIs.Mastodon.M_ListAccounts(listId), parameters, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + await LogFailure(response, nameof(Members), cancellationToken); + return members; + } + + var page = await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + members.AddRange(page); + maxId = response.PageCursors().MaxId; + if (page.Count < MembersPerPage || maxId is null) + return members; + } + return members; + } + + /// Adds (or removes) an account the persona follows to (from) the list. + public async Task SetMember(string listId, string accountId, bool member, CancellationToken cancellationToken = default) + { + using var response = member + ? await httpService.Post(APIs.Mastodon.M_ListAccounts(listId), new ListAccountsForm { AccountIds = [accountId] }, + cancellationToken: cancellationToken) + : await httpService.Delete(APIs.Mastodon.M_ListAccounts(listId), [$"account_ids[]={Uri.EscapeDataString(accountId)}"], + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult(); + } + + /// The persona's lists the account is in. + public async Task> Of(string accountId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountLists(accountId), cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(Of), cancellationToken); + return []; + } + + /// Accounts the persona follows whose name, display name or server starts with the query (PrivaPub's + /// following=true; nothing is resolved). + public async Task> FindFollowed(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + return []; + using var response = await httpService.Get(APIs.Mastodon.GET_AccountSearch, + [$"q={Uri.EscapeDataString(query.Trim())}", "following=true", "resolve=false", "limit=20"], cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(FindFollowed), cancellationToken); + return []; + } + + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) + { + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound)) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ListService)}.{caller}", cancellationToken); + } + } +} diff --git a/Services/TimelineService.cs b/Services/TimelineService.cs index 4e3744c..49765d0 100644 --- a/Services/TimelineService.cs +++ b/Services/TimelineService.cs @@ -12,9 +12,9 @@ using PrivaPub.ClientModels.Post; namespace decePubClient.Services { - /// What a feed shows: its kind, and the group, hashtag or position some kinds need. + /// What a feed shows: its kind, and the group, hashtag, list or position some kinds need. public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default, - double? Latitude = default, double? Longitude = default); + double? Latitude = default, double? Longitude = default, string ListId = default); /// /// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer) @@ -41,6 +41,8 @@ namespace decePubClient.Services TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken), TimelineType.Tag when query.Tag is { Length: > 0 } tag => await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken), + TimelineType.Lists when query.ListId is { Length: > 0 } listId => + await Statuses(APIs.Mastodon.GET_M_ListTimeline(listId), [], maxId, minId, cancellationToken), TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity => await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken), TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken), diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index b74bb84..09b5fbb 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -47,6 +47,10 @@ @Localizer["Search"] + + + @Localizer["Lists"] + @if (CascadingState.PersonaId is { } personaId) { diff --git a/tests/e2e/Actions/ListTests.cs b/tests/e2e/Actions/ListTests.cs new file mode 100644 index 0000000..323fbb7 --- /dev/null +++ b/tests/e2e/Actions/ListTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // A list in decePub: made on /lists, filled with an account the persona follows (found by its name among the accounts + // it follows), read as the feed's Lists tab, made exclusive and deleted. Each step is checked against PrivaPub's own + // answer to the persona's token, so the page and the server agree on the list, its members and its posts. + public sealed class ListTests(Browser browser) + { + static async Task Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) + { + using var http = Pasture.Http(); + using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path); + if (body is not null) + request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token); + using var response = await http.SendAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return JsonNode.Parse(await response.Content.ReadAsStringAsync()); + } + + // an account the persona follows that has written something + static async Task Followed(Pasture.Persona persona) => + (await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray() + .FirstOrDefault(a => a!["statuses_count"]!.GetValue() > 0 && !a["acct"]!.GetValue().StartsWith("st_")); + + [Fact] + public async Task An_account_joins_and_leaves_a_list_from_its_profile() + { + var persona = Pasture.Personas().First(); + var member = await Followed(persona); + Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted"); + var memberId = member!["id"]!.GetValue(); + var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue(); + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + try + { + await Results.Cell("client.action.list-member", "privapub", persona.UserName, async () => + { + async Task InList() => + (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue() == memberId); + + await page.GotoAsync($"{Pasture.Site}/account/{memberId}"); + var profile = page.Locator("article[data-account-id]").First; + await profile.WaitForAsync(new() { Timeout = 30000 }); + await profile.GetByRole(AriaRole.Button, new() { Name = "Lists" }).ClickAsync(); + var box = profile.Locator($"[data-list-id='{listId}'] input"); + await box.CheckAsync(); + Assert.True(await Pasture.Eventually(InList, 15), "the profile's checkbox never put the account in the list"); + await box.UncheckAsync(); + Assert.True(await Pasture.Eventually(async () => !await InList(), 15), "the profile's checkbox never took the account out"); + await session.AssertGuards(); + }, page); + } + finally + { + await Api(persona, $"/api/v1/lists/{listId}", HttpMethod.Delete); + } + } + + [Fact] + public async Task A_list_is_made_filled_read_as_a_feed_and_deleted() + { + var persona = Pasture.Personas().First(); + var member = await Followed(persona); + Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted"); + var memberId = member!["id"]!.GetValue(); + var title = $"e2e {Guid.NewGuid():N}"[..12]; + // lists an earlier failed run left behind + foreach (var left in (await Api(persona, "/api/v1/lists"))!.AsArray().Where(l => l!["title"]!.GetValue().StartsWith("e2e "))) + await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue()}", HttpMethod.Delete); + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + await Results.Cell("client.action.list", "privapub", persona.UserName, async () => + { + await page.GotoAsync($"{Pasture.Site}/lists"); + await page.Locator("[data-list-new] input, input[data-list-new]").First.FillAsync(title); + await page.GetByRole(AriaRole.Button, new() { Name = "Make a list" }).ClickAsync(); + var article = page.Locator("article[data-list-id]").Filter(new() { HasText = title }); + await article.WaitForAsync(new() { Timeout = 15000 }); + var listId = await article.GetAttributeAsync("data-list-id"); + Assert.Contains((await Api(persona, "/api/v1/lists"))!.AsArray(), l => l!["id"]!.GetValue() == listId); + + // the list opens once made: find the member among the accounts followed, and add it + await article.Locator("[data-list-find] input, input[data-list-find]").First.FillAsync(member["username"]!.GetValue()); + await article.Locator("[data-list-find] input, input[data-list-find]").First.PressAsync("Enter"); + var candidate = article.Locator($"[data-candidate-id='{memberId}']"); + await candidate.WaitForAsync(new() { Timeout = 15000 }); + await candidate.GetByRole(AriaRole.Button, new() { Name = "Add" }).ClickAsync(); + await article.Locator($"[data-member-id='{memberId}']").WaitForAsync(new() { Timeout = 15000 }); + Assert.Equal([memberId], (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Select(a => a!["id"]!.GetValue())); + + // read as a feed: the cards are the list timeline's first page, as PrivaPub answers it + await article.GetByRole(AriaRole.Button, new() { Name = "Read the list" }).ClickAsync(); + await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + var expected = (await Api(persona, $"/api/v1/timelines/list/{listId}?limit=20"))!.AsArray() + .Select(s => (s!["reblog"] ?? s)["uri"]!.GetValue()).ToHashSet(); + var shown = await page.Locator("article[data-uri]").EvaluateAllAsync("cards => cards.map(c => c.dataset.uri)"); + Assert.NotEmpty(expected); + Assert.Subset(expected, shown.ToHashSet()); + + // exclusive, then deleted + await page.GotoAsync($"{Pasture.Site}/lists"); + article = page.Locator($"article[data-list-id='{listId}']"); + await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync(); + await article.GetByLabel("Keep its members out of the home feed").CheckAsync(); + await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); + Assert.True(await Pasture.Eventually(async () => (await Api(persona, $"/api/v1/lists/{listId}"))?["exclusive"]?.GetValue() == true, 15), + "PrivaPub never made the list exclusive"); + // the icon's glyph (CSS content) is part of a button's name, so names are matched as substrings + await article.GetByRole(AriaRole.Button, new() { Name = "Delete" }).First.ClickAsync(); + await article.GetByRole(AriaRole.Button, new() { Name = "Delete it for good" }).ClickAsync(); + await Assertions.Expect(article).ToHaveCountAsync(0, new() { Timeout = 15000 }); + Assert.Null(await Api(persona, $"/api/v1/lists/{listId}")); + await session.AssertGuards(); + }, page); + } + } +}