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