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)
+ {
+
+ }
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. *@
+
+
+ }
+ @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);
+ }
+ }
+}