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