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
+7
-2
@@ -16,7 +16,7 @@
|
||||
</aside>
|
||||
|
||||
<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" />
|
||||
|
||||
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
||||
@@ -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<FeedPost> posts = [];
|
||||
List<FeedPost> pending = [];
|
||||
List<ViewGroup> groups = [];
|
||||
List<MastodonList> 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);
|
||||
|
||||
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
</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)"
|
||||
OnClick="@(() => Do(relationship?.Muting == true ? AccountAction.Unmute : AccountAction.Mute))">
|
||||
<i class="@(relationship?.Muting == true ? "ion-md-eye" : "ion-md-eye-off")"></i>
|
||||
@@ -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<MastodonList> allLists = [];
|
||||
HashSet<string> 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();
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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],
|
||||
|
||||
Reference in new issue
Block a user