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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
011bafb6af
commit
e2491de16e
18 files changed
+914
-9
No files matched your search
@@ -210,14 +210,18 @@ supplies:
|
|||||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
`/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`;
|
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,
|
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),
|
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||||
`GroupService`, `InstanceService` and `ProvenanceService`.
|
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
|
- **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
|
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.
|
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
|
- **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.
|
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.
|
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
|
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
||||||
`PrivaPub.ClientModels`.
|
`PrivaPub.ClientModels`.
|
||||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
@inherits LocalizableComponentBase
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
@* The feed page's tabs, one per kind of feed, scrolling sideways on narrow screens. Groups adds a choice of the
|
@* 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. *@
|
read; the buttons on the right open the composer and, on narrow screens, the globe. *@
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
@@ -49,6 +50,28 @@
|
|||||||
</DSelect>
|
</DSelect>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (Filter.TimelineType is TimelineType.Lists)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if (Lists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DSelect TValue="string" Size="DSize.Sm" class="w-full sm:w-auto" Value="Filter.ListId" ValueChanged="SelectList" ValueExpression="() => Filter.ListId">
|
||||||
|
<Prefix><i class="ion-md-list"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var list in Lists)
|
||||||
|
{
|
||||||
|
<option value="@list.Id">@list.Title</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
}
|
||||||
|
<a class="btn btn-sm" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
else if (Filter.TimelineType is TimelineType.Tag)
|
else if (Filter.TimelineType is TimelineType.Tag)
|
||||||
{
|
{
|
||||||
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p>
|
<p class="text-sm inline-flex items-center gap-2"><i class="ion-md-pricetag"></i><b>#@Filter.Tag</b></p>
|
||||||
@@ -58,6 +81,7 @@
|
|||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
[Parameter, EditorRequired] public FeedFilter Filter { get; set; }
|
||||||
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
[Parameter] public List<ViewGroup> Groups { get; set; } = [];
|
||||||
|
[Parameter] public List<MastodonList> Lists { get; set; } = [];
|
||||||
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
[Parameter] public IEnumerable<NerdRow> Stats { get; set; } = [];
|
||||||
[Parameter] public bool IsComposing { get; set; }
|
[Parameter] public bool IsComposing { get; set; }
|
||||||
[Parameter] public EventCallback OnCompose { get; set; }
|
[Parameter] public EventCallback OnCompose { get; set; }
|
||||||
@@ -67,7 +91,7 @@
|
|||||||
static readonly TimelineType[] Always =
|
static readonly TimelineType[] Always =
|
||||||
[
|
[
|
||||||
TimelineType.Home, TimelineType.Local, TimelineType.Federation, TimelineType.Nearby, TimelineType.Groups, TimelineType.Direct,
|
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<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
List<TimelineType> Types => Filter.Tag is { Length: > 0 } ? [.. Always, TimelineType.Tag] : [.. Always];
|
||||||
@@ -90,6 +114,14 @@
|
|||||||
Filter.TimelineType = Types[index];
|
Filter.TimelineType = Types[index];
|
||||||
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
if (Filter.TimelineType is TimelineType.Groups && SelectedGroup is null)
|
||||||
Filter.GroupId = Groups.FirstOrDefault()?.Id;
|
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);
|
await OnChanged.InvokeAsync(Filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public static class GenericExtensions
|
|||||||
TimelineType.Favourites => "ion-md-heart",
|
TimelineType.Favourites => "ion-md-heart",
|
||||||
TimelineType.Bookmarks => "ion-md-bookmark",
|
TimelineType.Bookmarks => "ion-md-bookmark",
|
||||||
TimelineType.Tag => "ion-md-pricetag",
|
TimelineType.Tag => "ion-md-pricetag",
|
||||||
|
TimelineType.Lists => "ion-md-list",
|
||||||
_ => default
|
_ => default
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(PostStatusForm))]
|
[JsonSerializable(typeof(PostStatusForm))]
|
||||||
[JsonSerializable(typeof(Poll))]
|
[JsonSerializable(typeof(Poll))]
|
||||||
[JsonSerializable(typeof(PollVoteForm))]
|
[JsonSerializable(typeof(PollVoteForm))]
|
||||||
|
[JsonSerializable(typeof(MastodonList))]
|
||||||
|
[JsonSerializable(typeof(List<MastodonList>))]
|
||||||
|
[JsonSerializable(typeof(ListAccountsForm))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,14 @@ public static class APIs
|
|||||||
$"api/v1/pleroma/statuses/{Uri.EscapeDataString(statusId)}/reactions/{Uri.EscapeDataString(emoji)}";
|
$"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_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 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
|
public static class PrivaPub
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public class FeedFilter
|
|||||||
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
public TimelineType TimelineType { get; set; } = TimelineType.Home;
|
||||||
/// <summary>The community or circle the Groups tab shows.</summary>
|
/// <summary>The community or circle the Groups tab shows.</summary>
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
|
/// <summary>The persona's list the Lists tab shows.</summary>
|
||||||
|
public string ListId { get; set; }
|
||||||
/// <summary>The hashtag the Tag tab shows, without #.</summary>
|
/// <summary>The hashtag the Tag tab shows, without #.</summary>
|
||||||
public string Tag { get; set; }
|
public string Tag { get; set; }
|
||||||
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
|
/// <summary>Whether the globe is open where it can be closed (narrow screens).</summary>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which replies a list's feed shows besides its members' replies to themselves and to the persona.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<ListRepliesPolicy>))]
|
||||||
|
public enum ListRepliesPolicy
|
||||||
|
{
|
||||||
|
/// <summary>Replies to any account the persona follows.</summary>
|
||||||
|
[JsonStringEnumMemberName("followed")] Followed,
|
||||||
|
/// <summary>Replies to the list's members.</summary>
|
||||||
|
[JsonStringEnumMemberName("list")] List,
|
||||||
|
[JsonStringEnumMemberName("none")] None
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListAccountsForm
|
||||||
|
{
|
||||||
|
public List<string> AccountIds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ namespace decePubClient.Models.Types
|
|||||||
Notifications,
|
Notifications,
|
||||||
Favourites,
|
Favourites,
|
||||||
Bookmarks,
|
Bookmarks,
|
||||||
Tag
|
Tag,
|
||||||
|
Lists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-2
@@ -16,7 +16,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
||||||
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
||||||
OnChanged="ChangeFeed" />
|
OnChanged="ChangeFeed" />
|
||||||
|
|
||||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||||
@@ -103,6 +103,7 @@
|
|||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
[Inject] TimelineService TimelineService { get; set; }
|
[Inject] TimelineService TimelineService { get; set; }
|
||||||
[Inject] GroupService GroupService { get; set; }
|
[Inject] GroupService GroupService { get; set; }
|
||||||
|
[Inject] ListService ListService { get; set; }
|
||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] RequestStats RequestStats { get; set; }
|
[Inject] RequestStats RequestStats { get; set; }
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
List<FeedPost> posts = [];
|
List<FeedPost> posts = [];
|
||||||
List<FeedPost> pending = [];
|
List<FeedPost> pending = [];
|
||||||
List<ViewGroup> groups = [];
|
List<ViewGroup> groups = [];
|
||||||
|
List<MastodonList> lists = [];
|
||||||
string maxId;
|
string maxId;
|
||||||
string minId;
|
string minId;
|
||||||
string loadError;
|
string loadError;
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
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);
|
||||||
|
|
||||||
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
||||||
async Task Reload()
|
async Task Reload()
|
||||||
@@ -194,10 +196,13 @@
|
|||||||
if (CascadingState.PersonaId != loadedPersonaId)
|
if (CascadingState.PersonaId != loadedPersonaId)
|
||||||
{
|
{
|
||||||
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
|
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
|
||||||
|
lists = await ListService.All(cancellationToken);
|
||||||
loadedPersonaId = CascadingState.PersonaId;
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
}
|
}
|
||||||
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
||||||
Filter.GroupId = groups.FirstOrDefault()?.Id;
|
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;
|
lastQuery = Query;
|
||||||
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
@page "/lists"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Lists"]</Title>
|
||||||
|
|
||||||
|
@* 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. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-list text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Lists"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
placeholder="@Localizer["A new list's title"]" data-list-new>
|
||||||
|
<Prefix><i class="ion-md-add"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
|
||||||
|
<span>@Localizer["Make a list"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (lists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var list in lists)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-list-id="@list.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@list.Title</b>
|
||||||
|
@if (list.Exclusive)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" title="@Localizer["Its members stay out of the home feed."]">
|
||||||
|
<i class="ion-md-eye-off"></i> @Localizer["Exclusive"]
|
||||||
|
</DBadge>
|
||||||
|
}
|
||||||
|
<DButton Size="DSize.Sm" OnClick="@(() => Read(list))" title="@Localizer["Read the list"]">
|
||||||
|
<i class="ion-md-paper"></i><span class="hidden sm:inline">@Localizer["Read the list"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == list.Id)" OnClick="@(() => Toggle(list))" title="@Localizer["Edit"]">
|
||||||
|
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == list.Id)
|
||||||
|
{
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
aria-label="@Localizer["Title"]" data-list-title />
|
||||||
|
<DSelect TValue="ListRepliesPolicy" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.RepliesPolicy">
|
||||||
|
<Prefix><i class="ion-md-undo"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var policy in Policies)
|
||||||
|
{
|
||||||
|
<option value="@policy">@PolicyName(policy)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
</div>
|
||||||
|
<DToggle Size="DSize.Sm" @bind-Value="edit.Exclusive" LabelText="@Localizer["Keep its members out of the home feed"]" />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title))">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (isConfirmingDelete)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count</span></h2>
|
||||||
|
<div class="flex flex-col gap-2" data-list-members>
|
||||||
|
@if (members.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Nobody is in this list yet."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in members)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-3 min-w-0" data-member-id="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<a class="flex flex-col min-w-0 flex-1" href="@($"account/{Uri.EscapeDataString(account.Id)}")">
|
||||||
|
<b class="truncate text-sm">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</a>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => SetMember(account, false))"
|
||||||
|
title="@Localizer["Remove from the list"]">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="find" OnValidSubmit="Find" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="find.Query" Immediate="true" autocomplete="off" type="search"
|
||||||
|
placeholder="@Localizer["Find accounts you follow"]" data-list-find>
|
||||||
|
<Prefix><i class="ion-md-search"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isFinding || string.IsNullOrWhiteSpace(find.Query))">
|
||||||
|
@if (isFinding)
|
||||||
|
{
|
||||||
|
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||||
|
}
|
||||||
|
<span>@Localizer["Search"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
@if (found is not null)
|
||||||
|
{
|
||||||
|
<div class="flex flex-col gap-2" data-list-found>
|
||||||
|
@if (Candidates.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70">@Localizer["Only accounts you follow can join a list, and none of them matches."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var account in Candidates)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-3 min-w-0" data-candidate-id="@account.Id">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0 flex-1">
|
||||||
|
<b class="truncate text-sm">@account.Name</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => SetMember(account, true))">
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
static readonly ListRepliesPolicy[] Policies = [ListRepliesPolicy.List, ListRepliesPolicy.Followed, ListRepliesPolicy.None];
|
||||||
|
|
||||||
|
[Inject] ListService ListService { get; set; }
|
||||||
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||||
|
|
||||||
|
List<MastodonList> lists = [];
|
||||||
|
MastodonList draft = new();
|
||||||
|
MastodonList edit = new();
|
||||||
|
MastodonList open;
|
||||||
|
List<Account> members = [];
|
||||||
|
List<Account> found;
|
||||||
|
SearchForm find = new();
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
bool isFinding;
|
||||||
|
bool isConfirmingDelete;
|
||||||
|
|
||||||
|
List<Account> 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<FeedFilter>(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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,30 @@
|
|||||||
@FollowLabel
|
@FollowLabel
|
||||||
</DButton>
|
</DButton>
|
||||||
}
|
}
|
||||||
|
@if (relationship?.Following == true)
|
||||||
|
{
|
||||||
|
<DDropdown @bind-IsOpen="isListMenuOpen" Align="DDropdownAlign.End" TriggerClass="btn btn-sm"
|
||||||
|
ContentClass="mt-2 flex flex-col gap-2 p-3 rounded-box w-64 bg-base-100 neo-raised neo-xs z-20" title="@Localizer["Lists"]">
|
||||||
|
<Trigger>
|
||||||
|
<i class="ion-md-list"></i><span>@Localizer["Lists"]</span>
|
||||||
|
</Trigger>
|
||||||
|
<ChildContent>
|
||||||
|
@if (allLists.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-xs opacity-70">@Localizer["This persona has no list yet."]</p>
|
||||||
|
}
|
||||||
|
@foreach (var list in allLists)
|
||||||
|
{
|
||||||
|
<label class="label text-sm" data-list-id="@list.Id">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@inLists.Contains(list.Id)" disabled="@(IsDefaultDisabled || isActing)"
|
||||||
|
@onchange="@((ChangeEventArgs e) => SetListMember(list, e.Value is true))" />
|
||||||
|
<span class="truncate">@list.Title</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<a class="btn btn-xs" href="lists"><i class="ion-md-create"></i><span>@Localizer["Manage lists"]</span></a>
|
||||||
|
</ChildContent>
|
||||||
|
</DDropdown>
|
||||||
|
}
|
||||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing || relationship is null)"
|
||||||
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
|
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
|
||||||
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
|
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
|
||||||
@@ -140,6 +164,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] ListService ListService { get; set; }
|
||||||
[Inject] InstanceService InstanceService { get; set; }
|
[Inject] InstanceService InstanceService { get; set; }
|
||||||
[Inject] ILocalStorageService LocalStorage { get; set; }
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
@@ -155,6 +180,9 @@
|
|||||||
string loadedPersonaId;
|
string loadedPersonaId;
|
||||||
bool isActing;
|
bool isActing;
|
||||||
bool isLoadingMore;
|
bool isLoadingMore;
|
||||||
|
bool isListMenuOpen;
|
||||||
|
List<MastodonList> allLists = [];
|
||||||
|
HashSet<string> inLists = [];
|
||||||
|
|
||||||
bool IsOwn => account is not null && account.Id == CascadingState.PersonaId;
|
bool IsOwn => account is not null && account.Id == CascadingState.PersonaId;
|
||||||
|
|
||||||
@@ -197,12 +225,44 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
|
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
|
||||||
|
await LoadLists();
|
||||||
posts = [];
|
posts = [];
|
||||||
maxId = null;
|
maxId = null;
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
await LoadPage();
|
await LoadPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's lists and which of them hold the account, for an account the persona follows.</summary>
|
||||||
|
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()
|
async Task LoadPage()
|
||||||
{
|
{
|
||||||
isLoadingMore = true;
|
isLoadingMore = true;
|
||||||
@@ -247,6 +307,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
relationship = (Relationship)result.Data ?? relationship;
|
relationship = (Relationship)result.Data ?? relationship;
|
||||||
|
if (action is AccountAction.Follow or AccountAction.Unfollow)
|
||||||
|
await LoadLists();
|
||||||
Toast.AddResponseSuccess(action switch
|
Toast.AddResponseSuccess(action switch
|
||||||
{
|
{
|
||||||
AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name],
|
AccountAction.Follow when relationship?.Requested == true => Localizer["Asked {0} to accept you.", account.Name],
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<TimelineService>()
|
.AddScoped<TimelineService>()
|
||||||
.AddScoped<StatusService>()
|
.AddScoped<StatusService>()
|
||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
|
.AddScoped<ListService>()
|
||||||
.AddScoped<PublicNoticeService>()
|
.AddScoped<PublicNoticeService>()
|
||||||
.AddScoped<MediaService>()
|
.AddScoped<MediaService>()
|
||||||
.AddScoped<GroupService>()
|
.AddScoped<GroupService>()
|
||||||
|
|||||||
@@ -908,4 +908,85 @@
|
|||||||
<data name="React" xml:space="preserve">
|
<data name="React" xml:space="preserve">
|
||||||
<value>Reagisci</value>
|
<value>Reagisci</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Lists" xml:space="preserve">
|
||||||
|
<value>Liste</value>
|
||||||
|
</data>
|
||||||
|
<data name="Manage lists" xml:space="preserve">
|
||||||
|
<value>Gestisci le liste</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona has no list yet." xml:space="preserve">
|
||||||
|
<value>Questa persona non ha ancora liste.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A new list's title" xml:space="preserve">
|
||||||
|
<value>Il titolo di una nuova lista</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make a list" xml:space="preserve">
|
||||||
|
<value>Crea una lista</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its members stay out of the home feed." xml:space="preserve">
|
||||||
|
<value>I suoi membri restano fuori dal feed principale.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Exclusive" xml:space="preserve">
|
||||||
|
<value>Esclusiva</value>
|
||||||
|
</data>
|
||||||
|
<data name="Read the list" xml:space="preserve">
|
||||||
|
<value>Leggi la lista</value>
|
||||||
|
</data>
|
||||||
|
<data name="Edit" xml:space="preserve">
|
||||||
|
<value>Modifica</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save" xml:space="preserve">
|
||||||
|
<value>Salva</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delete it for good" xml:space="preserve">
|
||||||
|
<value>Eliminala per sempre</value>
|
||||||
|
</data>
|
||||||
|
<data name="Members" xml:space="preserve">
|
||||||
|
<value>Membri</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody is in this list yet." xml:space="preserve">
|
||||||
|
<value>Nessuno è ancora in questa lista.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove from the list" xml:space="preserve">
|
||||||
|
<value>Togli dalla lista</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove" xml:space="preserve">
|
||||||
|
<value>Togli</value>
|
||||||
|
</data>
|
||||||
|
<data name="Find accounts you follow" xml:space="preserve">
|
||||||
|
<value>Trova account che segui</value>
|
||||||
|
</data>
|
||||||
|
<data name="Only accounts you follow can join a list, and none of them matches." xml:space="preserve">
|
||||||
|
<value>Solo gli account che segui possono entrare in una lista, e nessuno di loro corrisponde.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add" xml:space="preserve">
|
||||||
|
<value>Aggiungi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Replies to anyone you follow" xml:space="preserve">
|
||||||
|
<value>Risposte a chiunque tu segua</value>
|
||||||
|
</data>
|
||||||
|
<data name="No replies" xml:space="preserve">
|
||||||
|
<value>Nessuna risposta</value>
|
||||||
|
</data>
|
||||||
|
<data name="Replies to the list's members" xml:space="preserve">
|
||||||
|
<value>Risposte ai membri della lista</value>
|
||||||
|
</data>
|
||||||
|
<data name="Keep its members out of the home feed" xml:space="preserve">
|
||||||
|
<value>Tieni i suoi membri fuori dal feed principale</value>
|
||||||
|
</data>
|
||||||
|
<data name="List {0} made." xml:space="preserve">
|
||||||
|
<value>Lista {0} creata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="List {0} saved." xml:space="preserve">
|
||||||
|
<value>Lista {0} salvata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="List {0} deleted." xml:space="preserve">
|
||||||
|
<value>Lista {0} eliminata.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} added to {1}." xml:space="preserve">
|
||||||
|
<value>{0} aggiunto a {1}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} removed from {1}." xml:space="preserve">
|
||||||
|
<value>{0} tolto da {1}.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -908,4 +908,85 @@
|
|||||||
<data name="React" xml:space="preserve">
|
<data name="React" xml:space="preserve">
|
||||||
<value>React</value>
|
<value>React</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Lists" xml:space="preserve">
|
||||||
|
<value>Lists</value>
|
||||||
|
</data>
|
||||||
|
<data name="Manage lists" xml:space="preserve">
|
||||||
|
<value>Manage lists</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona has no list yet." xml:space="preserve">
|
||||||
|
<value>This persona has no list yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="A new list's title" xml:space="preserve">
|
||||||
|
<value>A new list's title</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make a list" xml:space="preserve">
|
||||||
|
<value>Make a list</value>
|
||||||
|
</data>
|
||||||
|
<data name="Its members stay out of the home feed." xml:space="preserve">
|
||||||
|
<value>Its members stay out of the home feed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Exclusive" xml:space="preserve">
|
||||||
|
<value>Exclusive</value>
|
||||||
|
</data>
|
||||||
|
<data name="Read the list" xml:space="preserve">
|
||||||
|
<value>Read the list</value>
|
||||||
|
</data>
|
||||||
|
<data name="Edit" xml:space="preserve">
|
||||||
|
<value>Edit</value>
|
||||||
|
</data>
|
||||||
|
<data name="Save" xml:space="preserve">
|
||||||
|
<value>Save</value>
|
||||||
|
</data>
|
||||||
|
<data name="Delete it for good" xml:space="preserve">
|
||||||
|
<value>Delete it for good</value>
|
||||||
|
</data>
|
||||||
|
<data name="Members" xml:space="preserve">
|
||||||
|
<value>Members</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody is in this list yet." xml:space="preserve">
|
||||||
|
<value>Nobody is in this list yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove from the list" xml:space="preserve">
|
||||||
|
<value>Remove from the list</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove" xml:space="preserve">
|
||||||
|
<value>Remove</value>
|
||||||
|
</data>
|
||||||
|
<data name="Find accounts you follow" xml:space="preserve">
|
||||||
|
<value>Find accounts you follow</value>
|
||||||
|
</data>
|
||||||
|
<data name="Only accounts you follow can join a list, and none of them matches." xml:space="preserve">
|
||||||
|
<value>Only accounts you follow can join a list, and none of them matches.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Add" xml:space="preserve">
|
||||||
|
<value>Add</value>
|
||||||
|
</data>
|
||||||
|
<data name="Replies to anyone you follow" xml:space="preserve">
|
||||||
|
<value>Replies to anyone you follow</value>
|
||||||
|
</data>
|
||||||
|
<data name="No replies" xml:space="preserve">
|
||||||
|
<value>No replies</value>
|
||||||
|
</data>
|
||||||
|
<data name="Replies to the list's members" xml:space="preserve">
|
||||||
|
<value>Replies to the list's members</value>
|
||||||
|
</data>
|
||||||
|
<data name="Keep its members out of the home feed" xml:space="preserve">
|
||||||
|
<value>Keep its members out of the home feed</value>
|
||||||
|
</data>
|
||||||
|
<data name="List {0} made." xml:space="preserve">
|
||||||
|
<value>List {0} made.</value>
|
||||||
|
</data>
|
||||||
|
<data name="List {0} saved." xml:space="preserve">
|
||||||
|
<value>List {0} saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="List {0} deleted." xml:space="preserve">
|
||||||
|
<value>List {0} deleted.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} added to {1}." xml:space="preserve">
|
||||||
|
<value>{0} added to {1}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} removed from {1}." xml:space="preserve">
|
||||||
|
<value>{0} removed from {1}.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class ListService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
/// <summary>The most members PrivaPub keeps in one list, and so the most read back.</summary>
|
||||||
|
public const int MaxMembers = 500;
|
||||||
|
const int MembersPerPage = 80;
|
||||||
|
|
||||||
|
public async Task<List<MastodonList>> All(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Lists, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<MastodonList>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(All), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the list when it has no id yet, or saves its title, replies policy and exclusivity; Data is the list.</summary>
|
||||||
|
public async Task<WebResult> 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<MastodonList>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every member of the list, page by page, up to MaxMembers.</summary>
|
||||||
|
public async Task<List<Account>> Members(string listId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var members = new List<Account>();
|
||||||
|
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<List<Account>>(cancellationToken) ?? [];
|
||||||
|
members.AddRange(page);
|
||||||
|
maxId = response.PageCursors().MaxId;
|
||||||
|
if (page.Count < MembersPerPage || maxId is null)
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds (or removes) an account the persona follows to (from) the list.</summary>
|
||||||
|
public async Task<WebResult> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The persona's lists the account is in.</summary>
|
||||||
|
public async Task<List<MastodonList>> 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<List<MastodonList>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
await LogFailure(response, nameof(Of), cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accounts the persona follows whose name, display name or server starts with the query (PrivaPub's
|
||||||
|
/// following=true; nothing is resolved).</summary>
|
||||||
|
public async Task<List<Account>> 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<List<Account>>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,9 @@ using PrivaPub.ClientModels.Post;
|
|||||||
|
|
||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>What a feed shows: its kind, and the group, hashtag or position some kinds need.</summary>
|
/// <summary>What a feed shows: its kind, and the group, hashtag, list or position some kinds need.</summary>
|
||||||
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
|
/// 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.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
|
||||||
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
|
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
|
||||||
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
|
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 =>
|
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
|
||||||
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
|
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),
|
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
<i class="ion-md-search"></i>
|
<i class="ion-md-search"></i>
|
||||||
<span>@Localizer["Search"]</span>
|
<span>@Localizer["Search"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="lists">
|
||||||
|
<i class="ion-md-list"></i>
|
||||||
|
<span>@Localizer["Lists"]</span>
|
||||||
|
</NavLink>
|
||||||
@if (CascadingState.PersonaId is { } personaId)
|
@if (CascadingState.PersonaId is { } personaId)
|
||||||
{
|
{
|
||||||
<NavLink ActiveClass="btn-active" class="btn justify-start" href="@($"account/{Uri.EscapeDataString(personaId)}")">
|
<NavLink ActiveClass="btn-active" class="btn justify-start" href="@($"account/{Uri.EscapeDataString(personaId)}")">
|
||||||
|
|||||||
@@ -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<JsonNode> 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<JsonNode> Followed(Pasture.Persona persona) =>
|
||||||
|
(await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray()
|
||||||
|
.FirstOrDefault(a => a!["statuses_count"]!.GetValue<int>() > 0 && !a["acct"]!.GetValue<string>().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<string>();
|
||||||
|
var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue<string>();
|
||||||
|
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<bool> InList() =>
|
||||||
|
(await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue<string>() == 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<string>();
|
||||||
|
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<string>().StartsWith("e2e ")))
|
||||||
|
await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue<string>()}", 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<string>() == 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<string>());
|
||||||
|
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<string>()));
|
||||||
|
|
||||||
|
// 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<string>()).ToHashSet();
|
||||||
|
var shown = await page.Locator("article[data-uri]").EvaluateAllAsync<string[]>("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<bool>() == 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user