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