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:
thepraandClaude Opus 5.5 committed 2026-10-05 18:24:39 +02:00
1 parent 2024798732
commit 0bbab720a5
9 files changed
+705 -1

No files matched your search

+4
View File
@@ -232,6 +232,10 @@ supplies:
- **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.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
left; the ones it runs edited, their invitation shown, the requests to join let in or declined, members taken out.
Only a group's owner and moderators see who is in it.
- **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.
+6
View File
@@ -38,6 +38,12 @@ namespace decePubClient;
[JsonSerializable(typeof(InsertAvatarForm))]
[JsonSerializable(typeof(ViewGroup))]
[JsonSerializable(typeof(List<ViewGroup>))]
[JsonSerializable(typeof(ViewGroupMember))]
[JsonSerializable(typeof(List<ViewGroupMember>))]
[JsonSerializable(typeof(InsertGroupForm))]
[JsonSerializable(typeof(UpdateGroupForm))]
[JsonSerializable(typeof(JoinGroupForm))]
[JsonSerializable(typeof(GroupMembershipForm))]
[JsonSerializable(typeof(ViewPost))]
[JsonSerializable(typeof(List<ViewPost>))]
[JsonSerializable(typeof(InsertPostForm))]
+8
View File
@@ -20,6 +20,14 @@ public static class APIs
public const string GET_Personas = "clientapi/avatar/private/list";
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
public const string GET_Groups = "clientapi/group/list";
public const string GET_GroupMembers = "clientapi/group/members";
public const string POST_InsertGroup = "clientapi/group/insert";
public const string POST_UpdateGroup = "clientapi/group/update";
public const string POST_JoinGroup = "clientapi/group/join";
public const string POST_LeaveGroup = "clientapi/group/leave";
public const string POST_ApproveGroupMember = "clientapi/group/approve";
public const string POST_RejectGroupMember = "clientapi/group/reject";
public const string POST_RemoveGroupMember = "clientapi/group/remove";
public const string GET_Posts = "clientapi/post/list";
public const string GET_Nearby = "clientapi/post/nearby";
public const string POST_InsertPost = "clientapi/post/insert";
+369
View File
@@ -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]);
}
}
+96
View File
@@ -1595,4 +1595,100 @@
<data name="Tentative" xml:space="preserve">
<value>Da confermare</value>
</data>
<data name="{0} is out of the group." xml:space="preserve">
<value>{0} non è più nel gruppo.</value>
</data>
<data name="{0} made." xml:space="preserve">
<value>{0} creato.</value>
</data>
<data name="A community, open to the fediverse (otherwise a private circle)" xml:space="preserve">
<value>Una comunità, aperta al fediverso (altrimenti una cerchia privata)</value>
</data>
<data name="A new group" xml:space="preserve">
<value>Un nuovo gruppo</value>
</data>
<data name="A new invitation code (the old one stops working)" xml:space="preserve">
<value>Un nuovo codice d'invito (quello vecchio smette di funzionare)</value>
</data>
<data name="A new invitation password" xml:space="preserve">
<value>Una nuova password d'invito</value>
</data>
<data name="Anyone may post" xml:space="preserve">
<value>Chiunque può pubblicare</value>
</data>
<data name="Approve members from elsewhere by hand" xml:space="preserve">
<value>Approva a mano i membri di altri server</value>
</data>
<data name="asks to join" xml:space="preserve">
<value>chiede di entrare</value>
</data>
<data name="Circle" xml:space="preserve">
<value>Cerchia</value>
</data>
<data name="Community" xml:space="preserve">
<value>Comunità</value>
</data>
<data name="Decline" xml:space="preserve">
<value>Rifiuta</value>
</data>
<data name="Description" xml:space="preserve">
<value>Descrizione</value>
</data>
<data name="Invitation code" xml:space="preserve">
<value>Codice d'invito</value>
</data>
<data name="Invitation password, if it has one" xml:space="preserve">
<value>Password d'invito, se ne ha una</value>
</data>
<data name="Invitation password (optional)" xml:space="preserve">
<value>Password d'invito (facoltativa)</value>
</data>
<data name="it asks for a password" xml:space="preserve">
<value>chiede una password</value>
</data>
<data name="Its followers post" xml:space="preserve">
<value>Pubblicano i suoi follower</value>
</data>
<data name="Join by invitation" xml:space="preserve">
<value>Entra con un invito</value>
</data>
<data name="Let in" xml:space="preserve">
<value>Fai entrare</value>
</data>
<data name="Listed and found in searches" xml:space="preserve">
<value>In elenco e trovabile nelle ricerche</value>
</data>
<data name="Make the group" xml:space="preserve">
<value>Crea il gruppo</value>
</data>
<data name="Manage" xml:space="preserve">
<value>Gestisci</value>
</data>
<data name="Only its moderators post" xml:space="preserve">
<value>Pubblicano solo i suoi moderatori</value>
</data>
<data name="owner" xml:space="preserve">
<value>proprietario</value>
</data>
<data name="Remove the invitation password" xml:space="preserve">
<value>Togli la password d'invito</value>
</data>
<data name="Take out" xml:space="preserve">
<value>Togli</value>
</data>
<data name="Take out of the group" xml:space="preserve">
<value>Togli dal gruppo</value>
</data>
<data name="This persona is in no group yet." xml:space="preserve">
<value>Questa persona non è ancora in nessun gruppo.</value>
</data>
<data name="Who may post" xml:space="preserve">
<value>Chi può pubblicare</value>
</data>
<data name="You joined {0}." xml:space="preserve">
<value>Sei entrato in {0}.</value>
</data>
<data name="You left {0}." xml:space="preserve">
<value>Hai lasciato {0}.</value>
</data>
</root>
+96
View File
@@ -1595,4 +1595,100 @@
<data name="Tentative" xml:space="preserve">
<value>Tentative</value>
</data>
<data name="{0} is out of the group." xml:space="preserve">
<value>{0} is out of the group.</value>
</data>
<data name="{0} made." xml:space="preserve">
<value>{0} made.</value>
</data>
<data name="A community, open to the fediverse (otherwise a private circle)" xml:space="preserve">
<value>A community, open to the fediverse (otherwise a private circle)</value>
</data>
<data name="A new group" xml:space="preserve">
<value>A new group</value>
</data>
<data name="A new invitation code (the old one stops working)" xml:space="preserve">
<value>A new invitation code (the old one stops working)</value>
</data>
<data name="A new invitation password" xml:space="preserve">
<value>A new invitation password</value>
</data>
<data name="Anyone may post" xml:space="preserve">
<value>Anyone may post</value>
</data>
<data name="Approve members from elsewhere by hand" xml:space="preserve">
<value>Approve members from elsewhere by hand</value>
</data>
<data name="asks to join" xml:space="preserve">
<value>asks to join</value>
</data>
<data name="Circle" xml:space="preserve">
<value>Circle</value>
</data>
<data name="Community" xml:space="preserve">
<value>Community</value>
</data>
<data name="Decline" xml:space="preserve">
<value>Decline</value>
</data>
<data name="Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="Invitation code" xml:space="preserve">
<value>Invitation code</value>
</data>
<data name="Invitation password, if it has one" xml:space="preserve">
<value>Invitation password, if it has one</value>
</data>
<data name="Invitation password (optional)" xml:space="preserve">
<value>Invitation password (optional)</value>
</data>
<data name="it asks for a password" xml:space="preserve">
<value>it asks for a password</value>
</data>
<data name="Its followers post" xml:space="preserve">
<value>Its followers post</value>
</data>
<data name="Join by invitation" xml:space="preserve">
<value>Join by invitation</value>
</data>
<data name="Let in" xml:space="preserve">
<value>Let in</value>
</data>
<data name="Listed and found in searches" xml:space="preserve">
<value>Listed and found in searches</value>
</data>
<data name="Make the group" xml:space="preserve">
<value>Make the group</value>
</data>
<data name="Manage" xml:space="preserve">
<value>Manage</value>
</data>
<data name="Only its moderators post" xml:space="preserve">
<value>Only its moderators post</value>
</data>
<data name="owner" xml:space="preserve">
<value>owner</value>
</data>
<data name="Remove the invitation password" xml:space="preserve">
<value>Remove the invitation password</value>
</data>
<data name="Take out" xml:space="preserve">
<value>Take out</value>
</data>
<data name="Take out of the group" xml:space="preserve">
<value>Take out of the group</value>
</data>
<data name="This persona is in no group yet." xml:space="preserve">
<value>This persona is in no group yet.</value>
</data>
<data name="Who may post" xml:space="preserve">
<value>Who may post</value>
</data>
<data name="You joined {0}." xml:space="preserve">
<value>You joined {0}.</value>
</data>
<data name="You left {0}." xml:space="preserve">
<value>You left {0}.</value>
</data>
</root>
+64 -1
View File
@@ -5,11 +5,13 @@ using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Group;
namespace decePubClient.Services
{
/// <summary>The communities and circles a persona belongs to (/clientapi/group/list).</summary>
/// <summary>The persona's communities and circles (/clientapi/group): made, joined by invitation, edited and left;
/// for their owners and moderators, who is in them and who asks to join.</summary>
public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The persona's groups, communities first; empty on failure.</summary>
@@ -31,5 +33,66 @@ namespace decePubClient.Services
var groups = await response.DefaultReadFromJsonAsync<List<ViewGroup>>(cancellationToken) ?? [];
return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)];
}
/// <summary>Makes a community or a circle owned by the persona; Data is the ViewGroup.</summary>
public Task<WebResult> Insert(InsertGroupForm form, CancellationToken cancellationToken = default) =>
GroupResult(APIs.ClientApi.POST_InsertGroup, form, cancellationToken);
/// <summary>Changes a group the persona runs; Data is the ViewGroup.</summary>
public Task<WebResult> Update(UpdateGroupForm form, CancellationToken cancellationToken = default) =>
GroupResult(APIs.ClientApi.POST_UpdateGroup, form, cancellationToken);
/// <summary>Joins a group by its invitation (and its password, when it has one); Data is the ViewGroup.</summary>
public Task<WebResult> Join(JoinGroupForm form, CancellationToken cancellationToken = default) =>
GroupResult(APIs.ClientApi.POST_JoinGroup, form, cancellationToken);
public Task<WebResult> Leave(string personaId, string groupId, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_LeaveGroup, personaId, groupId, null, cancellationToken);
/// <summary>Lets in someone who asked to join a group the persona runs.</summary>
public Task<WebResult> Approve(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_ApproveGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>Declines a request to join; the asker's server is told.</summary>
public Task<WebResult> Reject(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_RejectGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>Takes a member out of a group the persona runs.</summary>
public Task<WebResult> Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) =>
Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken);
/// <summary>Who is in a group the persona runs, and who asks to join; empty on failure.</summary>
public async Task<List<ViewGroupMember>> Members(string personaId, string groupId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_GroupMembers,
[$"avatarId={Uri.EscapeDataString(personaId)}", $"groupId={Uri.EscapeDataString(groupId)}"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(GroupService)}.{nameof(Members)}", cancellationToken);
return [];
}
return await response.DefaultReadFromJsonAsync<List<ViewGroupMember>>(cancellationToken) ?? [];
}
async Task<WebResult> GroupResult(string route, object form, CancellationToken cancellationToken)
{
using var response = await httpService.Post(route, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewGroup>(cancellationToken) };
}
async Task<WebResult> Membership(string route, string personaId, string groupId, string actorUri, CancellationToken cancellationToken)
{
using var response = await httpService.Post(route, new GroupMembershipForm { AvatarId = personaId, GroupId = groupId, MemberActorURI = actorUri },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
}
}
+4
View File
@@ -55,6 +55,10 @@
<i class="ion-md-list"></i>
<span>@Localizer["Lists"]</span>
</NavLink>
<NavLink ActiveClass="btn-active" class="btn justify-start" href="groups">
<i class="ion-md-people"></i>
<span>@Localizer["Groups"]</span>
</NavLink>
@if (CascadingState.PersonaId is { } personaId)
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="@($"account/{Uri.EscapeDataString(personaId)}")">
+58
View File
@@ -0,0 +1,58 @@
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// A circle made in decePub, joined by another persona with its invitation code, and that member taken out by the
// owner, who alone sees who is in it
public sealed class GroupTests(Browser browser)
{
[Fact]
public async Task A_circle_is_made_joined_by_invitation_and_its_member_taken_out()
{
var personas = Pasture.Personas();
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
var (owner, joiner) = (personas[0], personas[1]);
var userName = $"e2ecircle{DateTime.UtcNow:MMddHHmmss}";
await using var ownerSession = await SignIn.As(browser, owner);
await using var joinerSession = await SignIn.As(browser, joiner);
var page = ownerSession.Page;
await Results.Cell("client.action.group", "privapub", owner.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/groups");
var form = page.Locator("[data-group-new]");
await form.Locator("input[data-group-username]").FillAsync(userName);
await form.Locator("input[data-group-name]").FillAsync(userName);
await form.Locator("[data-group-create]").ClickAsync();
var invitation = page.Locator("[data-group-invitation]");
await invitation.WaitForAsync(new() { Timeout = 30000 });
var code = await invitation.InputValueAsync();
Assert.Equal(64, code.Length);
var other = joinerSession.Page;
await other.GotoAsync($"{Pasture.Site}/groups");
var join = other.Locator("[data-group-join]");
await join.Locator("input[data-group-code]").FillAsync(code);
await join.Locator("[data-group-join-submit]").ClickAsync();
await other.Locator("article[data-group-id] [data-group-leave]").First.WaitForAsync(new() { Timeout = 30000 });
// closed and opened again, so its members are read after the join
var card = page.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName });
await card.Locator("[data-group-manage]").ClickAsync();
await card.Locator("[data-group-manage]").ClickAsync();
var member = card.Locator($"[data-member='{joiner.ActorUri}']");
await member.WaitForAsync(new() { Timeout = 30000 });
Assert.Equal("false", await member.GetAttributeAsync("data-pending"));
await member.Locator("[data-member-remove]").ClickAsync();
await Assertions.Expect(member).ToHaveCountAsync(0, new() { Timeout = 15000 });
await other.ReloadAsync();
await other.Locator("[data-group-new]").WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(other.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName })).ToHaveCountAsync(0, new() { Timeout = 15000 });
await ownerSession.AssertGuards();
await joinerSession.AssertGuards();
}, page);
}
}
}