Files
decePubClient/Pages/Groups.razor
T
thepraandClaude Opus 5.5 1c9773bab0 What can't be undone asks first
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
2026-10-06 23:28:22 +02:00

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]);
}
}