@page "/Tools/Moderation/MembershipHistory" @using System.Collections.Frozen @using System.Collections.ObjectModel @using System.Diagnostics @using System.Text.Json @using ArcaneLibs.Extensions @using LibMatrix @using LibMatrix.EventTypes.Spec.State.RoomInfo @using LibMatrix.Filters @{ var sw = Stopwatch.StartNew(); Console.WriteLine("Start render"); }

Membership history viewer



Room ID: Execute

Chronological order Enable extended filters

Show joins leaves knocks invites bans

Hide all Show all Toggle all

Disambiguate @if (DoDisambiguate) { kicks unbans profile updates

invite actions accepted rejected retracted
knock actions accepted rejected retracted
}

@if (DoDisambiguate) {

Show @if (DisambiguateKicks) { kicks } @if (DisambiguateUnbans) { unbans } @if (DisambiguateProfileUpdates) { profile updates } @if (DisambiguateInviteActions) {

invite actions @if (DisambiguateInviteAccepted) { accepted } @if (DisambiguateInviteRejected) { rejected } @if (DisambiguateInviteRetracted) { retracted }
} @if (DisambiguateKnockActions) {
knock actions @if (DisambiguateKnockAccepted) { accepted } @if (DisambiguateKnockRejected) { rejected } @if (DisambiguateKnockRetracted) { retracted }
}

Un-disambiguate all Disambiguate all Toggle all

}

Sender: @foreach (var sender in Memberships.Select(x => x.Sender).Distinct()) { }

User: @foreach (var user in Memberships.Select(x => x.StateKey).Distinct()) { }

@{ Console.WriteLine($"Rendering took {sw.Elapsed} for {Memberships.Count} items"); }
Results @{ var filteredMemberships = GetFilteredMemberships(); } @foreach (var membershipEntry in filteredMemberships) { var (transition, membership, previousMembership) = membershipEntry; RoomMemberEventContent content = membership.TypedContent as RoomMemberEventContent ?? throw new InvalidOperationException("Event is not a RoomMemberEventContent!"); RoomMemberEventContent? previousContent = previousMembership?.TypedContent as RoomMemberEventContent; }
@DateTimeOffset.FromUnixTimeMilliseconds(membership.OriginServerTs ?? 0).ToString("g") @switch (transition) { case MembershipTransition.None: Unknown membership! Got None break; case MembershipTransition.Join:

@membership.StateKey joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
Display name: @content.DisplayName
Avatar URL: @content.AvatarUrl

break; case MembershipTransition.Leave:

@membership.StateKey left the room

break; case MembershipTransition.Knock:

@membership.StateKey knocked @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; case MembershipTransition.Invite:

@membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; case MembershipTransition.Ban:

@membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; @* disambiguated *@ case MembershipTransition.Kick:

@membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; case MembershipTransition.ProfileUpdate:

@membership.Sender changed their profile
Display name: @previousContent!.DisplayName -> @content.DisplayName
Avatar URL: @previousContent.AvatarUrl -> @content.AvatarUrl

break; case MembershipTransition.InviteAccepted:

@membership.StateKey accepted the invite from @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(invite reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(accept reason: {content.Reason})")

break; case MembershipTransition.KnockAccepted:

@membership.StateKey's knock was accepted by @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(knock reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(accept reason: {content.Reason})")

break; case MembershipTransition.KnockRejected:

@membership.StateKey's knock was rejected by @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(knock reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reject reason: {content.Reason})")

break; case MembershipTransition.Unban:

@membership.Sender unbanned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; case MembershipTransition.InviteRejected:

@membership.StateKey rejected the invite from @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(invite reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reject reason: {content.Reason})")

break; case MembershipTransition.InviteRetracted:

@membership.Sender retracted the invite for @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; case MembershipTransition.KnockRetracted:

@membership.Sender retracted the knock for @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")

break; default: throw new ArgumentOutOfRangeException(); }

Log @foreach (var line in Log.Reverse()) {
@line
}
@code { #region Filter bindings private bool ChronologicalOrder { get; set; } private bool ShowJoins { get; set; } = true; private bool ShowLeaves { get; set; } = true; private bool ShowKnocks { get; set; } = true; private bool ShowInvites { get; set; } = true; private bool ShowBans { get; set; } = true; private bool DoDisambiguate { get; set; } = true; private bool DisambiguateProfileUpdates { get => field && DoDisambiguate; set; } = true; private bool DisambiguateKicks { get => field && DoDisambiguate; set; } = true; private bool DisambiguateUnbans { get => field && DoDisambiguate; set; } = true; private bool DisambiguateInviteAccepted { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true; private bool DisambiguateInviteRejected { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true; private bool DisambiguateInviteRetracted { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true; private bool DisambiguateKnockAccepted { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true; private bool DisambiguateKnockRejected { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true; private bool DisambiguateKnockRetracted { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true; private bool DisambiguateKnockActions { get => field && DoDisambiguate; set; } = true; private bool DisambiguateInviteActions { get => field && DoDisambiguate; set; } = true; private bool ShowProfileUpdates { get => field && DisambiguateProfileUpdates; set; } = true; private bool ShowKicks { get => field && DisambiguateKicks; set; } = true; private bool ShowUnbans { get => field && DisambiguateUnbans; set; } = true; private bool ShowInviteAccepted { get => field && DisambiguateInviteAccepted; set; } = true; private bool ShowInviteRejected { get => field && DisambiguateInviteRejected; set; } = true; private bool ShowInviteRetracted { get => field && DisambiguateInviteRetracted; set; } = true; private bool ShowKnockAccepted { get => field && DisambiguateKnockAccepted; set; } = true; private bool ShowKnockRejected { get => field && DisambiguateKnockRejected; set; } = true; private bool ShowKnockRetracted { get => field && DisambiguateKnockRetracted; set; } = true; private bool ShowKnockActions { get => field && DisambiguateKnockActions; set; } = true; private bool ShowInviteActions { get => field && DisambiguateInviteActions; set; } = true; [Parameter, SupplyParameterFromQuery(Name = "sender")] public string Sender { get; set; } = ""; [Parameter, SupplyParameterFromQuery(Name = "user")] public string User { get; set; } = ""; [Parameter, SupplyParameterFromQuery(Name = "filter")] public string Filter { get; set { field = value; if (string.IsNullOrWhiteSpace(value)) return; var parts = value.Split(','); ShowJoins = parts.Contains("join"); ShowLeaves = parts.Contains("leave"); ShowKnocks = parts.Contains("knock"); ShowInvites = parts.Contains("invite"); ShowBans = parts.Contains("ban"); StateHasChanged(); } } = ""; #endregion private ObservableCollection Log { get; set; } = new(); private List Memberships { get; set; } = []; private AuthenticatedHomeserverGeneric Homeserver { get; set; } [Parameter, SupplyParameterFromQuery(Name = "room")] public string RoomId { get; set; } = ""; protected override async Task OnInitializedAsync() { Log.CollectionChanged += (sender, args) => StateHasChanged(); Homeserver = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (Homeserver is null) return; StateHasChanged(); Console.WriteLine("Rerendered!"); await base.OnInitializedAsync(); if (!string.IsNullOrWhiteSpace(RoomId)) await Execute(); } private async Task Execute() { Memberships.Clear(); var room = Homeserver.GetRoom(RoomId); var filter = new SyncFilter.EventFilter() { Types = [RoomMemberEventContent.EventId] }; var events = room.GetManyMessagesAsync(limit: int.MaxValue, filter: filter.ToJson(ignoreNull: true, indent: false)); await foreach (var resp in events) { var all = resp.State.Concat(resp.Chunk) // ugly hack, because some users fuck around too much .Select(x => { if (x.RawContent?["displayname"]?.GetValueKind() != JsonValueKind.String) x.RawContent?.Remove("displayname"); if (x.RawContent?["avatar_url"]?.GetValueKind() is not JsonValueKind.String) x.RawContent?.Remove("avatar_url"); return x; }); Memberships.AddRange(all.Where(x => x.Type == RoomMemberEventContent.EventId)); Log.Add($"Got {resp.State.Count} state and {resp.Chunk.Count} timeline events."); } Log.Add("Reached end of timeline!"); StateHasChanged(); } private readonly struct MembershipEntry { public required MembershipTransition State { get; init; } public required StateEventResponse Event { get; init; } public required StateEventResponse? Previous { get; init; } public void Deconstruct(out MembershipTransition transition, out StateEventResponse evt, out StateEventResponse? prev) { transition = State; evt = Event; prev = Previous; } } private enum MembershipTransition : byte { None, Join, Leave, Knock, Invite, Ban, // disambiguated ProfileUpdate, Kick, Unban, InviteAccepted, InviteRejected, InviteRetracted, KnockAccepted, KnockRejected, KnockRetracted } private static IEnumerable GetTransitions(List evts) { Dictionary transitions = new(); foreach (var evt in evts.OrderBy(x => x.OriginServerTs)) { var content = evt.TypedContent as RoomMemberEventContent ?? throw new InvalidOperationException("Event is not a RoomMemberEventContent!"); var prev = transitions.GetValueOrDefault(evt.StateKey!) as MembershipEntry?; transitions[evt.StateKey ?? throw new Exception("Member event has no state key??")] = new MembershipEntry { Event = evt, Previous = prev?.Event, State = content.Membership switch { RoomMemberEventContent.MembershipTypes.Join => prev?.State switch { MembershipTransition.Join or MembershipTransition.InviteAccepted => MembershipTransition.ProfileUpdate, MembershipTransition.Invite => MembershipTransition.InviteAccepted, _ => MembershipTransition.Join }, RoomMemberEventContent.MembershipTypes.Leave => evt.Sender == evt.StateKey ? prev?.State switch { MembershipTransition.Knock => MembershipTransition.KnockRetracted, MembershipTransition.Invite => MembershipTransition.InviteRejected, _ => MembershipTransition.Leave } : prev?.State switch { // not self MembershipTransition.Knock => MembershipTransition.KnockRejected, MembershipTransition.Invite => MembershipTransition.InviteRetracted, _ => MembershipTransition.Kick, }, RoomMemberEventContent.MembershipTypes.Invite => prev?.State switch { MembershipTransition.Knock => MembershipTransition.KnockAccepted, _ => MembershipTransition.Invite }, RoomMemberEventContent.MembershipTypes.Knock => MembershipTransition.Knock, RoomMemberEventContent.MembershipTypes.Ban => MembershipTransition.Ban, _ => MembershipTransition.None } }; yield return transitions[evt.StateKey]; } } private IEnumerable Disambiguated(IEnumerable entries) { FrozenDictionary disambiguated = new Dictionary() { { MembershipTransition.ProfileUpdate, MembershipTransition.Join }, { MembershipTransition.Kick, MembershipTransition.Leave }, { MembershipTransition.Unban, MembershipTransition.Leave }, { MembershipTransition.InviteAccepted, MembershipTransition.Join }, { MembershipTransition.InviteRejected, MembershipTransition.Leave }, { MembershipTransition.InviteRetracted, MembershipTransition.Leave }, { MembershipTransition.KnockAccepted, MembershipTransition.Invite }, { MembershipTransition.KnockRejected, MembershipTransition.Leave }, { MembershipTransition.KnockRetracted, MembershipTransition.Leave } }.ToFrozenDictionary(); foreach (var entry in entries) { if (!DoDisambiguate) { yield return entry; continue; } var newState = entry.State switch { MembershipTransition.ProfileUpdate when !DoDisambiguate || !DisambiguateProfileUpdates => MembershipTransition.Join, MembershipTransition.Kick when !DoDisambiguate || !DisambiguateKicks => MembershipTransition.Leave, MembershipTransition.Unban when !DoDisambiguate || !DisambiguateUnbans => MembershipTransition.Leave, MembershipTransition.InviteAccepted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted => MembershipTransition.Join, MembershipTransition.InviteRejected when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected => MembershipTransition.Leave, MembershipTransition.InviteRetracted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted => MembershipTransition.Leave, MembershipTransition.KnockAccepted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted => MembershipTransition.Invite, MembershipTransition.KnockRejected when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected => MembershipTransition.Leave, MembershipTransition.KnockRetracted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted => MembershipTransition.Leave, _ => entry.State }; if (newState != entry.State) { yield return entry with { State = newState }; } else yield return entry; } } private IEnumerable GetFilteredMemberships() { var filteredMemberships = GetTransitions(Memberships); if (!string.IsNullOrWhiteSpace(Sender)) filteredMemberships = filteredMemberships.Where(x => x.Event.Sender == Sender); if (!string.IsNullOrWhiteSpace(User)) filteredMemberships = filteredMemberships.Where(x => x.Event.StateKey == User); filteredMemberships = Disambiguated(filteredMemberships); if (!ShowJoins) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Join); if (!ShowLeaves) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Leave); if (!ShowKnocks) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Knock); if (!ShowInvites) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Invite); if (!ShowBans) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Ban); // extended filters if (DoDisambiguate) { if (!DisambiguateProfileUpdates || !ShowProfileUpdates) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.ProfileUpdate); if (!DisambiguateKicks || !ShowKicks) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Kick); if (!DisambiguateUnbans || !ShowUnbans) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Unban); if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteAccepted || !ShowInviteAccepted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteAccepted); if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteRejected || !ShowInviteRejected) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteRejected); if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteRetracted || !ShowInviteRetracted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteRetracted); if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockAccepted || !ShowKnockAccepted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockAccepted); if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockRejected || !ShowKnockRejected) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockRejected); if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockRetracted || !ShowKnockRetracted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockRetracted); } if (!ChronologicalOrder) filteredMemberships = filteredMemberships.Reverse(); return filteredMemberships; } }