about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-03-22 17:47:29 +0100
committerRory& <root@rory.gay>2024-03-22 17:47:29 +0100
commitc69e5c790b2b277d9b11265b8f0883e9f90fe3b9 (patch)
treea2ad72230772d7459605ebc4ba13337e70d3bda4
parentChanges (diff)
downloadMatrixUtils-c69e5c790b2b277d9b11265b8f0883e9f90fe3b9.tar.xz
Changes
m---------LibMatrix0
-rw-r--r--MatrixUtils.Abstractions/RoomInfo.cs59
-rw-r--r--MatrixUtils.Web/Pages/Index.razor32
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index.razor10
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2.razor2
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2ByRoomTypeTab.razor53
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor1
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Timeline.razor44
-rw-r--r--MatrixUtils.Web/Pages/Tools/InviteCounter.razor73
-rw-r--r--MatrixUtils.Web/Pages/Tools/MassCMEBan.razor75
-rw-r--r--MatrixUtils.Web/Pages/Tools/UserTrace.razor97
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor8
-rw-r--r--MatrixUtils.Web/Shared/ResourceUsage.razor64
-rw-r--r--MatrixUtils.Web/Shared/RoomListItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor4
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownStateItem.razor16
m---------MxApiExtensions0
17 files changed, 435 insertions, 105 deletions
diff --git a/LibMatrix b/LibMatrix
-Subproject 2b566a31b68f14d51faae61cbfbe359d0691a89
+Subproject fc3d89ad7422dedb8763783c6ebb5d70fcc2c53
diff --git a/MatrixUtils.Abstractions/RoomInfo.cs b/MatrixUtils.Abstractions/RoomInfo.cs
index 53acbee..5c258a4 100644
--- a/MatrixUtils.Abstractions/RoomInfo.cs
+++ b/MatrixUtils.Abstractions/RoomInfo.cs
@@ -11,6 +11,20 @@ using LibMatrix.RoomTypes;
 namespace MatrixUtils.Abstractions;
 
 public class RoomInfo : NotifyPropertyChanged {
+    public RoomInfo(GenericRoom room) {
+        Room = room;
+        _fallbackIcon = identiconGenerator.GenerateAsDataUri(room.RoomId);
+        RegisterEventListener();
+    }
+
+    public RoomInfo(GenericRoom room, List<StateEventResponse>? stateEvents) {
+        Room = room;
+        _fallbackIcon = identiconGenerator.GenerateAsDataUri(room.RoomId);
+        if (stateEvents is { Count: > 0 }) StateEvents = new(stateEvents!);
+        RegisterEventListener();
+        ProcessNewItems(stateEvents!);
+    }
+    
     public readonly GenericRoom Room;
     public ObservableCollection<StateEventResponse?> StateEvents { get; private set; } = new();
 
@@ -131,37 +145,30 @@ public class RoomInfo : NotifyPropertyChanged {
         set => SetField(ref _ownMembership, value);
     }
 
-    public RoomInfo(GenericRoom room) {
-        Room = room;
-        _fallbackIcon = identiconGenerator.GenerateAsDataUri(room.RoomId);
-        registerEventListener();
-    }
-
-    public RoomInfo(GenericRoom room, List<StateEventResponse>? stateEvents) {
-        Room = room;
-        _fallbackIcon = identiconGenerator.GenerateAsDataUri(room.RoomId);
-        if (stateEvents is { Count: > 0 }) StateEvents = new(stateEvents!);
-        registerEventListener();
-    }
-
-    private void registerEventListener() {
+    private void RegisterEventListener() {
         StateEvents.CollectionChanged += (_, args) => {
             if (args.NewItems is { Count: > 0 })
-                foreach (StateEventResponse? newState in args.NewItems) {
-                    // TODO: switch statement benchmark?
-                    if (newState is null) continue;
-                    if (newState.Type == RoomNameEventContent.EventId && newState.TypedContent is RoomNameEventContent roomNameContent)
-                        RoomName = roomNameContent.Name;
-                    else if (newState is { Type: RoomAvatarEventContent.EventId, TypedContent: RoomAvatarEventContent roomAvatarContent })
-                        RoomIcon = roomAvatarContent.Url;
-                    else if (newState is { Type: RoomCreateEventContent.EventId, TypedContent: RoomCreateEventContent roomCreateContent }) {
-                        CreationEventContent = roomCreateContent;
-                        RoomCreator = newState.Sender;
-                    }
-                }
+                ProcessNewItems(args.NewItems.OfType<StateEventResponse>());
         };
     }
 
+    private void ProcessNewItems(IEnumerable<StateEventResponse?> newItems) {
+        foreach (StateEventResponse? newState in newItems) {
+            if (newState is null) continue;
+            // TODO: Benchmark switch statement
+            
+            if(newState.StateKey != "") continue;
+            if (newState.Type == RoomNameEventContent.EventId && newState.TypedContent is RoomNameEventContent roomNameContent)
+                RoomName = roomNameContent.Name;
+            else if (newState is { Type: RoomAvatarEventContent.EventId, TypedContent: RoomAvatarEventContent roomAvatarContent })
+                RoomIcon = roomAvatarContent.Url;
+            else if (newState is { Type: RoomCreateEventContent.EventId, TypedContent: RoomCreateEventContent roomCreateContent }) {
+                CreationEventContent = roomCreateContent;
+                RoomCreator = newState.Sender;
+            }
+        }
+    }
+
     public async Task FetchAllStateAsync() {
         var stateEvents = Room.GetFullStateAsync();
         await foreach (var stateEvent in stateEvents) {
diff --git a/MatrixUtils.Web/Pages/Index.razor b/MatrixUtils.Web/Pages/Index.razor
index 19c74c3..7c0a9f2 100644
--- a/MatrixUtils.Web/Pages/Index.razor
+++ b/MatrixUtils.Web/Pages/Index.razor
@@ -88,6 +88,35 @@ Small collection of tools to do not-so-everyday things.
     </form>
 }
 
+@if (_invalidSessions.Count > 0) {
+    <br/>
+    <br/>
+    <h5>Invalid sessions</h5>
+    <hr/>
+    <form>
+        <table>
+            @foreach (var session in _invalidSessions) {
+                <tr class="user-entry">
+                    <td>
+                        <p>
+                            @{
+                                string[] parts = session.UserId.Split(':');
+                            }
+                            <span>@parts[0][1..]</span> on <span>@parts[1]</span>
+                            @if (!string.IsNullOrWhiteSpace(session.Proxy)) {
+                                <span class="badge badge-info"> (proxied via @session.Proxy)</span>
+                            }
+                        </p>
+                    </td>
+                    <td>
+                        <LinkButton OnClick="@(() => RemoveUser(session))">Remove</LinkButton>
+                    </td>
+                </tr>
+            }
+        </table>
+    </form>
+}
+
 @code
 {
 #if DEBUG
@@ -106,6 +135,7 @@ Small collection of tools to do not-so-everyday things.
     // private Dictionary<UserAuth, UserInfo> _users = new();
     private readonly List<AuthInfo> _sessions = [];
     private readonly List<UserAuth> _offlineSessions = [];
+    private readonly List<UserAuth> _invalidSessions = [];
     private LoginResponse? _currentSession;
     int scannedSessions = 0, totalSessions = 1;
     private SvgIdenticonGenerator _identiconGenerator = new();
@@ -162,7 +192,7 @@ Small collection of tools to do not-so-everyday things.
                 }
             }
             catch (MatrixException e) {
-                if (e is { ErrorCode: "M_UNKNOWN_TOKEN" }) _offlineSessions.Add(token);
+                if (e is { ErrorCode: "M_UNKNOWN_TOKEN" }) _invalidSessions.Add(token);
                 else throw;
             }
             catch {
diff --git a/MatrixUtils.Web/Pages/Rooms/Index.razor b/MatrixUtils.Web/Pages/Rooms/Index.razor
index c44e23f..28c4de2 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index.razor
@@ -13,8 +13,9 @@
 <p>@Status2</p>
 
 <LinkButton href="/Rooms/Create">Create new room</LinkButton>
-
-<RoomList Rooms="Rooms" GlobalProfile="@GlobalProfile" @bind-StillFetching="RenderContents"></RoomList>
+<CascadingValue TValue="AuthenticatedHomeserverGeneric" Value="Homeserver">
+    <RoomList Rooms="Rooms" GlobalProfile="@GlobalProfile" @bind-StillFetching="RenderContents"></RoomList>
+</CascadingValue>
 
 @code {
 
@@ -73,10 +74,9 @@
         // SemaphoreSlim _semaphore = new(160, 160);
         GlobalProfile = await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId);
 
-        // var filter = await Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetBasicRoomInfo);
         var filter = await Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetBasicRoomInfo);
         var filterData = await Homeserver.GetFilterAsync(filter);
-        
+
         // Rooms = new ObservableCollection<RoomInfo>(rooms.Select(room => new RoomInfo(room)));
         // foreach (var stateType in filterData.Room?.State?.Types ?? []) {
         //     var tasks = Rooms.Select(async room => {
@@ -204,7 +204,7 @@
             if (sync is null) continue;
 
             var filter = await Homeserver.GetFilterAsync(syncHelper.FilterId);
-            
+
             Status = $"Got sync with {sync.Rooms?.Join?.Count ?? 0} room updates, next batch: {sync.NextBatch}!";
             if (sync?.Rooms?.Join != null)
                 foreach (var joinedRoom in sync.Rooms.Join)
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2.razor b/MatrixUtils.Web/Pages/Rooms/Index2.razor
index ae31126..98b8a1d 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index2.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index2.razor
@@ -27,9 +27,11 @@
             break;
         case Tab.DMs:
             <h3>DMs tab</h3>
+            <RoomsIndex2DMsTab></RoomsIndex2DMsTab>
             break;
         case Tab.ByRoomType:
             <h3>By room type tab</h3>
+            <RoomsIndex2ByRoomTypeTab></RoomsIndex2ByRoomTypeTab>
             break;
         default:
             throw new InvalidEnumArgumentException();
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2ByRoomTypeTab.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2ByRoomTypeTab.razor
new file mode 100644
index 0000000..f4cf849
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2ByRoomTypeTab.razor
@@ -0,0 +1,53 @@
+@using MatrixUtils.Abstractions
+@using System.Security.Cryptography
+@using ArcaneLibs.Extensions
+<h3>RoomsIndex2MainTab</h3>
+
+<div>
+    <div class="row">
+        <div class="col-3" style="background-color: #ffffff66;">
+            <LinkButton>Uncategorised rooms</LinkButton>
+            @foreach (var space in Data.Rooms.Where(x => x.RoomType == "m.space")) {
+                <div style="@("width: 100%; height: 50px; background-color: #" + RandomNumberGenerator.GetBytes(3).Append((byte)0x11).ToArray().AsHexString().Replace(" ",""))">
+                    <p>@space.RoomName</p>
+                </div>
+            }
+        </div>
+        <div class="col-9" style="background-color: #ff00ff66;">
+            <p>omae wa mou shindeiru</p>
+        </div>
+    </div>
+</div>
+
+@code {
+
+    [CascadingParameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    protected override async Task OnInitializedAsync() {
+        Data.Rooms.CollectionChanged += (sender, args) => {
+            DebouncedStateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var newItem in args.NewItems) {
+                    (newItem as RoomInfo).PropertyChanged += (sender, args) => { DebouncedStateHasChanged(); };
+                }
+        };
+        await base.OnInitializedAsync();
+    }
+
+    //debounce StateHasChanged, we dont want to reredner on every key stroke
+
+    private CancellationTokenSource _debounceCts = new CancellationTokenSource();
+
+    private async Task DebouncedStateHasChanged() {
+        _debounceCts.Cancel();
+        _debounceCts = new CancellationTokenSource();
+        try {
+            await Task.Delay(100, _debounceCts.Token);
+            Console.WriteLine("DebouncedStateHasChanged - Calling StateHasChanged!");
+            StateHasChanged();
+        }
+        catch (TaskCanceledException) { }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor
index 1fb3f89..418ee02 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor
@@ -50,7 +50,6 @@
             ["Main"] = new SyncHelper(Data.Homeserver, logger) {
                 Timeout = 30000,
                 FilterId = await Data.Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetBasicRoomInfo),
-                // FilterId = await Data.Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetBasicRoomInfo),
                 // MinimumDelay = TimeSpan.FromMilliseconds(5000)
             }
         };
diff --git a/MatrixUtils.Web/Pages/Rooms/Timeline.razor b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
index 3886c5b..e6b1248 100644
--- a/MatrixUtils.Web/Pages/Rooms/Timeline.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
@@ -8,9 +8,9 @@
 <p>Loaded @Events.Count events...</p>
 
 @foreach (var evt in Events) {
-    <div type="@evt.Type" key="@evt.StateKey" itemid="@evt.EventId">
-        <DynamicComponent Type="@ComponentType(evt)"
-                          Parameters="@(new Dictionary<string, object> { { "Event", evt }, { "Events", Events }, { "Homeserver", Homeserver!} })">
+    <div type="@evt.Event.Type" key="@evt.Event.StateKey" itemid="@evt.Event.EventId">
+        <DynamicComponent Type="@evt.Type"
+                          Parameters="@(new Dictionary<string, object> { { "Event", evt.Event }, { "Events", RawEvents }, { "Homeserver", Homeserver! } })">
         </DynamicComponent>
     </div>
 }
@@ -20,8 +20,8 @@
     [Parameter]
     public string RoomId { get; set; }
 
-    private List<MessagesResponse> Messages { get; } = new();
-    private List<StateEventResponse> Events { get; } = new();
+    private List<TimelineEventItem> Events { get; } = new();
+    private List<StateEventResponse> RawEvents { get; } = new();
 
     private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
 
@@ -32,28 +32,34 @@
         var room = Homeserver.GetRoom(RoomId);
         MessagesResponse? msgs = null;
         do {
-            msgs = await room.GetMessagesAsync(limit: 1000, from: msgs?.End, dir: "b");
-            Messages.Add(msgs);
+            msgs = await room.GetMessagesAsync(limit: 10000, from: msgs?.End, dir: "b");
             Console.WriteLine($"Got {msgs.Chunk.Count} messages");
+            StateHasChanged();
             msgs.Chunk.Reverse();
-            Events.InsertRange(0, msgs.Chunk);
+            Events.InsertRange(0, msgs.Chunk.Select(x => new TimelineEventItem { Event = x, Type = ComponentType(x) }));
+            RawEvents.InsertRange(0, msgs.Chunk);
         } while (msgs.End is not null);
 
-
         await base.OnInitializedAsync();
     }
 
-    private StateEventResponse GetProfileEventBefore(StateEventResponse Event) => Events.TakeWhile(x => x != Event).Last(e => e.Type == "m.room.member" && e.StateKey == Event.Sender);
+    // private StateEventResponse GetProfileEventBefore(StateEventResponse Event) => Events.TakeWhile(x => x != Event).Last(e => e.Type == RoomMemberEventContent.EventId && e.StateKey == Event.Sender);
 
-    private Type ComponentType(StateEvent Event) => Event.TypedContent switch {
-        RoomCanonicalAliasEventContent => typeof(TimelineCanonicalAliasItem),
-        RoomHistoryVisibilityEventContent => typeof(TimelineHistoryVisibilityItem),
-        RoomTopicEventContent => typeof(TimelineRoomTopicItem),
-        RoomMemberEventContent => typeof(TimelineMemberItem),
-        RoomMessageEventContent => typeof(TimelineMessageItem),
-        RoomCreateEventContent => typeof(TimelineRoomCreateItem),
-        RoomNameEventContent => typeof(TimelineRoomNameItem),
+    private Type ComponentType(StateEvent Event) => Event.Type switch {
+        RoomCanonicalAliasEventContent.EventId => typeof(TimelineCanonicalAliasItem),
+        RoomHistoryVisibilityEventContent.EventId => typeof(TimelineHistoryVisibilityItem),
+        RoomTopicEventContent.EventId => typeof(TimelineRoomTopicItem),
+        RoomMemberEventContent.EventId => typeof(TimelineMemberItem),
+        RoomMessageEventContent.EventId => typeof(TimelineMessageItem),
+        RoomCreateEventContent.EventId => typeof(TimelineRoomCreateItem),
+        RoomNameEventContent.EventId => typeof(TimelineRoomNameItem),
+        // RoomMessageReactionEventContent.EventId => typeof(ComponentBase),
         _ => typeof(TimelineUnknownItem)
     };
+    
+    private class TimelineEventItem : ComponentBase {
+        public StateEventResponse Event { get; set; }
+        public Type Type { get; set; }
+    }
 
-}
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/InviteCounter.razor b/MatrixUtils.Web/Pages/Tools/InviteCounter.razor
new file mode 100644
index 0000000..8f4b4dd
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/InviteCounter.razor
@@ -0,0 +1,73 @@
+@page "/Tools/InviteCounter"
+@using ArcaneLibs.Extensions
+@using LibMatrix.RoomTypes
+@using System.Collections.ObjectModel
+@using LibMatrix
+@using System.Collections.Frozen
+@using LibMatrix.EventTypes.Spec.State
+@using MatrixUtils.Abstractions
+<h3>User Trace</h3>
+<hr/>
+
+<br/>
+<span>Room ID: </span>
+<InputText @bind-Value="@roomId"></InputText>
+<LinkButton OnClick="@Execute">Execute</LinkButton>
+
+<br/>
+
+<details>
+    <summary>Results</summary>
+    @foreach (var (userId, events) in invites.OrderByDescending(x=>x.Value).ToList()) {
+        <p>@userId: @events</p>
+    }
+</details>
+
+<br/>
+@foreach (var line in log.Reverse()) {
+    <pre>@line</pre>
+}
+
+@code {
+    private ObservableCollection<string> log { get; set; } = new();
+    private Dictionary<string, int> invites { get; set; } = new();
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+    
+    [Parameter, SupplyParameterFromQuery(Name = "room")]
+    public string roomId { get; set; }
+    
+
+    protected override async Task OnInitializedAsync() {
+        log.CollectionChanged += (sender, args) => StateHasChanged();
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+       
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+    private async Task<string> Execute() {
+        var room = hs.GetRoom(roomId);
+        var events = room.GetManyMessagesAsync(limit: int.MaxValue);
+        await foreach (var resp in events) {
+            var all = resp.State.Concat(resp.Chunk);
+            foreach (var evt in all) {
+                if(evt.Type != RoomMemberEventContent.EventId) continue;
+                var content = evt.TypedContent as RoomMemberEventContent;
+                if(content.Membership != "invite") continue;
+                if(!invites.ContainsKey(evt.Sender)) invites[evt.Sender] = 0;
+                invites[evt.Sender]++;
+            }
+
+            log.Add($"{resp.State.Count} state, {resp.Chunk.Count} timeline");
+        }
+        
+        
+        
+        StateHasChanged();
+
+        return "";
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor b/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor
new file mode 100644
index 0000000..cbbca9e
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor
@@ -0,0 +1,75 @@
+@page "/Tools/MassCMEBan"
+@using ArcaneLibs.Extensions
+@using LibMatrix.RoomTypes
+@using System.Collections.ObjectModel
+@using LibMatrix
+@using System.Collections.Frozen
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using MatrixUtils.Abstractions
+<h3>User Trace</h3>
+<hr/>
+
+<br/>
+<span>Users:</span>
+<InputTextArea @bind-Value="@roomId"></InputTextArea>
+<LinkButton OnClick="@Execute">Execute</LinkButton>
+
+<br/>
+
+<br/>
+@foreach (var line in log.Reverse()) {
+    <pre>@line</pre>
+}
+
+@code {
+    // TODO: Properly implement page to be more useful
+    private ObservableCollection<string> log { get; set; } = new();
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+    
+    [Parameter, SupplyParameterFromQuery(Name = "room")]
+    public string roomId { get; set; }
+    
+
+    protected override async Task OnInitializedAsync() {
+        log.CollectionChanged += (sender, args) => StateHasChanged();
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+       
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+    private async Task<string> Execute() {
+        var room = hs.GetRoom("!fTjMjIzNKEsFlUIiru:neko.dev");
+        // var room = hs.GetRoom("!yf7OpOiRDXx6zUGpT6:conduit.rory.gay");
+        var users = roomId.Split("\n").Select(x => x.Trim()).Where(x=>x.StartsWith('@')).ToList();
+        foreach (var user in users) {
+            var exists = false;
+            try {
+                exists = !string.IsNullOrWhiteSpace((await room.GetStateAsync<UserPolicyRuleEventContent>(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'))).Entity);
+            } catch (Exception e) {
+                log.Add($"Failed to get {user}");
+            }
+
+            if (!exists) {
+                var evt = await room.SendStateEventAsync(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'), new UserPolicyRuleEventContent() {
+                    Entity = user,
+                    Reason = "spam (invite)",
+                    Recommendation = "m.ban"
+                });
+                log.Add($"Sent {evt.EventId} to ban {user}");
+            }
+            else {
+                log.Add($"User {user} already exists");
+            }
+        }
+        
+        
+        StateHasChanged();
+
+        return "";
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/UserTrace.razor
index d78c58a..4ad9874 100644
--- a/MatrixUtils.Web/Pages/Tools/UserTrace.razor
+++ b/MatrixUtils.Web/Pages/Tools/UserTrace.razor
@@ -5,6 +5,7 @@
 @using LibMatrix
 @using System.Collections.Frozen
 @using LibMatrix.EventTypes.Spec.State
+@using MatrixUtils.Abstractions
 <h3>User Trace</h3>
 <hr/>
 
@@ -16,7 +17,7 @@
 <details>
     <summary>Rooms to be searched (@rooms.Count)</summary>
     @foreach (var room in rooms) {
-        <span>@room.RoomId</span>
+        <span>@room.Room.RoomId</span>
         <br/>
     }
 </details>
@@ -29,8 +30,13 @@
     @foreach (var (userId, events) in matches) {
         <h4>@userId</h4>
         <ul>
-            @foreach (var eventResponse in events) {
-                <li>@eventResponse.Room.RoomId</li>
+            @foreach (var match in events) {
+                <li>
+                    <ul>
+                        <li>@match.RoomName (<span>@match.Room.RoomId</span>)</li>
+                        <li>Membership: @(match.Event.RawContent.ToJson(indent: false))</li>
+                    </ul>
+                </li>
             }
         </ul>
     }
@@ -43,10 +49,8 @@
 
 @code {
     private ObservableCollection<string> log { get; set; } = new();
-    List<AuthenticatedHomeserverGeneric> hss { get; set; } = new();
-    ObservableCollection<GenericRoom> rooms { get; set; } = new();
-    Dictionary<GenericRoom, FrozenSet<StateEventResponse>> roomMembers { get; set; } = new();
-    Dictionary<string, List<Matches>> matches = new();
+    List<RoomInfo> rooms { get; set; } = new();
+    Dictionary<string, List<Match>> matches = new();
 
     private string UserIdString {
         get => string.Join("\n", UserIDs);
@@ -59,16 +63,13 @@
         log.CollectionChanged += (sender, args) => StateHasChanged();
         var hs = await RMUStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        rooms.CollectionChanged += (sender, args) => StateHasChanged();
         var sessions = await RMUStorage.GetAllTokens();
+        var baseRooms = new List<GenericRoom>();
         foreach (var userAuth in sessions) {
             var session = await RMUStorage.GetSession(userAuth);
             if (session is not null) {
-                var sessionRooms = await session.GetJoinedRooms();
-                foreach (var room in sessionRooms) {
-                    rooms.Add(room);
-                }
-
+                baseRooms.AddRange(await session.GetJoinedRooms());
+                var sessionRooms = (await session.GetJoinedRooms()).Where(x => !rooms.Any(y => y.Room.RoomId == x.RoomId)).ToList();
                 StateHasChanged();
                 log.Add($"Got {sessionRooms.Count} rooms for {userAuth.UserId}");
             }
@@ -76,32 +77,28 @@
 
         log.Add("Done fetching rooms!");
 
-        var distinctRooms = rooms.DistinctBy(x => x.RoomId).ToArray();
-        Random.Shared.Shuffle(distinctRooms);
-        rooms = new ObservableCollection<GenericRoom>(distinctRooms);
-        rooms.CollectionChanged += (sender, args) => StateHasChanged();
-        try {
-            var stateTasks = rooms.Select(async x => {
-                for (int i = 0; i < 10; i++) {
-                    try {
-                        return (x, await x.GetMembersListAsync(false));
-                    }
-                    catch {
-                        //
-                    }
+        baseRooms = baseRooms.DistinctBy(x => x.RoomId).ToList();
+
+        // rooms.CollectionChanged += (sender, args) => StateHasChanged();
+        var tasks = baseRooms.Select(async newRoom => {
+            bool success = false;
+            while (!success)
+                try {
+                    var state = await newRoom.GetFullStateAsListAsync();
+                    var newRoomInfo = new RoomInfo(newRoom, state);
+                    rooms.Add(newRoomInfo);
+                    log.Add($"Got {newRoomInfo.StateEvents.Count} events for {newRoomInfo.RoomName}");
+                    success = true;
                 }
-
-                return (x, new List<StateEventResponse>().ToFrozenSet());
-            }).ToAsyncEnumerable();
-
-            await foreach (var (room, state) in stateTasks) {
-                roomMembers.Add(room, state);
-                log.Add($"Got {state.Count} members for {room.RoomId}...");
-            }
-        }
-        catch {
-            //
-        }
+                catch (MatrixException e) {
+                    log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
+                    throw;
+                }
+                catch (HttpRequestException e) {
+                    log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
+                }
+        });
+        await Task.WhenAll(tasks);
 
         log.Add($"Done fetching members!");
 
@@ -114,17 +111,22 @@
 
     private async Task<string> Execute() {
         foreach (var userId in UserIDs) {
-            matches.Add(userId, new List<Matches>());
-            foreach (var (room, events) in roomMembers) {
-                if (events.Any(x => x.Type == RoomMemberEventContent.EventId && x.StateKey == userId)) {
+            matches.Add(userId, new List<Match>());
+
+            foreach (var room in rooms) {
+                var state = room.StateEvents.Where(x => x!.Type == RoomMemberEventContent.EventId).ToList();
+                if (state!.Any(x => x.StateKey == userId)) {
                     matches[userId].Add(new() {
-                        Event = events.First(x => x.StateKey == userId && x.Type == RoomMemberEventContent.EventId),
-                        Room = room,
+                        Event = state.First(x => x.StateKey == userId),
+                        Room = room.Room,
+                        RoomName = room.RoomName ?? "No name"
                     });
                 }
             }
         }
 
+        StateHasChanged();
+
         return "";
     }
 
@@ -133,8 +135,8 @@
     private async Task DoImportFromRoomId() {
         try {
             if (ImportFromRoomId is null) return;
-            var room = rooms.FirstOrDefault(x => x.RoomId == ImportFromRoomId);
-            UserIdString = string.Join("\n", (await room.GetMembersListAsync()).Select(x => x.StateKey));
+            var room = rooms.FirstOrDefault(x => x.Room.RoomId == ImportFromRoomId);
+            UserIdString = string.Join("\n", (await room.Room.GetMembersListAsync()).Select(x => x.StateKey));
         }
         catch (Exception e) {
             Console.WriteLine(e);
@@ -144,11 +146,10 @@
         StateHasChanged();
     }
 
-    private class Matches {
+    private class Match {
         public GenericRoom Room;
-
         public StateEventResponse Event;
-        // public 
+        public string RoomName { get; set; }
     }
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor b/MatrixUtils.Web/Shared/MainLayout.razor
index 41c3d69..c67f73c 100644
--- a/MatrixUtils.Web/Shared/MainLayout.razor
+++ b/MatrixUtils.Web/Shared/MainLayout.razor
@@ -1,4 +1,5 @@
-@inherits LayoutComponentBase
+@using ArcaneLibs
+@inherits LayoutComponentBase
 
 <div class="page">
     <div class="sidebar">
@@ -7,7 +8,8 @@
 
     <main>
         <div class="top-row px-4">
-            <PortableDevTools></PortableDevTools>
+            @* <PortableDevTools/> *@
+            @* <ResourceUsage/> *@
             <a style="color: #ccc; text-decoration: underline" href="https://cgit.rory.gay/matrix/MatrixRoomUtils.git/" target="_blank">Git</a>
             <a style="color: #ccc; text-decoration: underline" href="https://matrix.to/#/%23mru%3Arory.gay?via=rory.gay&via=matrix.org&via=feline.support" target="_blank">Matrix</a>
         </div>
@@ -15,6 +17,8 @@
         <article class="Content px-4">
             @Body
         </article>
+
+
     </main>
 </div>
 
diff --git a/MatrixUtils.Web/Shared/ResourceUsage.razor b/MatrixUtils.Web/Shared/ResourceUsage.razor
new file mode 100644
index 0000000..2a6365f
--- /dev/null
+++ b/MatrixUtils.Web/Shared/ResourceUsage.razor
@@ -0,0 +1,64 @@
+@using ArcaneLibs
+@using System.Diagnostics
+<h3>ResourceUsage</h3>
+<ModalWindow Title="Resource usage">
+    <div style="background-color: white; color: black;">
+        <span>Memory usage: @lastMemoryUsage</span>
+        <br/>
+        <TimelineGraph Data="MemoryUsage" ValueFormatter="@((double val) => Util.BytesToString((long)val))" Width="400"></TimelineGraph>
+    </div>
+    
+    <div style="background-color: white; color: black;">
+        <span>Time jitter: @lastCpuJitter</span>
+        <br/>
+        <TimelineGraph Data="CpuUsage" ValueFormatter="@(val => TimeSpan.FromTicks((long)val).ToString())" Width="400"></TimelineGraph>
+    </div>
+</ModalWindow>
+
+@code {
+    private Dictionary<DateTime, double> MemoryUsage = new();
+    private Dictionary<DateTime, double> CpuUsage = new();
+    private string lastMemoryUsage = "";
+    private string lastCpuJitter = "";
+
+    protected override async Task OnInitializedAsync() {
+        Task.Run(async () => {
+            try {
+                while (true) {
+                    lastMemoryUsage = Util.BytesToString((long)(MemoryUsage[DateTime.Now] = GC.GetTotalMemory(false)));
+                    if (MemoryUsage.Count > 60)
+                        MemoryUsage.Remove(MemoryUsage.Keys.First());
+                    await Task.Delay(1000);
+                }
+            }
+            catch (Exception e) {
+                Console.WriteLine(e);
+            }
+        });
+        
+        // calculate cpu usage estimate without Process or PerformanceCounter
+        Task.Run(async () => {
+            try {
+                var sw = new Stopwatch();
+                while (true) {
+                    sw.Restart();
+                    await Task.Delay(1000);
+                    sw.Stop();
+                    // CpuUsage[DateTime.Now] = sw.ElapsedTicks - TimeSpan.TicksPerSecond;
+                    var usage = sw.Elapsed - TimeSpan.FromSeconds(1);
+                    CpuUsage[DateTime.Now] = usage.Ticks - TimeSpan.TicksPerSecond;
+                    lastCpuJitter = usage.ToString();
+                    if (CpuUsage.Count > 60)
+                        CpuUsage.Remove(MemoryUsage.Keys.First());
+                    StateHasChanged();
+                }
+            }
+            catch (Exception e) {
+                Console.WriteLine(e);
+            }
+        });
+
+        await base.OnInitializedAsync();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomListItem.razor b/MatrixUtils.Web/Shared/RoomListItem.razor
index 623a03a..5a33b65 100644
--- a/MatrixUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixUtils.Web/Shared/RoomListItem.razor
@@ -11,7 +11,7 @@
     <div class="roomListItem @(HasDangerousRoomVersion ? "dangerousRoomVersion" : HasOldRoomVersion ? "oldRoomVersion" : "")" id="@RoomInfo.Room.RoomId">
         @if (OwnMemberState != null) {
             @* Class="@("avatar32" + (OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? " highlightChange" : "") + (ChildContent is not null ? " vcenter" : ""))" *@
-            <MxcImage Circular="true" Height="32" Width="32" MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/>
+            <MxcImage Homeserver="hs" Circular="true" Height="32" Width="32" MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/>
             <span class="centerVertical border75 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "")">
                 @(OwnMemberState?.DisplayName ?? GlobalProfile?.DisplayName ?? "Loading...")
             </span>
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
index c7bfd51..08aeffe 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
@@ -14,9 +14,9 @@
     [Parameter]
     public AuthenticatedHomeserverGeneric Homeserver { get; set; }
 
-    public List<StateEventResponse> EventsBefore => Events.TakeWhile(e => e.EventId != Event.EventId).ToList();
+    public IEnumerable<StateEventResponse> EventsBefore => Events.TakeWhile(e => e.EventId != Event.EventId);
 
-    public List<StateEventResponse> MatchingEventsBefore => EventsBefore.Where(x => x.Type == Event.Type && x.StateKey == Event.StateKey).ToList();
+    public IEnumerable<StateEventResponse> MatchingEventsBefore => EventsBefore.Where(x => x.Type == Event.Type && x.StateKey == Event.StateKey);
 
     public StateEventResponse? PreviousState => MatchingEventsBefore.LastOrDefault();
 
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownStateItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownStateItem.razor
new file mode 100644
index 0000000..4f05b30
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownStateItem.razor
@@ -0,0 +1,16 @@
+@using ArcaneLibs.Extensions
+@inherits BaseTimelineItem
+
+<div>
+    <details style="display: inline;">
+        <summary>
+            <i style="color: red;">Unknown event type: <pre style="display: inline;">@Event.Type</pre></i>
+        </summary>
+        <pre>@Event.ToJson(ignoreNull: true)</pre>
+    </details>
+</div>
+
+@code {
+
+
+}
diff --git a/MxApiExtensions b/MxApiExtensions
-Subproject 3c0c7b2e56a24bda06b8c567e5608546898c99d
+Subproject 2d21596eec41cf1669f8064278b3273dc4f4c36