From 5e8fdc43af61511c8f1daf7184d1cdad35c5291a Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 15:19:07 +0200 Subject: [PATCH] An event card joins and leaves the event PrivaPub lets a persona join another server's event (owner decision 2026-10-05). The card of a remote event shows the persona's participation (pending, accepted, rejected) with Join or Leave, except for events by invitation or taken on another site. Joining is public on the event's server, so the first join asks the persona once, as reactions and votes do, remembered apart from them (PublicAct). The e2e joins the event SocialPub's Mobilizon scenario keeps, checks the participant on Mobilizon and PrivaPub's answer, and leaves it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 10 ++-- Components/Content.razor | 51 +++++++++++++++++++ Components/PublicNoticeHost.razor | 11 ++-- Models/APIs.cs | 2 + Models/Mastodon/PrivaPubStatus.cs | 4 ++ Models/PublicActionNotices.cs | 13 ++++- Resources/AllStrings.it.resx | 21 ++++++++ Resources/AllStrings.resx | 21 ++++++++ Services/PublicNoticeService.cs | 17 ++++--- Services/StatusService.cs | 9 ++++ tests/e2e/Actions/EventTests.cs | 70 ++++++++++++++++++++++++++ tests/e2e/Actions/VoteAndReactTests.cs | 2 +- 12 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/Actions/EventTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index aa14722..f0b532c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,8 +209,9 @@ supplies: - **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`; circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from `/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete, - threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`; - located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow, + threads, mute, block, poll votes, emoji reactions through Pleroma's `/api/v1/pleroma/statuses/:id/reactions`, joining and + leaving remote events through `/api/privapub/v1/statuses/:id/join|leave`; located and group posts go through + `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow, block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and @@ -219,7 +220,10 @@ supplies: `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in - localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice. + localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice, + with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server + shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending, + accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`). - **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. diff --git a/Components/Content.razor b/Components/Content.razor index fb510ad..20fc44c 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -492,6 +492,35 @@ { @(venue.Name ?? venue.Address ?? venue.Url) } + @if (CanJoin(happening)) + { +
+ @switch (happening.Participation) + { + case "accepted": + @Localizer["You are going"] + break; + case "pending": + @Localizer["Waiting for the organiser"] + break; + case "rejected": + @Localizer["The organiser said no"] + break; + } + @if (happening.Participation is "accepted" or "pending") + { + + @Localizer["Leave"] + + } + else + { + + @Localizer["Join"] + + } +
+ } } @@ -582,6 +611,28 @@ Status.EmojiReactions = (updated?.Reblog ?? updated)?.EmojiReactions ?? Status.EmojiReactions; } + // a remote event the persona can take part in from here: not one by invitation, nor one that takes its participants + // on another site + bool CanJoin(PrivaPubEvent happening) => !Compact && !IsOwn && !Post.Located && happening.JoinMode is not ("invite" or "external"); + + async Task Participate(bool join) + { + if (join && !await Notices.Confirm(CascadingState.PersonaId, PublicAct.Participation)) + return; + isActing = true; + var result = await StatusService.Participate(Status.Id, join); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + var updated = (Status)result.Data; + if (Status.Privapub?.Event is { } happening) + happening.Participation = (updated?.Reblog ?? updated)?.Privapub?.Event?.Participation; + } + HashSet pollChoices = []; bool CanVote(Poll poll) => !poll.Expired && poll.Voted != true && !IsOwn && !Compact && !Post.Located; diff --git a/Components/PublicNoticeHost.razor b/Components/PublicNoticeHost.razor index 8789b87..97fc0b5 100644 --- a/Components/PublicNoticeHost.razor +++ b/Components/PublicNoticeHost.razor @@ -1,11 +1,16 @@ @inherits LocalizableComponentBase @implements IDisposable -@* The one-time notice before a persona's first reaction or poll vote (PublicNoticeService), in MainLayout. *@ +@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event + (PublicNoticeService), in MainLayout. *@ - @Localizer["Reactions and votes are public"] + @(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"]) -

@Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]

+

+ @(Notices.Act == PublicAct.Participation + ? Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""] + : Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]) +

@Localizer["Cancel"] diff --git a/Models/APIs.cs b/Models/APIs.cs index f1ed1e6..3c1e27e 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -120,5 +120,7 @@ public static class APIs public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}"; public static string GET_M_Provenance(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/provenance"; + public static string POST_M_Join(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/join"; + public static string POST_M_Leave(string statusId) => $"api/privapub/v1/statuses/{Uri.EscapeDataString(statusId)}/leave"; } } diff --git a/Models/Mastodon/PrivaPubStatus.cs b/Models/Mastodon/PrivaPubStatus.cs index 08a4ed3..0ebe1fa 100644 --- a/Models/Mastodon/PrivaPubStatus.cs +++ b/Models/Mastodon/PrivaPubStatus.cs @@ -73,9 +73,13 @@ public class PrivaPubEvent public string TimeZone { get; set; } public List Places { get; set; } = []; public string OnlineUrl { get; set; } + /// Mobilizon's: "free", "restricted" (the organiser approves), "invite" or "external" (not joined from here). + public string JoinMode { get; set; } public int? Participants { get; set; } public int? Capacity { get; set; } public string Status { get; set; } + /// The persona's own: "pending", "accepted" or "rejected"; null when it has not joined. + public string Participation { get; set; } } public class PrivaPubPlace diff --git a/Models/PublicActionNotices.cs b/Models/PublicActionNotices.cs index 7e53264..35b5867 100644 --- a/Models/PublicActionNotices.cs +++ b/Models/PublicActionNotices.cs @@ -1,10 +1,19 @@ namespace decePubClient.Models; /// -/// The personas already told that their reactions and poll votes are public (owner decision, PrivaPub's ROADMAP: each -/// persona is told once, before its first reaction or vote). Kept in localStorage under its type name. +/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told +/// once, before its first one): reactions and poll votes, and joining an event. Kept in localStorage under its type name. /// public class PublicActionNotices { + /// Told about reactions and poll votes. public List PersonaIds { get; set; } = []; + /// Told about joining events. + public List EventPersonaIds { get; set; } = []; +} + +public enum PublicAct +{ + Reaction = 0, + Participation = 1 } diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 571e1ff..bb97677 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1556,4 +1556,25 @@ L'autore non l'ha approvato + + Parteciperai + + + In attesa dell'organizzatore + + + L'organizzatore ha detto di no + + + Non partecipare più + + + Partecipa + + + Partecipare a un evento è pubblico + + + Il server dell'evento mostra chi partecipa, {0} compreso. Le tue altre persone non vengono mai nominate. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 3d307ae..2de6579 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1556,4 +1556,25 @@ Its author did not approve it + + You are going + + + Waiting for the organiser + + + The organiser said no + + + Leave + + + Join + + + Joining an event is public + + + The event's server shows who takes part, {0} included. Your other personas are never named. + diff --git a/Services/PublicNoticeService.cs b/Services/PublicNoticeService.cs index f29f6d3..69a202f 100644 --- a/Services/PublicNoticeService.cs +++ b/Services/PublicNoticeService.cs @@ -5,9 +5,9 @@ using decePubClient.Models; namespace decePubClient.Services { /// - /// Asks a persona, once, before its first reaction or poll vote, to accept that these are public and visible as that - /// persona (an owner decision). PublicNoticeHost shows the question; Confirm returns true at once for a persona that - /// already accepted it, and false when the reader declines. + /// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept + /// that these are public and visible as that persona (an owner decision). PublicNoticeHost shows the question; Confirm + /// returns true at once for a persona that already accepted it, and false when the reader declines. /// public class PublicNoticeService(ILocalStorageService localStorage) { @@ -17,14 +17,19 @@ namespace decePubClient.Services public bool IsAsking => pending is not null; - public async Task Confirm(string personaId, CancellationToken cancellationToken = default) + /// What the persona is being told about. + public PublicAct Act { get; private set; } = PublicAct.Reaction; + + public async Task Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(personaId)) return false; var seen = await localStorage.GetItemAsync(nameof(PublicActionNotices), cancellationToken) ?? new(); - if (seen.PersonaIds.Contains(personaId)) + var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds; + if (told.Contains(personaId)) return true; + Act = act; pending = new TaskCompletionSource(); Changed?.Invoke(); var accepted = await pending.Task; @@ -33,7 +38,7 @@ namespace decePubClient.Services if (!accepted) return false; - seen.PersonaIds.Add(personaId); + told.Add(personaId); await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken); return true; } diff --git a/Services/StatusService.cs b/Services/StatusService.cs index 63787d1..57b4812 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -172,6 +172,15 @@ namespace decePubClient.Services return await StatusResult(response, cancellationToken); } + /// Joins a remote event as the persona (its server is told, and shows who takes part), or leaves it; Data is + /// the updated Status, whose event says how the participation stands. + public async Task Participate(string statusId, bool join, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(join ? APIs.PrivaPub.POST_M_Join(statusId) : APIs.PrivaPub.POST_M_Leave(statusId), + cancellationToken: cancellationToken); + return await StatusResult(response, cancellationToken); + } + /// Votes in a poll as the persona (each option by its index); Data is the updated Poll. public async Task Vote(string pollId, IReadOnlyCollection choices, CancellationToken cancellationToken = default) { diff --git a/tests/e2e/Actions/EventTests.cs b/tests/e2e/Actions/EventTests.cs new file mode 100644 index 0000000..c8fdc48 --- /dev/null +++ b/tests/e2e/Actions/EventTests.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // Joining another server's event from its card (an owner decision): the persona is asked once that taking part is + // public, Mobilizon makes it a participant and accepts, and leaving takes it off there. The event is the one + // SocialPub's tools/pasture/scenarios/mobilizon.sh keeps for these tests. + public sealed class EventTests(Browser browser) + { + [Fact] + public async Task A_persona_joins_a_mobilizon_event_from_its_card_and_leaves_it() + { + var kept = Path.Combine(Pasture.Root, ".state", "mobilizon-kept-event.json"); + Assert.SkipUnless(File.Exists(kept), "no Mobilizon event kept for the client: run tools/pasture/interop.sh mobilizon"); + var url = JsonNode.Parse(File.ReadAllText(kept))!["url"]!.GetValue(); + var personas = Pasture.Personas(); + Assert.SkipWhen(personas.Count == 0, "the town has no personas"); + var reader = personas[0]; + var status = (await PersonaApi.Call(reader, $"/api/v2/search?q={Uri.EscapeDataString(url)}&resolve=true&type=statuses"))?["statuses"]?.AsArray().FirstOrDefault(); + Assert.SkipWhen(status is null, "PrivaPub cannot resolve the kept Mobilizon event"); + var id = status!["id"]!.GetValue(); + async Task Participation() => + (await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue(); + // a run cut short may have left the persona in: start from outside + if (await Participation() is not null) + await PersonaApi.Call(reader, $"/api/privapub/v1/statuses/{id}/leave", HttpMethod.Post); + await using var session = await SignIn.As(browser, reader); + var page = session.Page; + await Results.Cell("client.action.join", "mobilizon", "kept-event", async () => + { + await page.GotoAsync($"{Pasture.Site}/expand/{id}"); + var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue()}']").First; + await card.Locator("[data-event-join]").ClickAsync(new() { Timeout = 30000 }); + await VoteAndReactTests.AcceptNoticeIfAsked(page); + await card.Locator("[data-event-leave]").WaitForAsync(new() { Timeout = 15000 }); + Assert.True(await Pasture.Eventually(async () => await Participation() == "accepted", 45), "Mobilizon never accepted the Join"); + Assert.Equal("participant", await MobilizonRole(reader.ActorUri, url)); + + await page.ReloadAsync(); + await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "accepted", new() { Timeout = 30000 }); + await card.Locator("[data-event-leave]").ClickAsync(); + await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "none", new() { Timeout = 15000 }); + Assert.Null(await Participation()); + Assert.True(await Pasture.Eventually(async () => await MobilizonRole(reader.ActorUri, url) == "", 45), + "the persona is still a participant on Mobilizon"); + await session.AssertGuards(); + }, page); + } + + // the persona's role in the event, from Mobilizon's database; empty when it takes no part + static async Task MobilizonRole(string actorUri, string eventUrl) + { + static string Quoted(string value) => "'" + value.Replace("'", "''") + "'"; + var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in new[] { "exec", "pasture-postgis", "psql", "-U", "pasture", "-d", "mobilizon", "-tAc", + "select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id " + + $"where a.url = {Quoted(actorUri)} and e.url = {Quoted(eventUrl)}" }) + start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return output.Trim(); + } + } +} diff --git a/tests/e2e/Actions/VoteAndReactTests.cs b/tests/e2e/Actions/VoteAndReactTests.cs index 637d2a4..ab14ef5 100644 --- a/tests/e2e/Actions/VoteAndReactTests.cs +++ b/tests/e2e/Actions/VoteAndReactTests.cs @@ -47,7 +47,7 @@ namespace decePubClient.E2E.Actions } // the one-time notice, when this persona has not seen it yet - static async Task AcceptNoticeIfAsked(IPage page) + internal static async Task AcceptNoticeIfAsked(IPage page) { var proceed = page.GetByRole(AriaRole.Button, new() { Name = "Continue" }); try