Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute, unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists, filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a scheduled post confirm; a phone flow deletes a post after cancelling once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
373 lines
15 KiB
Plaintext
373 lines
15 KiB
Plaintext
@page "/groups"
|
|
@attribute [Authorize]
|
|
@inherits LocalizableComponentBase
|
|
<Title>@Localizer["Groups"]</Title>
|
|
|
|
@* The current persona's communities and circles (/clientapi/group): made, joined by invitation, and left; for the ones
|
|
it runs, edited, their invitation shown, the requests to join answered and members taken out. A circle's members are
|
|
never shown to anyone but its owner and moderators, here as on every server. *@
|
|
<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-people text-lg"></i>
|
|
<h1 class="font-bold truncate">@Localizer["Groups"]</h1>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-new>
|
|
<DataAnnotationsValidator />
|
|
<b class="text-sm">@Localizer["A new group"]</b>
|
|
<div class="flex flex-wrap gap-2">
|
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.UserName" Immediate="true" autocomplete="off" maxlength="32"
|
|
placeholder="@Localizer["user name (a-z, 0-9, _)"]" data-group-username>
|
|
<Prefix>@@</Prefix>
|
|
</DInput>
|
|
<DInput TValue="string" class="flex-1 min-w-40" @bind-Value="draft.Name" Immediate="true" autocomplete="off" maxlength="64"
|
|
placeholder="@Localizer["Name"]" data-group-name />
|
|
</div>
|
|
<DTextarea @bind-Value="draft.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" placeholder="@Localizer["Description"]" />
|
|
<DToggle Size="DSize.Sm" @bind-Value="draft.IsCommunity" LabelText="@Localizer["A community, open to the fediverse (otherwise a private circle)"]" data-group-community />
|
|
@if (draft.IsCommunity)
|
|
{
|
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="draft.PostingPolicy" aria-label="@Localizer["Who may post"]">
|
|
<Prefix><i class="ion-md-create"></i></Prefix>
|
|
<ChildContent>
|
|
@foreach (var policy in PostingPolicies)
|
|
{
|
|
<option value="@policy">@PolicyName(policy)</option>
|
|
}
|
|
</ChildContent>
|
|
</DSelect>
|
|
<DToggle Size="DSize.Sm" @bind-Value="draft.IsDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
|
|
}
|
|
<DToggle Size="DSize.Sm" @bind-Value="draft.ManuallyApprovesMembers" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
|
|
<DInput TValue="string" type="password" @bind-Value="draft.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
|
|
placeholder="@Localizer["Invitation password (optional)"]" />
|
|
<ErrorViewer />
|
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-create>
|
|
<i class="ion-md-add"></i><span>@Localizer["Make the group"]</span>
|
|
</DButton>
|
|
</EditForm>
|
|
|
|
<EditForm Model="joining" OnValidSubmit="Join" class="flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" data-group-join>
|
|
<b class="text-sm">@Localizer["Join by invitation"]</b>
|
|
<DInput TValue="string" @bind-Value="joining.InvitationCode" Immediate="true" autocomplete="off" maxlength="64"
|
|
placeholder="@Localizer["Invitation code"]" data-group-code>
|
|
<Prefix><i class="ion-md-key"></i></Prefix>
|
|
</DInput>
|
|
<DInput TValue="string" type="password" @bind-Value="joining.InvitationPassword" Immediate="true" autocomplete="current-password" maxlength="128"
|
|
placeholder="@Localizer["Invitation password, if it has one"]" />
|
|
<DButton Type="submit" Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(joining.InvitationCode))" data-group-join-submit>
|
|
<i class="ion-md-log-in"></i><span>@Localizer["Join"]</span>
|
|
</DButton>
|
|
</EditForm>
|
|
</div>
|
|
|
|
@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 (groups.Count == 0)
|
|
{
|
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona is in no group yet."]</p>
|
|
}
|
|
|
|
<div class="flex flex-col gap-3">
|
|
@foreach (var group in groups)
|
|
{
|
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-group-id="@group.Id">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<i class="@(group.IsCommunity ? "ion-md-globe" : "ion-md-lock") text-lg"></i>
|
|
<span class="flex flex-col min-w-0 flex-1">
|
|
<b class="truncate">@(group.Name ?? group.UserName)</b>
|
|
<span class="text-xs opacity-70 truncate">@("@" + group.Handle)</span>
|
|
</span>
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@(group.IsCommunity ? Localizer["Community"] : Localizer["Circle"])</DBadge>
|
|
<span class="text-sm tabular-nums opacity-70" title="@Localizer["Members"]"><i class="ion-md-person"></i> @group.MembersCount</span>
|
|
@if (group.IsOwner)
|
|
{
|
|
<DButton Size="DSize.Sm" Active="@(open?.Id == group.Id)" OnClick="@(() => Toggle(group))" data-group-manage>
|
|
<i class="ion-md-settings"></i><span class="hidden sm:inline">@Localizer["Manage"]</span>
|
|
</DButton>
|
|
}
|
|
else
|
|
{
|
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Leave(group))" data-group-leave>
|
|
<i class="ion-md-exit"></i><span>@Localizer["Leave"]</span>
|
|
</DButton>
|
|
}
|
|
</div>
|
|
|
|
@if (open?.Id == group.Id)
|
|
{
|
|
@if (group.InvitationCode is { Length: > 0 } invitation)
|
|
{
|
|
<div class="flex flex-col gap-1 text-sm">
|
|
<span>@Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null)</span>
|
|
<input class="input input-sm w-full font-mono text-xs" value="@invitation" readonly autocomplete="off" onfocus="this.select()"
|
|
aria-label="@Localizer["Invitation code"]" data-group-invitation />
|
|
</div>
|
|
}
|
|
|
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-2">
|
|
<DInput TValue="string" @bind-Value="edit.Name" Immediate="true" autocomplete="off" maxlength="64" aria-label="@Localizer["Name"]" />
|
|
<DTextarea @bind-Value="edit.Description" Immediate="true" rows="2" autocomplete="off" maxlength="2000" aria-label="@Localizer["Description"]" />
|
|
@if (group.IsCommunity)
|
|
{
|
|
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="edit.PostingPolicy" aria-label="@Localizer["Who may post"]">
|
|
<Prefix><i class="ion-md-create"></i></Prefix>
|
|
<ChildContent>
|
|
@foreach (var policy in PostingPolicies)
|
|
{
|
|
<option value="@policy">@PolicyName(policy)</option>
|
|
}
|
|
</ChildContent>
|
|
</DSelect>
|
|
<DToggle Size="DSize.Sm" @bind-Value="editDiscoverable" LabelText="@Localizer["Listed and found in searches"]" />
|
|
}
|
|
<DToggle Size="DSize.Sm" @bind-Value="editApproves" LabelText="@Localizer["Approve members from elsewhere by hand"]" />
|
|
<DInput TValue="string" type="password" @bind-Value="edit.InvitationPassword" Immediate="true" autocomplete="new-password" maxlength="128"
|
|
placeholder="@Localizer["A new invitation password"]" />
|
|
@if (group.IsPasswordRequired)
|
|
{
|
|
<DToggle Size="DSize.Sm" @bind-Value="edit.RemoveInvitationPassword" LabelText="@Localizer["Remove the invitation password"]" />
|
|
}
|
|
<DToggle Size="DSize.Sm" @bind-Value="edit.RegenerateInvitationCode" LabelText="@Localizer["A new invitation code (the old one stops working)"]" />
|
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-group-save>
|
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
|
</DButton>
|
|
</EditForm>
|
|
|
|
<h2 class="font-semibold text-sm">@Localizer["Members"] <span class="opacity-60 tabular-nums">@members.Count(m => !m.IsPending)</span></h2>
|
|
<div class="flex flex-col gap-2" data-group-members>
|
|
@foreach (var member in members.OrderBy(m => m.IsPending ? 0 : 1).ThenBy(m => m.Since))
|
|
{
|
|
<div class="flex flex-wrap items-center gap-3 min-w-0" data-member="@member.ActorUri" data-pending="@(member.IsPending ? "true" : "false")">
|
|
@* alt="": the name is next to it. *@
|
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(member.PictureUrl ?? "imgs/icon-192.png")" />
|
|
<span class="flex flex-col min-w-0 flex-1">
|
|
<b class="truncate text-sm">@(member.Name ?? member.Handle)</b>
|
|
<span class="text-xs opacity-70 truncate">@("@" + member.Handle)</span>
|
|
</span>
|
|
@if (member.IsPending)
|
|
{
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["asks to join"]</DBadge>
|
|
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, true))" data-member-approve>
|
|
<i class="ion-md-checkmark"></i><span>@Localizer["Let in"]</span>
|
|
</DButton>
|
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Answer(member, false))" data-member-reject>
|
|
<i class="ion-md-close"></i><span>@Localizer["Decline"]</span>
|
|
</DButton>
|
|
}
|
|
else if (member.Role == "owner")
|
|
{
|
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Primary">@Localizer["owner"]</DBadge>
|
|
}
|
|
else
|
|
{
|
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => Remove(member))" title="@Localizer["Take out of the group"]" data-member-remove>
|
|
<i class="ion-md-remove-circle"></i><span class="hidden sm:inline">@Localizer["Take out"]</span>
|
|
</DButton>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</article>
|
|
}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
@code {
|
|
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
|
|
|
|
[Inject] GroupService GroupService { get; set; }
|
|
[Inject] ConfirmService Confirm { get; set; }
|
|
|
|
List<ViewGroup> groups = [];
|
|
List<ViewGroupMember> members = [];
|
|
InsertGroupForm draft = new() { PostingPolicy = "followers" };
|
|
JoinGroupForm joining = new();
|
|
UpdateGroupForm edit = new();
|
|
bool editDiscoverable;
|
|
bool editApproves;
|
|
ViewGroup open;
|
|
string loadedPersonaId;
|
|
bool isSaving;
|
|
|
|
string PolicyName(string policy) => policy switch
|
|
{
|
|
"anyone" => Localizer["Anyone may post"],
|
|
"moderators" => Localizer["Only its moderators post"],
|
|
_ => Localizer["Its followers post"]
|
|
};
|
|
|
|
// The persona changed (NavMenu's switcher): its groups 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 = [];
|
|
// (the forms are the persona's: validated as they are filled, so they name it from the start)
|
|
draft.AvatarId = CascadingState.PersonaId;
|
|
joining.AvatarId = CascadingState.PersonaId;
|
|
groups = await GroupService.List(CascadingState.PersonaId);
|
|
IsLoading = false;
|
|
}
|
|
|
|
async Task Create()
|
|
{
|
|
if (!draft.IsCommunity)
|
|
draft.IsDiscoverable = false;
|
|
isSaving = true;
|
|
var result = await GroupService.Insert(draft);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
var group = (ViewGroup)result.Data;
|
|
draft = new() { AvatarId = CascadingState.PersonaId, PostingPolicy = "followers" };
|
|
Toast.AddResponseSuccess(Localizer["{0} made.", group.Name ?? group.UserName]);
|
|
await LoadData();
|
|
if (groups.FirstOrDefault(g => g.Id == group.Id) is { } made)
|
|
await Toggle(made);
|
|
}
|
|
|
|
async Task Join()
|
|
{
|
|
isSaving = true;
|
|
var result = await GroupService.Join(joining);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
var group = (ViewGroup)result.Data;
|
|
joining = new() { AvatarId = CascadingState.PersonaId };
|
|
Toast.AddResponseSuccess(Localizer["You joined {0}.", group.Name ?? group.UserName]);
|
|
await LoadData();
|
|
}
|
|
|
|
async Task Leave(ViewGroup group)
|
|
{
|
|
if (!await Confirm.Ask(Localizer["Leave {0}?", group.Name ?? group.UserName], null, Localizer["Leave"], true))
|
|
return;
|
|
isSaving = true;
|
|
var result = await GroupService.Leave(CascadingState.PersonaId, group.Id);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
groups.Remove(group);
|
|
Toast.AddResponseSuccess(Localizer["You left {0}.", group.Name ?? group.UserName]);
|
|
}
|
|
|
|
async Task Toggle(ViewGroup group)
|
|
{
|
|
if (open?.Id == group.Id)
|
|
{
|
|
open = null;
|
|
members = [];
|
|
return;
|
|
}
|
|
|
|
open = group;
|
|
edit = new UpdateGroupForm { AvatarId = CascadingState.PersonaId, GroupId = group.Id, Name = group.Name, Description = group.Description, PostingPolicy = group.PostingPolicy };
|
|
editDiscoverable = group.IsDiscoverable;
|
|
editApproves = group.ManuallyApprovesMembers;
|
|
members = await GroupService.Members(CascadingState.PersonaId, group.Id);
|
|
}
|
|
|
|
async Task Save()
|
|
{
|
|
edit.IsDiscoverable = open.IsCommunity ? editDiscoverable : null;
|
|
edit.ManuallyApprovesMembers = editApproves;
|
|
if (string.IsNullOrEmpty(edit.InvitationPassword))
|
|
edit.InvitationPassword = null;
|
|
isSaving = true;
|
|
var result = await GroupService.Update(edit);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
var updated = (ViewGroup)result.Data;
|
|
var index = groups.FindIndex(g => g.Id == updated.Id);
|
|
if (index >= 0)
|
|
groups[index] = updated;
|
|
open = updated;
|
|
edit.InvitationPassword = null;
|
|
edit.RemoveInvitationPassword = false;
|
|
edit.RegenerateInvitationCode = false;
|
|
Toast.AddResponseSuccess(Localizer["Saved."]);
|
|
}
|
|
|
|
async Task Answer(ViewGroupMember member, bool approve)
|
|
{
|
|
isSaving = true;
|
|
var result = approve
|
|
? await GroupService.Approve(CascadingState.PersonaId, open.Id, member.ActorUri)
|
|
: await GroupService.Reject(CascadingState.PersonaId, open.Id, member.ActorUri);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
members = await GroupService.Members(CascadingState.PersonaId, open.Id);
|
|
}
|
|
|
|
async Task Remove(ViewGroupMember member)
|
|
{
|
|
isSaving = true;
|
|
var result = await GroupService.Remove(CascadingState.PersonaId, open.Id, member.ActorUri);
|
|
isSaving = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
members.Remove(member);
|
|
Toast.AddResponseSuccess(Localizer["{0} is out of the group.", member.Name ?? member.Handle]);
|
|
}
|
|
}
|