From 0bbab720a51f703207aff705bd640929eb73792e Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 18:24:39 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 4 + ClientJsonContext.cs | 6 + Models/APIs.cs | 8 + Pages/Groups.razor | 369 ++++++++++++++++++++++++++++++++ Resources/AllStrings.it.resx | 96 +++++++++ Resources/AllStrings.resx | 96 +++++++++ Services/GroupService.cs | 65 +++++- Shared/NavMenu.razor | 4 + tests/e2e/Actions/GroupTests.cs | 58 +++++ 9 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 Pages/Groups.razor create mode 100644 tests/e2e/Actions/GroupTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 5ca6cbc..22ef093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index 123790f..0979300 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -38,6 +38,12 @@ namespace decePubClient; [JsonSerializable(typeof(InsertAvatarForm))] [JsonSerializable(typeof(ViewGroup))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ViewGroupMember))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(InsertGroupForm))] +[JsonSerializable(typeof(UpdateGroupForm))] +[JsonSerializable(typeof(JoinGroupForm))] +[JsonSerializable(typeof(GroupMembershipForm))] [JsonSerializable(typeof(ViewPost))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(InsertPostForm))] diff --git a/Models/APIs.cs b/Models/APIs.cs index 3c1e27e..2997034 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -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"; diff --git a/Pages/Groups.razor b/Pages/Groups.razor new file mode 100644 index 0000000..cf85ab0 --- /dev/null +++ b/Pages/Groups.razor @@ -0,0 +1,369 @@ +@page "/groups" +@attribute [Authorize] +@inherits LocalizableComponentBase +@Localizer["Groups"] + +@* 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. *@ +
+
+ +
+ +

@Localizer["Groups"]

+
+ +
+ + + @Localizer["A new group"] +
+ + @@ + + +
+ + + @if (draft.IsCommunity) + { + + + + @foreach (var policy in PostingPolicies) + { + + } + + + + } + + + + + @Localizer["Make the group"] + +
+ + + @Localizer["Join by invitation"] + + + + + + @Localizer["Join"] + + +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (groups.Count == 0) + { +

@Localizer["This persona is in no group yet."]

+ } + +
+ @foreach (var group in groups) + { +
+
+ + + @(group.Name ?? group.UserName) + @("@" + group.Handle) + + @(group.IsCommunity ? Localizer["Community"] : Localizer["Circle"]) + @group.MembersCount + @if (group.IsOwner) + { + + + + } + else + { + + @Localizer["Leave"] + + } +
+ + @if (open?.Id == group.Id) + { + @if (group.InvitationCode is { Length: > 0 } invitation) + { +
+ @Localizer["Invitation code"]@(group.IsPasswordRequired ? " · " + Localizer["it asks for a password"] : null) + +
+ } + + + + + @if (group.IsCommunity) + { + + + + @foreach (var policy in PostingPolicies) + { + + } + + + + } + + + @if (group.IsPasswordRequired) + { + + } + + + @Localizer["Save"] + + + +

@Localizer["Members"] @members.Count(m => !m.IsPending)

+
+ @foreach (var member in members.OrderBy(m => m.IsPending ? 0 : 1).ThenBy(m => m.Since)) + { +
+ @* alt="": the name is next to it. *@ + + + @(member.Name ?? member.Handle) + @("@" + member.Handle) + + @if (member.IsPending) + { + @Localizer["asks to join"] + + @Localizer["Let in"] + + + @Localizer["Decline"] + + } + else if (member.Role == "owner") + { + @Localizer["owner"] + } + else + { + + + + } +
+ } +
+ } +
+ } +
+
+
+ +@code { + static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"]; + + [Inject] GroupService GroupService { get; set; } + + List groups = []; + List 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]); + } +} diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 2b76abb..ba40171 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1595,4 +1595,100 @@ Da confermare + + {0} non è più nel gruppo. + + + {0} creato. + + + Una comunità, aperta al fediverso (altrimenti una cerchia privata) + + + Un nuovo gruppo + + + Un nuovo codice d'invito (quello vecchio smette di funzionare) + + + Una nuova password d'invito + + + Chiunque può pubblicare + + + Approva a mano i membri di altri server + + + chiede di entrare + + + Cerchia + + + Comunità + + + Rifiuta + + + Descrizione + + + Codice d'invito + + + Password d'invito, se ne ha una + + + Password d'invito (facoltativa) + + + chiede una password + + + Pubblicano i suoi follower + + + Entra con un invito + + + Fai entrare + + + In elenco e trovabile nelle ricerche + + + Crea il gruppo + + + Gestisci + + + Pubblicano solo i suoi moderatori + + + proprietario + + + Togli la password d'invito + + + Togli + + + Togli dal gruppo + + + Questa persona non è ancora in nessun gruppo. + + + Chi può pubblicare + + + Sei entrato in {0}. + + + Hai lasciato {0}. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index ef9ec16..3614ed2 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1595,4 +1595,100 @@ Tentative + + {0} is out of the group. + + + {0} made. + + + A community, open to the fediverse (otherwise a private circle) + + + A new group + + + A new invitation code (the old one stops working) + + + A new invitation password + + + Anyone may post + + + Approve members from elsewhere by hand + + + asks to join + + + Circle + + + Community + + + Decline + + + Description + + + Invitation code + + + Invitation password, if it has one + + + Invitation password (optional) + + + it asks for a password + + + Its followers post + + + Join by invitation + + + Let in + + + Listed and found in searches + + + Make the group + + + Manage + + + Only its moderators post + + + owner + + + Remove the invitation password + + + Take out + + + Take out of the group + + + This persona is in no group yet. + + + Who may post + + + You joined {0}. + + + You left {0}. + diff --git a/Services/GroupService.cs b/Services/GroupService.cs index 0485a04..45435bd 100644 --- a/Services/GroupService.cs +++ b/Services/GroupService.cs @@ -5,11 +5,13 @@ using collAnon.Client.Services; using decePubClient.Extensions; using decePubClient.Models; +using PrivaPub.ClientModels; using PrivaPub.ClientModels.Group; namespace decePubClient.Services { - /// The communities and circles a persona belongs to (/clientapi/group/list). + /// 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. public class GroupService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { /// The persona's groups, communities first; empty on failure. @@ -31,5 +33,66 @@ namespace decePubClient.Services var groups = await response.DefaultReadFromJsonAsync>(cancellationToken) ?? []; return [.. groups.OrderByDescending(g => g.IsCommunity).ThenBy(g => g.Name ?? g.UserName)]; } + + /// Makes a community or a circle owned by the persona; Data is the ViewGroup. + public Task Insert(InsertGroupForm form, CancellationToken cancellationToken = default) => + GroupResult(APIs.ClientApi.POST_InsertGroup, form, cancellationToken); + + /// Changes a group the persona runs; Data is the ViewGroup. + public Task Update(UpdateGroupForm form, CancellationToken cancellationToken = default) => + GroupResult(APIs.ClientApi.POST_UpdateGroup, form, cancellationToken); + + /// Joins a group by its invitation (and its password, when it has one); Data is the ViewGroup. + public Task Join(JoinGroupForm form, CancellationToken cancellationToken = default) => + GroupResult(APIs.ClientApi.POST_JoinGroup, form, cancellationToken); + + public Task Leave(string personaId, string groupId, CancellationToken cancellationToken = default) => + Membership(APIs.ClientApi.POST_LeaveGroup, personaId, groupId, null, cancellationToken); + + /// Lets in someone who asked to join a group the persona runs. + public Task Approve(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) => + Membership(APIs.ClientApi.POST_ApproveGroupMember, personaId, groupId, actorUri, cancellationToken); + + /// Declines a request to join; the asker's server is told. + public Task Reject(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) => + Membership(APIs.ClientApi.POST_RejectGroupMember, personaId, groupId, actorUri, cancellationToken); + + /// Takes a member out of a group the persona runs. + public Task Remove(string personaId, string groupId, string actorUri, CancellationToken cancellationToken = default) => + Membership(APIs.ClientApi.POST_RemoveGroupMember, personaId, groupId, actorUri, cancellationToken); + + /// Who is in a group the persona runs, and who asks to join; empty on failure. + public async Task> 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>(cancellationToken) ?? []; + } + + async Task 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(cancellationToken) }; + } + + async Task 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(); + } } } diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index d61f491..3267e4a 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -55,6 +55,10 @@ @Localizer["Lists"] + + + @Localizer["Groups"] + @if (CascadingState.PersonaId is { } personaId) { diff --git a/tests/e2e/Actions/GroupTests.cs b/tests/e2e/Actions/GroupTests.cs new file mode 100644 index 0000000..b1af3c9 --- /dev/null +++ b/tests/e2e/Actions/GroupTests.cs @@ -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); + } + } +}