Groups: make, join, run and leave communities and circles
The Groups page (/groups, in the menu) is where a persona makes a community or a circle (with its invitation code and an optional password), joins one by invitation and leaves one. For the groups it runs it edits them, shows the invitation, lets in or declines the requests to join, and takes members out, through PrivaPub's new /clientapi/group/members, reject and remove. The e2e makes a circle, joins it as another persona with the code, and has the owner take that member out. 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
2024798732
commit
0bbab720a5
9 files changed
+705
-1
No files matched your search
@@ -0,0 +1,369 @@
|
||||
@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; }
|
||||
|
||||
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)
|
||||
{
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user