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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 15:19:07 +02:00
1 parent 6f8472bc9f
commit 5e8fdc43af
12 files changed
+216 -15

No files matched your search

+7 -3
View File
@@ -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.
+51
View File
@@ -492,6 +492,35 @@
{
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
}
@if (CanJoin(happening))
{
<div class="flex flex-wrap gap-2 items-center" data-participation="@(happening.Participation ?? "none")">
@switch (happening.Participation)
{
case "accepted":
<span class="badge badge-success badge-sm"><i class="ion-md-checkmark"></i>@Localizer["You are going"]</span>
break;
case "pending":
<span class="badge badge-warning badge-sm"><i class="ion-md-hourglass"></i>@Localizer["Waiting for the organiser"]</span>
break;
case "rejected":
<span class="badge badge-error badge-sm"><i class="ion-md-close-circle"></i>@Localizer["The organiser said no"]</span>
break;
}
@if (happening.Participation is "accepted" or "pending")
{
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Participate(false)" data-event-leave>
@Localizer["Leave"]
</DButton>
}
else
{
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Participate(true)" data-event-join>
<i class="ion-md-people"></i>@Localizer["Join"]
</DButton>
}
</div>
}
</div>
}
@@ -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<int> pollChoices = [];
bool CanVote(Poll poll) => !poll.Expired && poll.Voted != true && !IsOwn && !Compact && !Post.Located;
+8 -3
View File
@@ -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. *@
<DModal IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
<Heading>@Localizer["Reactions and votes are public"]</Heading>
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
<ChildContent>
<p class="text-sm">@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 ?? ""]</p>
<p class="text-sm">
@(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 ?? ""])
</p>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
+2
View File
@@ -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";
}
}
+4
View File
@@ -73,9 +73,13 @@ public class PrivaPubEvent
public string TimeZone { get; set; }
public List<PrivaPubPlace> Places { get; set; } = [];
public string OnlineUrl { get; set; }
/// <summary>Mobilizon's: "free", "restricted" (the organiser approves), "invite" or "external" (not joined from here).</summary>
public string JoinMode { get; set; }
public int? Participants { get; set; }
public int? Capacity { get; set; }
public string Status { get; set; }
/// <summary>The persona's own: "pending", "accepted" or "rejected"; null when it has not joined.</summary>
public string Participation { get; set; }
}
public class PrivaPubPlace
+11 -2
View File
@@ -1,10 +1,19 @@
namespace decePubClient.Models;
/// <summary>
/// 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.
/// </summary>
public class PublicActionNotices
{
/// <summary>Told about reactions and poll votes.</summary>
public List<string> PersonaIds { get; set; } = [];
/// <summary>Told about joining events.</summary>
public List<string> EventPersonaIds { get; set; } = [];
}
public enum PublicAct
{
Reaction = 0,
Participation = 1
}
+21
View File
@@ -1556,4 +1556,25 @@
<data name="Its author did not approve it" xml:space="preserve">
<value>L'autore non l'ha approvato</value>
</data>
<data name="You are going" xml:space="preserve">
<value>Parteciperai</value>
</data>
<data name="Waiting for the organiser" xml:space="preserve">
<value>In attesa dell'organizzatore</value>
</data>
<data name="The organiser said no" xml:space="preserve">
<value>L'organizzatore ha detto di no</value>
</data>
<data name="Leave" xml:space="preserve">
<value>Non partecipare più</value>
</data>
<data name="Join" xml:space="preserve">
<value>Partecipa</value>
</data>
<data name="Joining an event is public" xml:space="preserve">
<value>Partecipare a un evento è pubblico</value>
</data>
<data name="The event&#x27;s server shows who takes part, {0} included. Your other personas are never named." xml:space="preserve">
<value>Il server dell'evento mostra chi partecipa, {0} compreso. Le tue altre persone non vengono mai nominate.</value>
</data>
</root>
+21
View File
@@ -1556,4 +1556,25 @@
<data name="Its author did not approve it" xml:space="preserve">
<value>Its author did not approve it</value>
</data>
<data name="You are going" xml:space="preserve">
<value>You are going</value>
</data>
<data name="Waiting for the organiser" xml:space="preserve">
<value>Waiting for the organiser</value>
</data>
<data name="The organiser said no" xml:space="preserve">
<value>The organiser said no</value>
</data>
<data name="Leave" xml:space="preserve">
<value>Leave</value>
</data>
<data name="Join" xml:space="preserve">
<value>Join</value>
</data>
<data name="Joining an event is public" xml:space="preserve">
<value>Joining an event is public</value>
</data>
<data name="The event&#x27;s server shows who takes part, {0} included. Your other personas are never named." xml:space="preserve">
<value>The event's server shows who takes part, {0} included. Your other personas are never named.</value>
</data>
</root>
+11 -6
View File
@@ -5,9 +5,9 @@ using decePubClient.Models;
namespace decePubClient.Services
{
/// <summary>
/// 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.
/// </summary>
public class PublicNoticeService(ILocalStorageService localStorage)
{
@@ -17,14 +17,19 @@ namespace decePubClient.Services
public bool IsAsking => pending is not null;
public async Task<bool> Confirm(string personaId, CancellationToken cancellationToken = default)
/// <summary>What the persona is being told about.</summary>
public PublicAct Act { get; private set; } = PublicAct.Reaction;
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(personaId))
return false;
var seen = await localStorage.GetItemAsync<PublicActionNotices>(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<bool>();
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;
}
+9
View File
@@ -172,6 +172,15 @@ namespace decePubClient.Services
return await StatusResult(response, cancellationToken);
}
/// <summary>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.</summary>
public async Task<WebResult> 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);
}
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
{
+70
View File
@@ -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<string>();
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<string>();
async Task<string> Participation() =>
(await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue<string>();
// 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<string>()}']").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<string> 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();
}
}
}
+1 -1
View File
@@ -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