about summary refs log tree commit diff
path: root/MatrixUtils.Web
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Web')
-rw-r--r--MatrixUtils.Web/MatrixUtils.Web.csproj4
-rw-r--r--MatrixUtils.Web/Pages/About.razor4
-rw-r--r--MatrixUtils.Web/Pages/Index.razor38
-rw-r--r--MatrixUtils.Web/Pages/LoginPage.razor44
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Create.razor2
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList.razor97
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css9
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList2.razor213
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css32
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyLists.razor177
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css6
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Space.razor52
-rw-r--r--MatrixUtils.Web/Pages/StreamTest.razor119
-rw-r--r--MatrixUtils.Web/Pages/Tools/Index.razor2
-rw-r--r--MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor29
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor143
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor (renamed from MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor)41
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor139
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor51
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor129
-rw-r--r--MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor2
-rw-r--r--MatrixUtils.Web/Pages/User/Profile.razor89
-rw-r--r--MatrixUtils.Web/Shared/InlineUserItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor.css1
-rw-r--r--MatrixUtils.Web/Shared/MxcAvatar.razor58
-rw-r--r--MatrixUtils.Web/Shared/MxcImage.razor28
-rw-r--r--MatrixUtils.Web/Shared/NavMenu.razor6
-rw-r--r--MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor102
-rw-r--r--MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor60
-rw-r--r--MatrixUtils.Web/Shared/RoomListItem.razor37
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor2
-rw-r--r--MatrixUtils.Web/wwwroot/index.html16
32 files changed, 1453 insertions, 281 deletions
diff --git a/MatrixUtils.Web/MatrixUtils.Web.csproj b/MatrixUtils.Web/MatrixUtils.Web.csproj
index 8760e7a..eeb13ee 100644
--- a/MatrixUtils.Web/MatrixUtils.Web.csproj
+++ b/MatrixUtils.Web/MatrixUtils.Web.csproj
@@ -13,6 +13,10 @@
         <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
         <BlazorCacheBootResources>false</BlazorCacheBootResources>
 <!--        <RunAOTCompilation>true</RunAOTCompilation>-->
+
+
+        <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
+        <InvariantGlobalization>true</InvariantGlobalization>
     </PropertyGroup>
 
     <ItemGroup>
diff --git a/MatrixUtils.Web/Pages/About.razor b/MatrixUtils.Web/Pages/About.razor
index 18d7c3f..9f83991 100644
--- a/MatrixUtils.Web/Pages/About.razor
+++ b/MatrixUtils.Web/Pages/About.razor
@@ -7,6 +7,6 @@
 <p>Rory&::MatrixUtils is a "small" collection of tools to do not-so-everyday things.</p>
 <p>These range from joining rooms on dead homeservers, to managing your accounts and rooms, and creating rooms based on templates.</p>
 
-<br/><br/>
-<p>You can find the source code on <a href="https://cgit.rory.gay/matrix/MatrixRoomUtils.git/">cgit.rory.gay</a>.<br/></p>
+<br/>
+<p>You can find the source code on <a href="https://cgit.rory.gay/matrix/tools/MatrixUtils.git/about/">cgit.rory.gay</a>.<br/></p>
 <p>You can also join the <a href="https://matrix.to/#/%23mru%3Arory.gay?via=rory.gay&via=matrix.org&via=feline.support">Matrix room</a> for this project.</p>
diff --git a/MatrixUtils.Web/Pages/Index.razor b/MatrixUtils.Web/Pages/Index.razor
index a7619ae..4be91b7 100644
--- a/MatrixUtils.Web/Pages/Index.razor
+++ b/MatrixUtils.Web/Pages/Index.razor
@@ -22,20 +22,26 @@ Small collection of tools to do not-so-everyday things.
 <form>
     <table>
         @foreach (var session in _sessions.OrderByDescending(x => x.UserInfo.RoomCount)) {
-            var _auth = session.UserAuth;
+            var auth = session.UserAuth;
             <tr class="user-entry">
                 <td>
-                    <img class="avatar" src="@session.UserInfo.AvatarUrl" crossorigin="anonymous"/>
+                    @if (!string.IsNullOrWhiteSpace(@session.UserInfo.AvatarUrl)) {
+                        <MxcAvatar Homeserver="session.Homeserver" MxcUri="@session.UserInfo.AvatarUrl" Circular="true" Size="4" SizeUnit="em"/>
+                    }
+                    else {
+                        <img class="avatar" src="@_identiconGenerator.GenerateAsDataUri(session.Homeserver.WhoAmI.UserId)"/>
+                    }
+                    @* <img class="avatar" src="@session.UserInfo.AvatarUrl" crossorigin="anonymous"/> *@
                 </td>
                 <td class="user-info">
                     <p>
-                        <input type="radio" name="csa" checked="@(_currentSession.AccessToken == _auth.AccessToken)" @onclick="@(() => SwitchSession(_auth))" style="text-decoration-line: unset;"/>
-                        <b>@session.UserInfo.DisplayName</b> on <b>@_auth.Homeserver</b><br/>
+                        <input type="radio" name="csa" checked="@(_currentSession.AccessToken == auth.AccessToken)" @onclick="@(() => SwitchSession(auth))" style="text-decoration-line: unset;"/>
+                        <b>@session.UserInfo.DisplayName</b> on <b>@auth.Homeserver</b><br/>
                     </p>
                     <span style="display: inline-block; width: 128px;">@session.UserInfo.RoomCount rooms</span>
                     <a style="color: #888888" href="@("/ServerInfo/" + session.Homeserver?.ServerName + "/")">@session.ServerVersion?.Server.Name @session.ServerVersion?.Server.Version</a>
-                    @if (_auth.Proxy != null) {
-                        <span class="badge badge-info"> (proxied via @_auth.Proxy)</span>
+                    @if (auth.Proxy != null) {
+                        <span class="badge badge-info"> (proxied via @auth.Proxy)</span>
                     }
                     else {
                         <p>Not proxied</p>
@@ -48,9 +54,9 @@ Small collection of tools to do not-so-everyday things.
                 </td>
                 <td>
                     <p>
-                        <LinkButton OnClick="@(() => ManageUser(_auth))">Manage</LinkButton>
-                        <LinkButton OnClick="@(() => RemoveUser(_auth))">Remove</LinkButton>
-                        <LinkButton OnClick="@(() => RemoveUser(_auth, true))">Log out</LinkButton>
+                        <LinkButton OnClick="@(() => ManageUser(auth))">Manage</LinkButton>
+                        <LinkButton OnClick="@(() => RemoveUser(auth))">Remove</LinkButton>
+                        <LinkButton OnClick="@(() => RemoveUser(auth, true))">Log out</LinkButton>
                     </p>
                 </td>
             </tr>
@@ -162,17 +168,11 @@ Small collection of tools to do not-so-everyday things.
         }
 
         List<string> offlineServers = [];
-        var sema = new SemaphoreSlim(64, 64);
+        var sema = new SemaphoreSlim(8, 8);
         var updateSw = Stopwatch.StartNew();
         var tasks = tokens.Select(async token => {
             await sema.WaitAsync();
-            if ((!string.IsNullOrWhiteSpace(token.Proxy) && offlineServers.Contains(token.Proxy)) || offlineServers.Contains(token.Homeserver)) {
-                _offlineSessions.Add(token);
-                sema.Release();
-                scannedSessions++;
-                return;
-            }
-
+            
             AuthenticatedHomeserverGeneric hs;
             try {
                 hs = await hsProvider.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy);
@@ -181,7 +181,7 @@ Small collection of tools to do not-so-everyday things.
                 var serverVersionTask = hs.FederationClient?.GetServerVersionAsync();
                 _sessions.Add(new() {
                     UserInfo = new() {
-                        AvatarUrl = string.IsNullOrWhiteSpace((await profileTask).AvatarUrl) ? _identiconGenerator.GenerateAsDataUri(hs.WhoAmI.UserId) : hs.ResolveMediaUri((await profileTask).AvatarUrl),
+                        AvatarUrl = (await profileTask).AvatarUrl,
                         RoomCount = (await joinedRoomsTask).Count,
                         DisplayName = (await profileTask).DisplayName ?? hs.WhoAmI.UserId
                     },
@@ -226,7 +226,7 @@ Small collection of tools to do not-so-everyday things.
     }
 
     private class UserInfo {
-        internal string AvatarUrl { get; set; }
+        internal string? AvatarUrl { get; set; }
         internal string DisplayName { get; set; }
         internal int RoomCount { get; set; }
     }
diff --git a/MatrixUtils.Web/Pages/LoginPage.razor b/MatrixUtils.Web/Pages/LoginPage.razor
index 6c869ac..d43913c 100644
--- a/MatrixUtils.Web/Pages/LoginPage.razor
+++ b/MatrixUtils.Web/Pages/LoginPage.razor
@@ -27,6 +27,27 @@
 <br/>
 <br/>
 
+
+<h4>Add with access token</h4>
+<hr/>
+
+<span style="display: block;">
+    <label>Homeserver:</label>
+    <FancyTextBox @bind-Value="@newRecordInput.Homeserver"></FancyTextBox>
+</span>
+<span style="display: block;">
+    <label>Access token:</label>
+    <FancyTextBox @bind-Value="@newRecordInput.Password" IsPassword="true"></FancyTextBox>
+</span>
+<span style="display: block">
+    <label>Proxy (<a href="https://cgit.rory.gay/matrix/MxApiExtensions.git">MxApiExtensions</a> or similar):</label>
+    <FancyTextBox @bind-Value="@newRecordInput.Proxy"></FancyTextBox>
+</span>
+<br/>
+<LinkButton OnClick="@(() => AddWithAccessToken(newRecordInput))">Add session</LinkButton>
+<br/>
+<br/>
+
 <h4>Import from TSV</h4>
 <hr/>
 <span>Import credentials from a TSV (Tab Separated Values) file</span><br/>
@@ -156,4 +177,27 @@
         internal Exception? Exception { get; set; }
     }
 
+    private async Task AddWithAccessToken(LoginStruct record) {
+        try {
+            var session = await hsProvider.GetAuthenticatedWithToken(record.Homeserver, record.Password, record.Proxy);
+            if (session == null) {
+                Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
+                return;
+            }
+            
+            await RMUStorage.AddToken(new UserAuth() {
+                UserId = session.WhoAmI.UserId,
+                AccessToken = session.AccessToken,
+                Proxy = record.Proxy,
+                DeviceId = session.WhoAmI.DeviceId
+            });
+            LoggedInSessions = await RMUStorage.GetAllTokens();
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
+            Console.WriteLine(e);
+            record.Exception = e;
+        }
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Create.razor b/MatrixUtils.Web/Pages/Rooms/Create.razor
index f2dfb01..3527bf5 100644
--- a/MatrixUtils.Web/Pages/Rooms/Create.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Create.razor
@@ -89,7 +89,7 @@
         <tr>
             <td>Room icon:</td>
             <td>
-                <img src="@Homeserver.ResolveMediaUri(roomAvatarEvent.Url)" style="width: 128px; height: 128px; border-radius: 50%;"/>
+                @* <img src="@Homeserver.ResolveMediaUri(roomAvatarEvent.Url)" style="width: 128px; height: 128px; border-radius: 50%;"/> *@
                 <div style="display: inline-block; vertical-align: middle;">
                     <FancyTextBox @bind-Value="@roomAvatarEvent.Url"></FancyTextBox><br/>
                     <InputFile OnChange="RoomIconFilePicked"></InputFile>
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
index b7ebae2..d57aa43 100644
--- a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
@@ -16,6 +16,7 @@
 <hr/>
 @* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
 <LinkButton OnClick="@(() => { CurrentlyEditingEvent = new() { Type = "", RawContent = new() }; return Task.CompletedTask; })">Create new policy</LinkButton>
+<LinkButton OnClick="@(() => { MassCreatePolicies = true; return Task.CompletedTask; })">Create many new policies</LinkButton>
 
 @if (Loading) {
     <p>Loading...</p>
@@ -24,6 +25,8 @@ else if (PolicyEventsByType is not { Count: > 0 }) {
     <p>No policies yet</p>
 }
 else {
+    var renderSw = Stopwatch.StartNew();
+    var renderTotalSw = Stopwatch.StartNew();
     @foreach (var (type, value) in PolicyEventsByType) {
         <p>
             @(GetValidPolicyEventsByType(type).Count) active,
@@ -33,6 +36,8 @@ else {
         </p>
     }
 
+    Console.WriteLine($"Rendered hearder in {renderSw.GetElapsedAndRestart()}");
+
     @foreach (var type in KnownPolicyTypes.OrderByDescending(t => GetPolicyEventsByType(t).Count)) {
         <details>
             <summary>
@@ -41,7 +46,7 @@ else {
                 </span>
                 <hr style="margin: revert;"/>
             </summary>
-            <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+            <table class="table table-striped table-hover">
                 @{
                     var policies = GetValidPolicyEventsByType(type);
                     var invalidPolicies = GetInvalidPolicyEventsByType(type);
@@ -51,13 +56,18 @@ else {
                         .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
                         .ToFrozenSet();
                     var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+                    
+                    var proxySafeProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => props.Any(y => y.Name == x.Name))
+                        .ToFrozenSet(); 
+                    Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
                 }
                 <thead>
                     <tr>
                         @foreach (var name in propNames) {
-                            <th style="border-width: 1px">@name</th>
+                            <th>@name</th>
                         }
-                        <th style="border-width: 1px">Actions</th>
+                        <th>Actions</th>
                     </tr>
                 </thead>
                 <tbody style="border-width: 1px;">
@@ -65,10 +75,6 @@ else {
                         <tr>
                             @{
                                 var typedContent = policy.TypedContent!;
-                                var proxySafeProps = typedContent.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
-                                    .Where(x => props.Any(y => y.Name == x.Name))
-                                    .ToFrozenSet();
-                                Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
                             }
                             @foreach (var prop in proxySafeProps ?? Enumerable.Empty<PropertyInfo>()) {
                                 <td>@prop.GetGetMethod()?.Invoke(typedContent, null)</td>
@@ -94,11 +100,11 @@ else {
                         @("Invalid " + GetPolicyTypeName(type).ToLower())
                     </u>
                 </summary>
-                <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+                <table class="table table-striped table-hover">
                     <thead>
                         <tr>
-                            <th style="border-width: 1px">State key</th>
-                            <th style="border-width: 1px">Json contents</th>
+                            <th>State key</th>
+                            <th>Json contents</th>
                         </tr>
                     </thead>
                     <tbody>
@@ -115,12 +121,19 @@ else {
             </details>
         </details>
     }
+
+    Console.WriteLine($"Rendered policies in {renderSw.GetElapsedAndRestart()}");
+    Console.WriteLine($"Rendered in {renderTotalSw.Elapsed}");
 }
 
 @if (CurrentlyEditingEvent is not null) {
     <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSave="@(e => UpdatePolicyAsync(e))"></PolicyEditorModal>
 }
 
+@if (MassCreatePolicies) {
+    <MassPolicyEditorModal Room="@Room" OnClose="@(() => MassCreatePolicies = false)" OnSaved="@(() => { MassCreatePolicies = false; LoadStatesAsync(); })"></MassPolicyEditorModal>
+}
+
 @code {
 
 #if DEBUG
@@ -130,21 +143,14 @@ else {
 #endif
 
     private bool Loading { get; set; } = true;
-    //get room list
-    // - sync withroom list filter
-    // Type = support.feline.msc3784
-    //support.feline.policy.lists.msc.v1
 
     [Parameter]
     public string RoomId { get; set; } = null!;
 
     private bool _enableAvatars;
     private StateEventResponse? _currentlyEditingEvent;
+    private bool _massCreatePolicies;
 
-    // static readonly Dictionary<string, string?> Avatars = new();
-    // static readonly Dictionary<string, RemoteHomeserver> Servers = new();
-
-    // private static List<StateEventResponse> PolicyEvents { get; set; } = new();
     private Dictionary<Type, List<StateEventResponse>> PolicyEventsByType { get; set; } = new();
 
     private StateEventResponse? CurrentlyEditingEvent {
@@ -155,18 +161,18 @@ else {
         }
     }
 
-    // public bool EnableAvatars {
-    //     get => _enableAvatars;
-    //     set {
-    //         _enableAvatars = value;
-    //         if (value) GetAllAvatars();
-    //     }
-    // }
-
     private AuthenticatedHomeserverGeneric Homeserver { get; set; }
     private GenericRoom Room { get; set; }
     private RoomPowerLevelEventContent PowerLevels { get; set; }
 
+    public bool MassCreatePolicies {
+        get => _massCreatePolicies;
+        set {
+            _massCreatePolicies = value;
+            StateHasChanged();
+        }
+    }
+
     protected override async Task OnInitializedAsync() {
         var sw = Stopwatch.StartNew();
         await base.OnInitializedAsync();
@@ -193,48 +199,13 @@ else {
         StateHasChanged();
     }
 
-    // private async Task GetAllAvatars() {
-    //     // if (!_enableAvatars) return;
-    //     Console.WriteLine("Getting avatars...");
-    //     var users = GetValidPolicyEventsByType(typeof(UserPolicyRuleEventContent)).Select(x => x.RawContent!["entity"]!.GetValue<string>()).Where(x => x.Contains(':') && !x.Contains("*")).ToList();
-    //     Console.WriteLine($"Got {users.Count} users!");
-    //     var usersByHomeServer = users.GroupBy(x => x!.Split(':')[1]).ToDictionary(x => x.Key!, x => x.ToList());
-    //     Console.WriteLine($"Got {usersByHomeServer.Count} homeservers!");
-    //     var homeserverTasks = usersByHomeServer.Keys.Select(x => RemoteHomeserver.TryCreate(x)).ToAsyncEnumerable();
-    //     await foreach (var server in homeserverTasks) {
-    //         if (server is null) continue;
-    //         var profileTasks = usersByHomeServer[server.BaseUrl].Select(x => TryGetProfile(server, x)).ToList();
-    //         await Task.WhenAll(profileTasks);
-    //         profileTasks.RemoveAll(x => x.Result is not { Value: { AvatarUrl: not null } });
-    //         foreach (var profile in profileTasks.Select(x => x.Result!.Value)) {
-    //             // if (profile is null) continue;
-    //             if (!string.IsNullOrWhiteSpace(profile.Value.AvatarUrl)) {
-    //                 var url = await hsResolver.ResolveMediaUri(server.BaseUrl, profile.Value.AvatarUrl);
-    //                 Avatars.TryAdd(profile.Key, url);
-    //             }
-    //             else Avatars.TryAdd(profile.Key, null);
-    //         }
-    //
-    //         StateHasChanged();
-    //     }
-    // }
-    //
-    // private async Task<KeyValuePair<string, UserProfileResponse>?> TryGetProfile(RemoteHomeserver server, string mxid) {
-    //     try {
-    //         return new KeyValuePair<string, UserProfileResponse>(mxid, await server.GetProfileAsync(mxid));
-    //     }
-    //     catch {
-    //         return null;
-    //     }
-    // }
-
     private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
 
     private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
-        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
 
     private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
-        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
 
     private string? GetPolicyTypeNameOrNull(Type type) => type.GetFriendlyNamePluralOrNull()
                                                           ?? type.GetCustomAttributes<MatrixEventAttribute>()
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css
new file mode 100644
index 0000000..afe9fb0
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css
@@ -0,0 +1,9 @@
+th {
+    border-width: 1px;
+}
+
+table {
+    width: fit-content;
+    border-width: 1px;
+    vertical-align: middle;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor
new file mode 100644
index 0000000..adac385
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor
@@ -0,0 +1,213 @@
+@page "/Rooms/{RoomId}/Policies2"
+@using LibMatrix
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Diagnostics
+@using LibMatrix.RoomTypes
+@using System.Collections.Frozen
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix.EventTypes
+
+@using MatrixUtils.Web.Shared.PolicyEditorComponents
+
+<h3>Policy list editor - Editing @RoomId</h3>
+<hr/>
+@* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
+<LinkButton OnClick="@(() => { CurrentlyEditingEvent = new() { Type = "", RawContent = new() }; return Task.CompletedTask; })">Create new policy</LinkButton>
+
+@if (Loading) {
+    <p>Loading...</p>
+}
+else if (PolicyEventsByType is not { Count: > 0 }) {
+    <p>No policies yet</p>
+}
+else {
+    var renderSw = Stopwatch.StartNew();
+    var renderTotalSw = Stopwatch.StartNew();
+    @foreach (var (type, value) in PolicyEventsByType) {
+        <p>
+            @(GetValidPolicyEventsByType(type).Count) active,
+            @(GetInvalidPolicyEventsByType(type).Count) invalid
+            (@value.Count total)
+            @(GetPolicyTypeName(type).ToLower())
+        </p>
+    }
+
+    Console.WriteLine($"Rendered hearder in {renderSw.GetElapsedAndRestart()}");
+
+    @foreach (var type in KnownPolicyTypes.OrderByDescending(t => GetPolicyEventsByType(t).Count)) {
+        <details>
+            <summary>
+                <span>
+                    @($"{GetPolicyTypeName(type)}: {GetPolicyEventsByType(type).Count} policies")
+                </span>
+                <hr style="margin: revert;"/>
+            </summary>
+            <div class="flex-grid">
+                @{
+                    var policies = GetValidPolicyEventsByType(type);
+                    var invalidPolicies = GetInvalidPolicyEventsByType(type);
+                    // enumerate all properties with friendly name
+                    var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => (x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyNameOrNull()) is not null)
+                        .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
+                        .ToFrozenSet();
+                    var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+
+                    var proxySafeProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => props.Any(y => y.Name == x.Name))
+                        .ToFrozenSet();
+                    Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
+                }
+                @foreach (var policy in policies.OrderBy(x => x.RawContent?["entity"]?.GetValue<string>())) {
+                    <div class="flex-item">
+                        @{
+                            var typedContent = policy.TypedContent!;
+                        }
+                        @foreach (var prop in proxySafeProps ?? Enumerable.Empty<PropertyInfo>()) {
+                            <td>@prop.GetGetMethod()?.Invoke(typedContent, null)</td>
+                        }
+                        <div style="display: ruby;">
+                            @if (PowerLevels.UserHasStatePermission(Homeserver.WhoAmI.UserId, policy.Type)) {
+                                <LinkButton OnClick="@(() => { CurrentlyEditingEvent = policy; return Task.CompletedTask; })">Edit</LinkButton>
+                                <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Remove</LinkButton>
+                                @if (policy.IsLegacyType) {
+                                    <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Update policy type</LinkButton>
+                                }
+                            }
+                        </div>
+                    </div>
+                }
+            </div>
+            <details>
+                <summary>
+                    <u>
+                        @("Invalid " + GetPolicyTypeName(type).ToLower())
+                    </u>
+                </summary>
+                <table class="table table-striped table-hover">
+                    <thead>
+                        <tr>
+                            <th>State key</th>
+                            <th>Json contents</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        @foreach (var policy in invalidPolicies) {
+                            <tr>
+                                <td>@policy.StateKey</td>
+                                <td>
+                                    <pre>@policy.RawContent.ToJson(true, false)</pre>
+                                </td>
+                            </tr>
+                        }
+                    </tbody>
+                </table>
+            </details>
+        </details>
+    }
+
+    Console.WriteLine($"Rendered policies in {renderSw.GetElapsedAndRestart()}");
+    Console.WriteLine($"Rendered in {renderTotalSw.Elapsed}");
+}
+
+@if (CurrentlyEditingEvent is not null) {
+    <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSave="@(e => UpdatePolicyAsync(e))"></PolicyEditorModal>
+}
+
+@code {
+
+#if DEBUG
+    private const bool Debug = true;
+#else
+    private const bool Debug = false;
+#endif
+
+    private bool Loading { get; set; } = true;
+
+    [Parameter]
+    public string RoomId { get; set; } = null!;
+
+    private bool _enableAvatars;
+    private StateEventResponse? _currentlyEditingEvent;
+
+    private Dictionary<Type, List<StateEventResponse>> PolicyEventsByType { get; set; } = new();
+
+    private StateEventResponse? CurrentlyEditingEvent {
+        get => _currentlyEditingEvent;
+        set {
+            _currentlyEditingEvent = value;
+            StateHasChanged();
+        }
+    }
+
+    private AuthenticatedHomeserverGeneric Homeserver { get; set; }
+    private GenericRoom Room { get; set; }
+    private RoomPowerLevelEventContent PowerLevels { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        var sw = Stopwatch.StartNew();
+        await base.OnInitializedAsync();
+        Homeserver = (await RMUStorage.GetCurrentSessionOrNavigate())!;
+        if (Homeserver is null) return;
+        Room = Homeserver.GetRoom(RoomId!);
+        PowerLevels = (await Room.GetPowerLevelsAsync())!;
+        await LoadStatesAsync();
+        Console.WriteLine($"Policy list editor initialized in {sw.Elapsed}!");
+    }
+
+    private async Task LoadStatesAsync() {
+        Loading = true;
+        var states = Room.GetFullStateAsync();
+        PolicyEventsByType.Clear();
+        await foreach (var state in states) {
+            if (state is null) continue;
+            if (!state.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) continue;
+            if (!PolicyEventsByType.ContainsKey(state.MappedType)) PolicyEventsByType.Add(state.MappedType, new());
+            PolicyEventsByType[state.MappedType].Add(state);
+        }
+
+        Loading = false;
+        StateHasChanged();
+    }
+
+    private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
+
+    private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
+
+    private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
+
+    private string? GetPolicyTypeNameOrNull(Type type) => type.GetFriendlyNamePluralOrNull()
+                                                          ?? type.GetCustomAttributes<MatrixEventAttribute>()
+                                                              .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.EventName))?.EventName;
+
+    private string GetPolicyTypeName(Type type) => GetPolicyTypeNameOrNull(type) ?? type.Name;
+
+    private async Task RemovePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey.UrlEncode(), new { });
+        PolicyEventsByType[policyEvent.MappedType].Remove(policyEvent);
+        await LoadStatesAsync();
+    }
+
+    private async Task UpdatePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey.UrlEncode(), policyEvent.RawContent);
+        CurrentlyEditingEvent = null;
+        await LoadStatesAsync();
+    }
+
+    private async Task UpgradePolicyAsync(StateEventResponse policyEvent) {
+        policyEvent.RawContent["upgraded_from_type"] = policyEvent.Type;
+        await LoadStatesAsync();
+    }
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    // event types, unnamed
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css
new file mode 100644
index 0000000..d224737
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css
@@ -0,0 +1,32 @@
+th {
+    border-width: 1px;
+}
+
+table {
+    width: fit-content;
+    border-width: 1px;
+    vertical-align: middle;
+}
+
+.flex-grid {
+    display: grid;
+    /*grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));*/
+    /*// fit based on content max width*/
+    grid-template-columns: repeat(auto-fill, minmax(min-content, 1fr));
+    
+    gap: 10px;
+}
+
+.flex-item {
+    /*flex: 1 1 30%;*/
+    /*margin: 0.25rem;*/
+    /*position: relative;*/
+    /*display: flex;*/
+    /*flex-direction: column;*/
+    min-width: 0;
+    word-wrap: break-word;
+    background-color: #fff1;
+    background-clip: border-box;
+    border: 1px solid rgba(0, 0, 0, .125);
+    border-radius: .5rem
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
new file mode 100644
index 0000000..63dc206
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
@@ -0,0 +1,177 @@
+@page "/PolicyLists"
+@using System.Collections.ObjectModel
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes
+@using LibMatrix.EventTypes.Common
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using LibMatrix.RoomTypes
+@inject ILogger<Index> logger
+<h3>Policy lists </h3> @* <LinkButton href="/Rooms/Create">Create new policy list</LinkButton> *@
+
+@if (!string.IsNullOrWhiteSpace(Status)) {
+    <p>@Status</p>
+}
+@if (!string.IsNullOrWhiteSpace(Status2)) {
+    <p>@Status2</p>
+}
+<hr/>
+
+<table>
+    <thead>
+        <tr>
+            <th/>
+            <th>Room name</th>
+            <th>Policies</th>
+        </tr>
+    </thead>
+    <tbody>
+        @foreach (var room in Rooms.OrderByDescending(x => x.PolicyCounts.Sum(y => y.Value))) {
+            <tr>
+                <td>
+                    <LinkButton href="@($"/Rooms/{room.Room.RoomId}/Policies")">
+                        <span class="oi oi-pencil" aria-hidden="true"></span>
+                    </LinkButton>
+                </td>
+                <td style="padding-right: 24px;">
+                    <span>@room.RoomName</span>
+                    @if (room.IsLegacy) {
+                        <span style="color: red;"> (legacy)</span>
+                    }
+                    <br/>
+                    @if (!string.IsNullOrWhiteSpace(room.Shortcode)) {
+                        <span style="font-size: 0.8em;">@room.Shortcode</span>
+                    }
+                    else {
+                        <span style="color: red;">(no shortcode)</span>
+                    }
+                </td>
+                <td>
+                    <span>@(room.PolicyCounts.GetValueOrDefault(RoomInfo.PolicyType.User) ?? 0) user policies</span><br/>
+                    <span>@(room.PolicyCounts.GetValueOrDefault(RoomInfo.PolicyType.Server) ?? 0) server policies</span><br/>
+                    <span>@(room.PolicyCounts.GetValueOrDefault(RoomInfo.PolicyType.Room) ?? 0) room policies</span><br/>
+                </td>
+            </tr>
+        }
+    </tbody>
+</table>
+
+@code {
+
+    private List<RoomInfo> Rooms { get; } = [];
+
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+
+        Status = "Fetching rooms...";
+
+        var userEventTypes = EventContent.GetMatchingEventTypes<UserPolicyRuleEventContent>();
+        var serverEventTypes = EventContent.GetMatchingEventTypes<ServerPolicyRuleEventContent>();
+        var roomEventTypes = EventContent.GetMatchingEventTypes<RoomPolicyRuleEventContent>();
+        var knownPolicyTypes = (List<string>) [..userEventTypes, ..serverEventTypes, ..roomEventTypes];
+
+        List<GenericRoom> roomsByType = [];
+        await foreach (var room in Homeserver.GetJoinedRoomsByType("support.feline.policy.lists.msc.v1")) {
+            roomsByType.Add(room);
+            Status2 = $"Found {room.RoomId} (MSC3784)...";
+        }
+
+        List<Task<RoomInfo>> tasks = roomsByType.Select(async room => {
+            Status2 = $"Fetching room {room.RoomId}...";
+            return await RoomInfo.FromRoom(room);
+        }).ToList();
+
+        var results = tasks.ToAsyncEnumerable();
+        await foreach (var result in results) {
+            Rooms.Add(result);
+            StateHasChanged();
+        }
+
+        Status = "Searching for legacy lists...";
+
+        var rooms = (await Homeserver.GetJoinedRooms())
+            .Where(x => !Rooms.Any(y => y.Room.RoomId == x.RoomId))
+            .Select(async room => {
+                var state = await room.GetFullStateAsListAsync();
+                var policies = state
+                    .Where(x => knownPolicyTypes.Contains(x.Type))
+                    .ToList();
+                if (policies.Count == 0) return null;
+                Status2 = $"Found legacy list {room.RoomId}...";
+                return await RoomInfo.FromRoom(room, state, true);
+            })
+            .ToAsyncEnumerable();
+
+        await foreach (var room in rooms) {
+            if (room is not null) {
+                Rooms.Add(room);
+                StateHasChanged();
+            }
+        }
+
+        Status = "";
+        Status2 = "";
+        await base.OnInitializedAsync();
+    }
+
+    private string _status;
+
+    public string Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private string _status2;
+
+    public string Status2 {
+        get => _status2;
+        set {
+            _status2 = value;
+            StateHasChanged();
+        }
+    }
+
+    private class RoomInfo {
+        public GenericRoom Room { get; set; }
+        public string RoomName { get; set; }
+        public string? Shortcode { get; set; }
+        public Dictionary<PolicyType, int?> PolicyCounts { get; set; }
+        public bool IsLegacy { get; set; }
+
+        public enum PolicyType {
+            User,
+            Room,
+            Server
+        }
+
+        private static readonly List<string> userEventTypes = EventContent.GetMatchingEventTypes<UserPolicyRuleEventContent>();
+        private static readonly List<string> serverEventTypes = EventContent.GetMatchingEventTypes<ServerPolicyRuleEventContent>();
+        private static readonly List<string> roomEventTypes = EventContent.GetMatchingEventTypes<RoomPolicyRuleEventContent>();
+        private static readonly List<string> allKnownPolicyTypes = [..userEventTypes, ..serverEventTypes, ..roomEventTypes];
+
+        public static async Task<RoomInfo> FromRoom(GenericRoom room, List<StateEventResponse>? state = null, bool legacy = false) {
+            state ??= await room.GetFullStateAsListAsync();
+            return new RoomInfo() {
+                Room = room,
+                IsLegacy = legacy,
+                RoomName = await room.GetNameAsync()
+                           ?? (await room.GetCanonicalAliasAsync())?.Alias
+                           ?? (await room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId))?.Shortcode
+                           ?? room.RoomId,
+                Shortcode = (await room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId))?.Shortcode,
+                PolicyCounts = new() {
+                    { PolicyType.User, state.Count(x => userEventTypes.Contains(x.Type)) },
+                    { PolicyType.Server, state.Count(x => serverEventTypes.Contains(x.Type)) },
+                    { PolicyType.Room, state.Count(x => roomEventTypes.Contains(x.Type)) }
+                }
+            };
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css
new file mode 100644
index 0000000..f9b5b3f
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css
@@ -0,0 +1,6 @@
+table, th, td {
+    border-width: 1px;
+}
+td {
+    padding: 8px;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Space.razor b/MatrixUtils.Web/Pages/Rooms/Space.razor
index 01ab1c4..088fdcd 100644
--- a/MatrixUtils.Web/Pages/Rooms/Space.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Space.razor
@@ -1,12 +1,17 @@
 @page "/Rooms/{RoomId}/Space"
+@using System.Collections.ObjectModel
 @using LibMatrix.RoomTypes
 @using ArcaneLibs.Extensions
 @using LibMatrix
+@using MatrixUtils.Abstractions
 <h3>Room manager - Viewing Space</h3>
 
+<span>Add new room to space: </span>
+<FancyTextBox @bind-Value="@NewRoomId"></FancyTextBox>
+<button onclick="@AddNewRoom">Add</button>
 <button onclick="@JoinAllRooms">Join all rooms</button>
 @foreach (var room in Rooms) {
-    <RoomListItem Room="room" ShowOwnProfile="true"></RoomListItem>
+    <RoomListItem RoomInfo="room" ShowOwnProfile="true"></RoomListItem>
 }
 
 
@@ -27,8 +32,9 @@
     private GenericRoom? Room { get; set; }
 
     private StateEventResponse[] States { get; set; } = Array.Empty<StateEventResponse>();
-    private List<GenericRoom> Rooms { get; } = new();
+    private List<RoomInfo> Rooms { get; } = new();
     private List<string> ServersInSpace { get; } = new();
+    private string? NewRoomId { get; set; }
 
     protected override async Task OnInitializedAsync() {
         var hs = await RMUStorage.GetCurrentSessionOrNavigate();
@@ -43,7 +49,18 @@
                     var roomId = stateEvent.StateKey;
                     var room = hs.GetRoom(roomId);
                     if (room is not null) {
-                        Rooms.Add(room);
+                        Task.Run(async () => {
+                            try {
+                                Rooms.Add(new(Room, await room.GetFullStateAsListAsync()));
+                            }
+                            catch (MatrixException e) {
+                                if (e is { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) {
+                                    Rooms.Add(new(Room) {
+                                        RoomName = "M_FORBIDDEN"
+                                    });
+                                }
+                            }
+                        });
                     }
                     break;
                 }
@@ -96,8 +113,37 @@
         // List<Task<RoomIdResponse>> tasks = Rooms.Select(room => room.JoinAsync(ServersInSpace.ToArray())).ToList();
         // await Task.WhenAll(tasks);
         foreach (var room in Rooms) {
+            await JoinRecursive(room.Room.RoomId);
+        }
+    }
+
+    private async Task JoinRecursive(string roomId) {
+        var room = Room!.Homeserver.GetRoom(roomId);
+        if (room is null) return;
+        try {
             await room.JoinAsync(ServersInSpace.ToArray());
+            var joined = false;
+            while (!joined) {
+                var ce = await room.GetCreateEventAsync();
+                if(ce is null) continue;
+                if (ce.Type == "m.space") {
+                     var children = room.AsSpace.GetChildrenAsync(false);
+                     await foreach (var child in children) {
+                         JoinRecursive(child.RoomId);
+                     }
+                }
+                joined = true;
+            }
         }
+        catch (Exception e) {
+            Console.WriteLine(e);
+        }
+
+    }
+
+    private async Task AddNewRoom() {
+        if (string.IsNullOrWhiteSpace(NewRoomId)) return;
+        await Room.AsSpace.AddChildByIdAsync(NewRoomId);
     }
 
 }
diff --git a/MatrixUtils.Web/Pages/StreamTest.razor b/MatrixUtils.Web/Pages/StreamTest.razor
new file mode 100644
index 0000000..541cfe8
--- /dev/null
+++ b/MatrixUtils.Web/Pages/StreamTest.razor
@@ -0,0 +1,119 @@
+@page "/StreamTest"
+@inject ILogger<Index> logger
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+
+<PageTitle>StreamText</PageTitle>
+@if (Homeserver is not null) {
+    <p>Got homeserver @Homeserver.BaseUrl</p>
+
+    @* <img src="@ResolvedUri" @ref="imgElement"/> *@
+    @* <StreamedImage Stream="@Stream"/> *@
+
+    <br/>
+    @foreach (var stream in Streams.OrderBy(x => x.GetHashCode())) {
+        <StreamedImage Stream="@stream" style="width: 12em; height: 12em; object-fit: cover;"/>
+    }
+}
+
+@code
+{
+    private string? _resolvedUri;
+
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    private string? ResolvedUri {
+        get => _resolvedUri;
+        set {
+            _resolvedUri = value;
+            StateHasChanged();
+        }
+    }
+
+    ElementReference imgElement { get; set; }
+    public Stream? Stream { get; set; }
+    public List<Stream> Streams { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+
+        //await InitOld();
+        await Init2();
+
+        await base.OnInitializedAsync();
+    }
+
+    private async Task Init2() {
+        var roomState = await Homeserver.GetRoom("!dSMpkVKGgQHlgBDSpo:matrix.org").GetFullStateAsListAsync();
+        var members = roomState.Where(x => x.Type == RoomMemberEventContent.EventId).ToList();
+        Console.WriteLine($"Got {members.Count()} members");
+        var ss = new SemaphoreSlim(1, 1);
+        foreach (var stateEventResponse in members) {
+            // Console.WriteLine(stateEventResponse.ToJson());
+            var mc = stateEventResponse.TypedContent as RoomMemberEventContent;
+            if (!string.IsNullOrWhiteSpace(mc?.AvatarUrl)) {
+                var uri = mc.AvatarUrl[6..].Split('/');
+                var url = $"/_matrix/media/v3/download/{uri[0]}/{uri[1]}";
+                // Homeserver.GetMediaStreamAsync(mc?.AvatarUrl).ContinueWith(async x => {
+                // await ss.WaitAsync();
+                // var stream = x.Result;
+                // Streams.Add(stream);
+                // StateHasChanged();
+                await Task.Delay(100);
+                // ss.Release();
+                // });
+                try {
+                    Homeserver.ClientHttpClient.GetStreamAsync(url).ContinueWith(async x => {
+                        // await ss.WaitAsync();
+                        var stream = x.Result;
+                        Streams.Add(stream);
+                        StateHasChanged();
+                        // await Task.Delay(100);
+                        // ss.Release();
+                    });
+                }
+                catch (Exception e) {
+                    Console.WriteLine(e);
+                }
+            }
+        }
+    }
+
+    private async Task InitOld() {
+        // var value = "mxc://rory.gay/AcFYcSpVXhEwbejrPVQrRUqt";
+        // var value = "mxc://rory.gay/oqfCjIUVTAObSQbnMFekQvYR";
+        var value = "mxc://feline.support/LUslNRVIYfeyCdRElqkkumKP";
+        var uri = value[6..].Split('/');
+        var url = $"/_matrix/media/v3/download/{uri[0]}/{uri[1]}";
+        // var res = Homeserver.ClientHttpClient.GetAsync(url);
+        // var res2 = Homeserver.ClientHttpClient.GetAsync(url);
+        // var tasks = Enumerable.Range(1, 128)
+        // .Select(x => Homeserver.ClientHttpClient.GetStreamAsync(url+$"?width={x*128}&height={x*128}"))
+        // .ToAsyncEnumerable();
+        await foreach (var result in GetStreamsDelayed(url)) {
+            Streams.Add(result);
+            // await Task.Delay(100);
+            StateHasChanged();
+        }
+
+        // var stream = await (await res).Content.ReadAsStreamAsync();
+        // Stream = await (await res2).Content.ReadAsStreamAsync();
+        StateHasChanged();
+
+        // await JSRuntime.streamImage(stream, imgElement);
+    }
+
+    private async IAsyncEnumerable<Stream> GetStreamsDelayed(string url) {
+        for (int i = 0; i < 32; i++) {
+            var tasks = Enumerable.Range(1, 4)
+                .Select(x => Homeserver.ClientHttpClient.GetStreamAsync(url + $"?width={x * 128}&height={x * 128}&r={Random.Shared.Next(100000)}"))
+                .ToAsyncEnumerable();
+            await foreach (var result in tasks) {
+                yield return result;
+            }
+            // var resp = await Homeserver.ClientHttpClient.GetAsync(url + $"?width={i * 128}&height={i * 128}");
+            // yield return await resp.Content.ReadAsStreamAsync();
+            // await Task.Delay(250);
+        }
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Index.razor b/MatrixUtils.Web/Pages/Tools/Index.razor
index e68bb9a..f99e932 100644
--- a/MatrixUtils.Web/Pages/Tools/Index.razor
+++ b/MatrixUtils.Web/Pages/Tools/Index.razor
@@ -24,7 +24,7 @@
 <a href="/Tools/Moderation/UserTrace">Trace user across rooms</a><br/>
 <a href="/tools/Moderation/MassCMEBan">Mass write policies to Community Moderation Effort</a><br/>
 <a href="/tools/Moderation/RoomIntersections">Find rooms with common users</a><br/>
-<a href="/tools/Moderation/DraupnirProtectedRoomsEditor">Edit Draupnir protected rooms set</a><br/>
+<a href="/tools/Moderation/Draupnir/ProtectedRoomsEditor">Draupnir: edit protected rooms set</a><br/>
 
 
 <h4 class="tool-category">Debugging tools</h4>
diff --git a/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor b/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
index de0bfe7..e093db2 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
@@ -5,6 +5,8 @@
 @using LibMatrix.EventTypes.Common
 
 
+
+@* <ActivityGraph Data="TestData"/> *@
 @if (RoomData.Count == 0)
 {
     <p>Loading...</p>
@@ -15,10 +17,8 @@ else
         <h3>@room.Key</h3>
         @foreach (var year in room.Value.OrderBy(x => x.Key))
         {
-            <h5>@year.Key</h5>
-            <ActivityGraph Data="@year.Value" GlobalMax="MaxValue"
-                           RLabel="removed" GLabel="new" BLabel="updated policies">
-            </ActivityGraph>
+            <span>@year.Key</span>
+            <ActivityGraph Data="@year.Value" GlobalMax="MaxValue" RLabel="removed" GLabel="new" BLabel="updated policies"/>
         }
     }
 
@@ -42,15 +42,22 @@ else
         await base.OnInitializedAsync();
         Homeserver = (await RMUStorage.GetCurrentSessionOrNavigate())!;
         if (Homeserver is null) return;
-
+        //
         //random test data
-        for (DateOnly i = new DateOnly(2020, 1, 1); i < new DateOnly(2020, 12, 30); i = i.AddDays(Random.Shared.Next(5)))
+        for (DateOnly i = new DateOnly(2020, 1, 1); i < new DateOnly(2020, 12, 30); i = i.AddDays(2))
         {
+            // TestData[i] = new()
+            // {
+            //     R = (int)(Random.Shared.NextSingle() * 255),
+            //     G = (int)(Random.Shared.NextSingle() * 255),
+            //     B = (int)(Random.Shared.NextSingle() * 255)
+            // };
+            // rgb based on number of week
             TestData[i] = new()
             {
-                R = (int)(Random.Shared.NextSingle() * 255),
-                G = (int)(Random.Shared.NextSingle() * 255),
-                B = (int)(Random.Shared.NextSingle() * 255)
+                R = i.DayOfYear % 255,
+                G = i.DayOfYear + 96 % 255,
+                B = i.DayOfYear + 192 % 255
             };
         }
 
@@ -109,7 +116,7 @@ else
             }
 
             //use timeline
-            var timeline = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 5000);
+            var timeline = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 2000);
             await foreach (var response in timeline)
             {
                 Console.WriteLine($"Got {response.State.Count} state, {response.Chunk.Count} timeline");
@@ -133,7 +140,7 @@ else
 
                     var rgb = RoomData[roomName][date.Year][date];
                     if (message.RawContent?.Count == 0) rgb.R++;
-                    else if (string.IsNullOrWhiteSpace(message.Unsigned?.ReplacesState)) rgb.G++;
+                    else if (message.Unsigned?.ContainsKey("replaces_state") ?? false) rgb.G++;
                     else rgb.B++;
                     RoomData[roomName][date.Year][date] = rgb;
                 }
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor
new file mode 100644
index 0000000..8745459
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor
@@ -0,0 +1,143 @@
+@page "/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/Draupnir/ProtectedRoomsEditor"
+@using System.Text.Json.Serialization
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.RoomTypes
+<h3>Edit Draupnir protected rooms</h3>
+<hr/>
+<p><b>Note:</b> You will need to restart Draupnir after applying changes!</p>
+<p>Minor note: This <i>should</i> also work with Mjolnir, but this hasn't been tested, and as such functionality cannot be guaranteed.</p>
+
+@if (data is not null) {
+    <div class="row">
+        <div class="col-12">
+            <details>
+                <summary>Currently protected room IDs</summary>
+                <ul>
+                    @foreach (var room in data.Rooms) {
+                        <li>@room</li>
+                    }
+                </ul>
+            </details>
+            <hr/>
+            <h4>Tickyboxes</h4>
+            <table class="table">
+                <thead>
+                    <tr>
+                        <th></th> @* Checkbox column *@
+                        <th>Kick?</th> @* PL > kick *@
+                        <th>Ban?</th> @* PL > ban *@
+                        <th>ACL?</th> @* PL > m.room.server_acls event *@
+                        <th>Room ID</th>
+                        <th>Room name</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    @foreach (var room in Rooms.OrderBy(x => x.RoomName)) {
+                        <tr>
+                            <td>
+                                <input type="checkbox" @bind="room.IsProtected"/>
+                            </td>
+                            <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerACLEventContent.EventId) ? "X" : "")</td>
+                            <td>@room.Room.RoomId</td>
+                            <td>@room.RoomName</td>
+                        </tr>
+                    }
+                </tbody>
+            </table>
+        </div>
+    </div>
+}
+<br/>
+<LinkButton OnClick="@Apply">Apply</LinkButton>
+
+
+@code {
+    private DraupnirProtectedRoomsData data { get; set; } = new();
+    private List<EditorRoomInfo> Rooms { get; set; } = new();
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms");
+        StateHasChanged();
+        var tasks = (await hs.GetJoinedRooms()).Select(async room => {
+            var plTask = room.GetPowerLevelsAsync();
+            var roomNameTask = room.GetNameOrFallbackAsync();
+            var EditorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = data.Rooms.Contains(room.RoomId),
+                RoomName = await roomNameTask,
+                PowerLevels = await plTask
+            };
+
+            Rooms.Add(EditorRoomInfo);
+            StateHasChanged();
+            return Task.CompletedTask;
+        }).ToList();
+        await Task.WhenAll(tasks);
+        await Task.Delay(500);
+
+        foreach (var protectedRoomId in data.Rooms) {
+            if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue;
+            var room = hs.GetRoom(protectedRoomId);
+            var editorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = true
+            };
+
+            try {
+                var pl = await room.GetPowerLevelsAsync();
+                editorRoomInfo.PowerLevels = pl;
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}");
+            }
+
+            try {
+                editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get name for {room.RoomId}: {e}");
+            }
+
+            try {
+                var membership = await room.GetStateEventOrNullAsync(hs.UserId);
+                if (membership is not null) {
+                    editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}";
+                }
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}");
+            }
+
+            Rooms.Add(editorRoomInfo);
+        }
+
+        StateHasChanged();
+    }
+
+    private class DraupnirProtectedRoomsData {
+        [JsonPropertyName("rooms")]
+        public List<string> Rooms { get; set; } = new();
+    }
+
+    private class EditorRoomInfo {
+        public GenericRoom Room { get; set; }
+        public bool IsProtected { get; set; }
+        public string RoomName { get; set; }
+        public RoomPowerLevelEventContent PowerLevels { get; set; }
+    }
+
+    private async Task Apply() {
+        Console.WriteLine(string.Join('\n', Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId)));
+        data.Rooms = Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId).ToList();
+        await hs.SetAccountDataAsync("org.matrix.mjolnir.protected_rooms", data);
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor
index 805bd40..b722596 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor
@@ -1,6 +1,6 @@
-@page "/Moderation/DraupnirProtectedRoomsEditor"
-@page "/Tools/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/Draupnir/ProtectionsEditor"
 @using System.Text.Json.Serialization
+@using LibMatrix
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.RoomTypes
 <h3>Edit Draupnir protected rooms</h3>
@@ -78,6 +78,43 @@
         }).ToList();
         await Task.WhenAll(tasks);
         await Task.Delay(500);
+
+        foreach (var protectedRoomId in data.Rooms) {
+            if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue;
+            var room = hs.GetRoom(protectedRoomId);
+            var editorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = true
+            };
+
+            try {
+                var pl = await room.GetPowerLevelsAsync();
+                editorRoomInfo.PowerLevels = pl;
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}");
+            }
+
+            try {
+                editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get name for {room.RoomId}: {e}");
+            }
+
+            try {
+                var membership = await room.GetStateEventOrNullAsync(hs.UserId);
+                if (membership is not null) {
+                    editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}";
+                }
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}");
+            }
+
+            Rooms.Add(editorRoomInfo);
+        }
+
         StateHasChanged();
     }
 
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor
new file mode 100644
index 0000000..b2f4026
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor
@@ -0,0 +1,139 @@
+@page "/Tools/Moderation/Draupnir/WatchedListsEditor"
+@using System.Text.Json.Serialization
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.RoomTypes
+<h3>Edit Draupnir protected rooms</h3>
+<hr/>
+<p><b>Note:</b> You will need to restart Draupnir after applying changes!</p>
+<p>Minor note: This <i>should</i> also work with Mjolnir, but this hasn't been tested, and as such functionality cannot be guaranteed.</p>
+
+@if (data is not null) {
+    <div class="row">
+        <div class="col-12">
+            <h4>Current rooms</h4>
+            <ul>
+                @foreach (var room in data.Rooms) {
+                    <li>@room</li>
+                }
+            </ul>
+            <hr/>
+            <h4>Tickyboxes</h4>
+            <table class="table">
+                <thead>
+                    <tr>
+                        <th></th> @* Checkbox column *@
+                        <th>Kick?</th> @* PL > kick *@
+                        <th>Ban?</th> @* PL > ban *@
+                        <th>ACL?</th> @* PL > m.room.server_acls event *@
+                        <th>Room ID</th>
+                        <th>Room name</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    @foreach (var room in Rooms.OrderBy(x => x.RoomName)) {
+                        <tr>
+                            <td>
+                                <input type="checkbox" @bind="room.IsProtected"/>
+                            </td>
+                            <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerACLEventContent.EventId) ? "X" : "")</td>
+                            <td>@room.Room.RoomId</td>
+                            <td>@room.RoomName</td>
+                        </tr>
+                    }
+                </tbody>
+            </table>
+        </div>
+    </div>
+}
+<br/>
+<LinkButton OnClick="@Apply">Apply</LinkButton>
+
+
+@code {
+    private DraupnirProtectedRoomsData data { get; set; } = new();
+    private List<EditorRoomInfo> Rooms { get; set; } = new();
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms");
+        StateHasChanged();
+        var tasks = (await hs.GetJoinedRooms()).Select(async room => {
+            var plTask = room.GetPowerLevelsAsync();
+            var roomNameTask = room.GetNameOrFallbackAsync();
+            var EditorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = data.Rooms.Contains(room.RoomId),
+                RoomName = await roomNameTask,
+                PowerLevels = await plTask
+            };
+
+            Rooms.Add(EditorRoomInfo);
+            StateHasChanged();
+            return Task.CompletedTask;
+        }).ToList();
+        await Task.WhenAll(tasks);
+        await Task.Delay(500);
+
+        foreach (var protectedRoomId in data.Rooms) {
+            if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue;
+            var room = hs.GetRoom(protectedRoomId);
+            var editorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = true
+            };
+
+            try {
+                var pl = await room.GetPowerLevelsAsync();
+                editorRoomInfo.PowerLevels = pl;
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}");
+            }
+
+            try {
+                editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get name for {room.RoomId}: {e}");
+            }
+
+            try {
+                var membership = await room.GetStateEventOrNullAsync(hs.UserId);
+                if (membership is not null) {
+                    editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}";
+                }
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}");
+            }
+
+            Rooms.Add(editorRoomInfo);
+        }
+
+        StateHasChanged();
+    }
+
+    private class DraupnirProtectedRoomsData {
+        [JsonPropertyName("rooms")]
+        public List<string> Rooms { get; set; } = new();
+    }
+
+    private class EditorRoomInfo {
+        public GenericRoom Room { get; set; }
+        public bool IsProtected { get; set; }
+        public string RoomName { get; set; }
+        public RoomPowerLevelEventContent PowerLevels { get; set; }
+    }
+
+    private async Task Apply() {
+        Console.WriteLine(string.Join('\n', Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId)));
+        data.Rooms = Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId).ToList();
+        await hs.SetAccountDataAsync("org.matrix.mjolnir.protected_rooms", data);
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
index ea1e5f6..a4e3918 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
@@ -1,6 +1,7 @@
 @page "/Tools/Moderation/MassCMEBan"
 @using System.Collections.ObjectModel
 @using LibMatrix.EventTypes.Spec.State.Policy
+@using LibMatrix.RoomTypes
 <h3>User Trace</h3>
 <hr/>
 
@@ -17,19 +18,19 @@
 }
 
 @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();
@@ -37,33 +38,41 @@
 
     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}");
-            }
+        // var room = hs.GetRoom("!IVSjKMsVbjXsmUTuRR:rory.gay");
+        var users = roomId.Split("\n").Select(x => x.Trim()).Where(x => x.StartsWith('@')).ToList();
+        var tasks = users.Select(x => ExecuteBan(room, x)).ToList();
+        await Task.WhenAll(tasks);
+
+        StateHasChanged();
+
+        return "";
+    }
 
-            if (!exists) {
+    private async Task ExecuteBan(GenericRoom room, string user) {
+        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) {
+            try {
                 var evt = await room.SendStateEventAsync(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'), new UserPolicyRuleEventContent() {
                     Entity = user,
-                    Reason = "spam (invite)",
+                    Reason = "spam",
                     Recommendation = "m.ban"
                 });
                 log.Add($"Sent {evt.EventId} to ban {user}");
             }
-            else {
-                log.Add($"User {user} already exists");
+            catch (Exception e) {
+                log.Add($"Failed to ban {user}: {e}");
             }
         }
-        
-        
-        StateHasChanged();
-
-        return "";
+        else {
+            log.Add($"User {user} already exists");
+        }
     }
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
index e5ba004..94afc9a 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
@@ -55,75 +55,92 @@
         if (ChronologicalOrder) {
             filteredMemberships = filteredMemberships.Reverse();
         }
-        if(!string.IsNullOrWhiteSpace(Sender)) {
+
+        if (!string.IsNullOrWhiteSpace(Sender)) {
             filteredMemberships = filteredMemberships.Where(x => x.Sender == Sender);
         }
-        if(!string.IsNullOrWhiteSpace(User)) {
+
+        if (!string.IsNullOrWhiteSpace(User)) {
             filteredMemberships = filteredMemberships.Where(x => x.StateKey == User);
         }
 
+    <table>
         @foreach (var membership in filteredMemberships) {
             RoomMemberEventContent content = membership.TypedContent as RoomMemberEventContent;
-            @switch (content.Membership) {
-                case RoomMemberEventContent.MembershipTypes.Invite: {
-                    if (_showInvites) {
-                        <p style="color: green;">@membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                    }
-
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Ban: {
-                    if (_showBans) {
-                        <p style="color: red;">@membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                    }
+            StateEventResponse? previous = previousMemberships.GetValueOrDefault(membership.StateKey);
+            RoomMemberEventContent? previousContent = previous?.TypedContent as RoomMemberEventContent;
+            <tr>
+                <td>@DateTimeOffset.FromUnixTimeMilliseconds(membership.OriginServerTs ?? 0).ToString("g")</td>
+                <td>
+                    @switch (content.Membership) {
+                        case RoomMemberEventContent.MembershipTypes.Invite: {
+                            if (_showInvites) {
+                                <p style="color: green;">@membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
+                            }
+
+                            break;
+                        }
+                        case RoomMemberEventContent.MembershipTypes.Ban: {
+                            if (_showBans) {
+                                <p style="color: red;">@membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
+                            }
 
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Leave: {
-                    if (membership.Sender == membership.StateKey) {
-                        if (_showLeaves) {
-                            <p style="color: #C66;">@membership.Sender left the room</p>
+                            break;
                         }
-                    }
-                    else {
-                        if (_showKicks) {
-                            <p style="color: darkorange;">@membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
+                        case RoomMemberEventContent.MembershipTypes.Leave: {
+                            if (membership.Sender == membership.StateKey) {
+                                if (_showLeaves) {
+                                    <p style="color: #C66;">@membership.Sender left the room</p>
+                                }
+                            }
+                            else {
+                                if (_showKicks) {
+                                    <p style="color: darkorange;">@membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
+                                }
+                            }
+
+                            break;
                         }
-                    }
-
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Knock: {
-                    if (_showKnocks) {
-                        <p>@membership.Sender knocked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                    }
+                        case RoomMemberEventContent.MembershipTypes.Knock: {
+                            if (_showKnocks) {
+                                <p>@membership.Sender knocked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
+                            }
 
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Join: {
-                    if (previousMemberships.TryGetValue(membership.StateKey, out var previous)
-                        && (previous.TypedContent as RoomMemberEventContent).Membership == RoomMemberEventContent.MembershipTypes.Join) {
-                        if (_showUpdates) {
-                            <p style="color: #777;">@membership.Sender changed their profile</p>
+                            break;
                         }
-                    }
-                    else {
-                        if (_showJoins) {
-                            <p style="color: #6C6;">@membership.Sender joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
+                        case RoomMemberEventContent.MembershipTypes.Join: {
+                            if (previousContent is { Membership: RoomMemberEventContent.MembershipTypes.Join }) {
+                                if (_showUpdates) {
+                                    <p style="color: #777;">
+                                        @membership.Sender changed their profile<br/>
+                                        Display name: @previousContent.DisplayName -> @content.DisplayName<br/>
+                                        Avatar URL: @previousContent.AvatarUrl -> @content.AvatarUrl
+                                    </p>
+                                }
+                            }
+                            else {
+                                if (_showJoins) {
+                                    <p style="color: #6C6;">
+                                        @membership.Sender joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")<br/>
+                                        Display name: @content.DisplayName<br/>
+                                        Avatar URL: @content.AvatarUrl
+                                    </p>
+                                }
+                            }
+
+                            break;
+                        }
+                        default: {
+                            <b>Unknown membership @content.Membership!</b>
+                            break;
                         }
                     }
-
-                    break;
-                }
-                default: {
-                    <b>Unknown membership @content.Membership!</b>
-                    break;
-                }
-            }
+                </td>
+            </tr>
 
             previousMemberships[membership.StateKey] = membership;
         }
-    }
+    </table>}
 </details>
 
 <br/>
@@ -217,9 +234,9 @@
             StateHasChanged();
         }
     }
-    
+
     private string sender = "";
-    
+
     private string Sender {
         get => sender;
         set {
@@ -227,9 +244,9 @@
             StateHasChanged();
         }
     }
-    
+
     private string user = "";
-    
+
     private string User {
         get => user;
         set {
diff --git a/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor b/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor
index d8b02bb..153518e 100644
--- a/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor
@@ -1,4 +1,4 @@
-@page "/Tools/ViewAccountData"
+@page "/Tools/User/ViewAccountData"
 @using ArcaneLibs.Extensions
 @using LibMatrix
 <h3>View account data</h3>
diff --git a/MatrixUtils.Web/Pages/User/Profile.razor b/MatrixUtils.Web/Pages/User/Profile.razor
index 49af22f..4e1fd0c 100644
--- a/MatrixUtils.Web/Pages/User/Profile.razor
+++ b/MatrixUtils.Web/Pages/User/Profile.razor
@@ -12,7 +12,7 @@
     <h4>Profile</h4>
     <hr/>
     <div>
-        <img src="@Homeserver.ResolveMediaUri(NewProfile.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/>
+        <MxcAvatar MxcUri="@NewProfile.AvatarUrl" Circular="true" Size="96"/>
         <div style="display: inline-block; vertical-align: middle;">
             <span>Display name: </span><FancyTextBox @bind-Value="@NewProfile.DisplayName"></FancyTextBox><br/>
             <span>Avatar URL: </span><FancyTextBox @bind-Value="@NewProfile.AvatarUrl"></FancyTextBox>
@@ -35,12 +35,13 @@
             <summary style="@(room.OwnMembership?.DisplayName == OldProfile.DisplayName && room.OwnMembership?.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")">
                 <div style="display: inline-block; width: calc(100% - 50px); vertical-align: middle; margin-top: -8px; margin-bottom: -8px;">
                     <CascadingValue Value="OldProfile">
-                        <RoomListItem ShowOwnProfile="true" RoomInfo="@room" OwnMemberState="@room.OwnMembership"></RoomListItem>
+                        <RoomListItem Homeserver="Homeserver" ShowOwnProfile="true" RoomInfo="@room" OwnMemberState="@room.OwnMembership"></RoomListItem>
                     </CascadingValue>
                 </div>
             </summary>
             @if (room.OwnMembership is not null) {
-                <img src="@Homeserver.ResolveMediaUri(room.OwnMembership.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/>
+                @* <img src="@Homeserver.ResolveMediaUri(room.OwnMembership.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/> *@
+                <MxcAvatar MxcUri="@room.OwnMembership.AvatarUrl" Circular="true" Size="96"/>
                 <div style="display: inline-block; vertical-align: middle;">
                     <span>Display name: </span><FancyTextBox BackgroundColor="@(room.OwnMembership.DisplayName == OldProfile.DisplayName ? "" : "#ffff0033")" @bind-Value="@room.OwnMembership.DisplayName"></FancyTextBox><br/>
                     <span>Avatar URL: </span><FancyTextBox BackgroundColor="@(room.OwnMembership.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")" @bind-Value="@room.OwnMembership.AvatarUrl"></FancyTextBox>
@@ -58,24 +59,6 @@
         </details>
         <br/>
     }
-
-    @foreach (var (roomId, roomProfile) in RoomProfiles.OrderBy(x => RoomNames.TryGetValue(x.Key, out var _name) ? _name : x.Key)) {
-        <details class="details-compact">
-            <summary style="@(roomProfile.DisplayName == OldProfile.DisplayName && roomProfile.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")">@(RoomNames.TryGetValue(roomId, out var name) ? name : roomId)</summary>
-            <img src="@Homeserver.ResolveMediaUri(roomProfile.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/>
-            <div style="display: inline-block; vertical-align: middle;">
-                <span>Display name: </span><FancyTextBox BackgroundColor="@(roomProfile.DisplayName == OldProfile.DisplayName ? "" : "#ffff0033")" @bind-Value="@roomProfile.DisplayName"></FancyTextBox><br/>
-                <span>Avatar URL: </span><FancyTextBox BackgroundColor="@(roomProfile.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")" @bind-Value="@roomProfile.AvatarUrl"></FancyTextBox>
-                <InputFile OnChange="@(ifcea => RoomAvatarChanged(ifcea, roomId))"></InputFile><br/>
-                <LinkButton OnClick="@(() => UpdateRoomProfile(roomId))">Update profile</LinkButton>
-            </div>
-            <br/>
-            @if (!string.IsNullOrWhiteSpace(Status)) {
-                <p>@Status</p>
-            }
-        </details>
-        <br/>
-    }
     // </details>
 }
 
@@ -107,44 +90,50 @@
         OldProfile = (await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId)); //.DeepClone();
         Status = "Loading room profiles...";
         var roomProfiles = Homeserver.GetRoomProfilesAsync();
+        List<Task> roomInfoTasks = [];
         await foreach (var (roomId, roomProfile) in roomProfiles) {
-            var room = Homeserver.GetRoom(roomId);
-            var roomNameTask = room.GetNameOrFallbackAsync();
-            var roomIconTask = room.GetAvatarUrlAsync();
-            var roomInfo = new RoomInfo(room) {
-                OwnMembership = roomProfile
-            };
-            try {
-                roomInfo.RoomIcon = (await roomIconTask).Url;
-            }
-            catch (MatrixException e) {
-                if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
-            }
+            var task = Task.Run(async () => {
+                var room = Homeserver.GetRoom(roomId);
+                var roomNameTask = room.GetNameOrFallbackAsync();
+                var roomIconTask = room.GetAvatarUrlAsync();
+                var roomInfo = new RoomInfo(room) {
+                    OwnMembership = roomProfile
+                };
+                try {
+                    roomInfo.RoomIcon = (await roomIconTask).Url;
+                }
+                catch (MatrixException e) {
+                    if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
+                }
 
-            try {
-                roomInfo.RoomName = await roomNameTask;
-            }
-            catch (MatrixException e) {
-                if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
-            }
+                try {
+                    RoomNames[roomId] = roomInfo.RoomName = await roomNameTask;
+                }
+                catch (MatrixException e) {
+                    if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
+                }
 
-            Rooms.Add(roomInfo);
-            // Status = $"Got profile for {roomId}...";
-            RoomProfiles[roomId] = roomProfile; //.DeepClone();
+                Rooms.Add(roomInfo);
+                // Status = $"Got profile for {roomId}...";
+                RoomProfiles[roomId] = roomProfile; //.DeepClone();
+            });
+            roomInfoTasks.Add(task);
         }
+        
+        await Task.WhenAll(roomInfoTasks);
 
         StateHasChanged();
         Status = "Room profiles loaded, loading room names...";
 
-        var roomNameTasks = RoomProfiles.Keys.Select(x => Homeserver.GetRoom(x)).Select(async x => {
-            var name = await x.GetNameOrFallbackAsync();
-            return new KeyValuePair<string, string?>(x.RoomId, name);
-        }).ToAsyncEnumerable();
+        // var roomNameTasks = RoomProfiles.Keys.Select(x => Homeserver.GetRoom(x)).Select(async x => {
+        // var name = await x.GetNameOrFallbackAsync();
+        // return new KeyValuePair<string, string?>(x.RoomId, name);
+        // }).ToAsyncEnumerable();
 
-        await foreach (var (roomId, roomName) in roomNameTasks) {
-            // Status = $"Got room name for {roomId}: {roomName}";
-            RoomNames[roomId] = roomName;
-        }
+        // await foreach (var (roomId, roomName) in roomNameTasks) {
+        // Status = $"Got room name for {roomId}: {roomName}";
+        // RoomNames[roomId] = roomName;
+        // }
 
         StateHasChanged();
         Status = null;
diff --git a/MatrixUtils.Web/Shared/InlineUserItem.razor b/MatrixUtils.Web/Shared/InlineUserItem.razor
index 9c6608a..c7f16f0 100644
--- a/MatrixUtils.Web/Shared/InlineUserItem.razor
+++ b/MatrixUtils.Web/Shared/InlineUserItem.razor
@@ -59,7 +59,7 @@
         }
 
 
-        ProfileAvatar ??= Homeserver.ResolveMediaUri(User.AvatarUrl);
+        // ProfileAvatar ??= Homeserver.ResolveMediaUri(User.AvatarUrl);
         ProfileName ??= User.DisplayName;
 
         _semaphoreSlim.Release();
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor.css b/MatrixUtils.Web/Shared/MainLayout.razor.css
index 01a5066..924393b 100644
--- a/MatrixUtils.Web/Shared/MainLayout.razor.css
+++ b/MatrixUtils.Web/Shared/MainLayout.razor.css
@@ -57,6 +57,7 @@ main {
 
     .sidebar {
         width: 250px;
+        min-width: 250px;
         height: 100vh;
         position: sticky;
         top: 0;
diff --git a/MatrixUtils.Web/Shared/MxcAvatar.razor b/MatrixUtils.Web/Shared/MxcAvatar.razor
new file mode 100644
index 0000000..09ea790
--- /dev/null
+++ b/MatrixUtils.Web/Shared/MxcAvatar.razor
@@ -0,0 +1,58 @@
+@using System.Security
+@using System.Security.Cryptography
+@using Blazored.SessionStorage.JsonConverters
+<StreamedImage Stream="@_stream" style="@StyleString"/>
+
+@code {
+    private string _mxcUri;
+    private string _style;
+    private Stream _stream;
+    
+    [Parameter]
+    public string MxcUri {
+        get => _mxcUri ?? "";
+        set {
+            if(_mxcUri == value) return;
+            _mxcUri = value;
+            UriHasChanged(value);
+        }
+    }
+
+    [Parameter]
+    public bool Circular { get; set; }
+
+    [Parameter]
+    public int Size { get; set; } = 48;
+
+    [Parameter]
+    public string SizeUnit { get; set; } = "px";
+
+    [Parameter]
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    
+    private string StyleString => $"{(Circular ? "border-radius: 50%;" : "")} width: {Size}{SizeUnit}; height: {Size}{SizeUnit}; object-fit: cover;";
+
+    private static readonly string Prefix = "mxc://";
+    private static readonly int PrefixLength = Prefix.Length;
+
+    private async Task UriHasChanged(string value) {
+        if (!value.StartsWith(Prefix)) {
+            // Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
+            // ResolvedUri = value;
+            return;
+        }
+
+        if (Homeserver is null) {
+            Console.WriteLine("Homeserver is required for MxcAvatar");
+            return;
+        }
+
+        var uri = value[PrefixLength..].Split('/');
+        // Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
+        var url = $"/_matrix/media/v3/download/{uri[0]}/{uri[1]}";
+        Console.WriteLine($"ResolvedUri: {url}");
+        _stream = await Homeserver.ClientHttpClient.GetStreamAsync(url);
+        StateHasChanged();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MxcImage.razor b/MatrixUtils.Web/Shared/MxcImage.razor
index e651c3f..e7cb2e0 100644
--- a/MatrixUtils.Web/Shared/MxcImage.razor
+++ b/MatrixUtils.Web/Shared/MxcImage.razor
@@ -31,7 +31,7 @@
         }
     }
     
-    [Parameter]
+    [CascadingParameter, Parameter]
     public RemoteHomeserver? Homeserver { get; set; }
 
     private string ResolvedUri {
@@ -48,19 +48,19 @@
     private static readonly int PrefixLength = Prefix.Length;
 
     private async Task UriHasChanged(string value) {
-        if (!value.StartsWith(Prefix)) {
-            Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
-            ResolvedUri = value;
-            return;
-        }
-        var uri = value[PrefixLength..].Split('/');
-        Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
-        if (Homeserver is null) {
-            Console.WriteLine($"Homeserver is null, creating new remotehomeserver for {uri[0]}");
-            Homeserver = await hsProvider.GetRemoteHomeserver(uri[0]);
-        }
-        ResolvedUri = Homeserver.ResolveMediaUri(value);
-        Console.WriteLine($"ResolvedUri: {ResolvedUri}");
+        // if (!value.StartsWith(Prefix)) {
+        //     Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
+        //     ResolvedUri = value;
+        //     return;
+        // }
+        // var uri = value[PrefixLength..].Split('/');
+        // Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
+        // if (Homeserver is null) {
+        //     Console.WriteLine($"Homeserver is null, creating new remotehomeserver for {uri[0]}");
+        //     Homeserver = await hsProvider.GetRemoteHomeserver(uri[0]);
+        // }
+        // ResolvedUri = Homeserver.ResolveMediaUri(value);
+        // Console.WriteLine($"ResolvedUri: {ResolvedUri}");
     }
 
     // [Parameter]
diff --git a/MatrixUtils.Web/Shared/NavMenu.razor b/MatrixUtils.Web/Shared/NavMenu.razor
index 770a246..7371e66 100644
--- a/MatrixUtils.Web/Shared/NavMenu.razor
+++ b/MatrixUtils.Web/Shared/NavMenu.razor
@@ -37,6 +37,12 @@
         </div>
 
         <div class="nav-item px-3">
+            <NavLink class="nav-link" href="PolicyLists">
+                <span class="oi oi-ban" aria-hidden="true"></span> Manage policy lists
+            </NavLink>
+        </div>
+
+        <div class="nav-item px-3">
             <NavLink class="nav-link" href="User/Profile">
                 <span class="oi oi-person" aria-hidden="true"></span> Manage profile
             </NavLink>
diff --git a/MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor b/MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor
new file mode 100644
index 0000000..11ba18a
--- /dev/null
+++ b/MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor
@@ -0,0 +1,102 @@
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix
+@using System.Collections.Frozen
+@using LibMatrix.EventTypes
+@using LibMatrix.RoomTypes
+<ModalWindow Title="@("Creating many new " + (PolicyTypes.ContainsKey(MappedType??"") ? PolicyTypes[MappedType!].GetFriendlyNamePluralOrNull()?.ToLower() ?? PolicyTypes[MappedType!].Name : "event"))"
+             OnCloseClicked="@OnClose" X="60" Y="60" MinWidth="600">
+    <span>Policy type:</span>
+    <select @bind="@MappedType">
+        <option>Select a value</option>
+        @foreach (var (type, mappedType) in PolicyTypes) {
+            <option value="@type">@mappedType.GetFriendlyName().ToLower()</option>
+        }
+    </select><br/>
+    
+    <span>Reason:</span>
+    <FancyTextBox @bind-Value="@Reason"></FancyTextBox><br/>
+    
+    <span>Recommendation:</span>
+    <FancyTextBox @bind-Value="@Recommendation"></FancyTextBox><br/>
+
+    <span>Entities:</span><br/>
+    <InputTextArea @bind-Value="@Users" style="width: 500px;"></InputTextArea><br/>
+    
+    
+    @* <details> *@
+    @*     <summary>JSON data</summary> *@
+    @*     <pre> *@
+    @*             $1$ @PolicyEvent.ToJson(true, true) #1# *@
+    @*     </pre> *@
+    @* </details> *@
+    <LinkButton OnClick="@(() => { OnClose.Invoke(); return Task.CompletedTask; })"> Cancel </LinkButton>
+    <LinkButton OnClick="@(() => { _ = Save(); return Task.CompletedTask; })"> Save </LinkButton>
+
+</ModalWindow>
+
+@code {
+
+    [Parameter]
+    public required Action OnClose { get; set; }
+
+    [Parameter]
+    public required Action OnSaved { get; set; }
+
+    [Parameter]
+    public required GenericRoom Room { get; set; }
+
+    public string Recommendation { get; set; } = "m.ban";
+    public string Reason { get; set; } = "spam";
+    public string Users { get; set; } = "";
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+    private string? MappedType { get; set; }
+
+    private async Task Save() {
+        try {
+            await DoActualSave();
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to save: {e}");
+        }
+    }
+
+    private async Task DoActualSave() {
+        Console.WriteLine($"Saving ---");
+        Console.WriteLine($"Users = {Users}");
+        var users = Users.Split("\n").Select(x => x.Trim()).Where(x => x.StartsWith('@')).ToList();
+        var tasks = users.Select(x => ExecuteBan(Room, x)).ToList();
+        await Task.WhenAll(tasks);
+        
+        OnSaved.Invoke();
+    }
+
+    private async Task ExecuteBan(GenericRoom room, string entity) {
+        bool success = false;
+        while (!success) {
+            try {
+                var content = Activator.CreateInstance(PolicyTypes[MappedType!]) as PolicyRuleEventContent;
+                content.Recommendation = Recommendation;
+                content.Reason = Reason;
+                content.Entity = entity;
+                await room.SendStateEventAsync(MappedType!, content.GetDraupnir2StateKey(), content);
+                success = true;
+            }
+            catch (MatrixException e) {
+                if (e is not { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) throw;
+                Console.WriteLine(e);
+            }
+            catch (Exception e) {
+                //ignored
+                Console.WriteLine(e);
+            }
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor b/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
index 1bd00d1..fc536c0 100644
--- a/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
+++ b/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
@@ -35,39 +35,61 @@
             </thead>
             <tbody>
                 @foreach (var prop in props) {
+                    var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null;
                     <tr>
                         <td style="padding-right: 8px;">
                             <span>@prop.GetFriendlyName()</span>
-                            @if (Nullable.GetUnderlyingType(prop.PropertyType) is not null) {
+                            @if (Nullable.GetUnderlyingType(prop.PropertyType) is null) {
                                 <span style="color: red;">*</span>
                             }
                         </td>
                         @{
                             var getter = prop.GetGetMethod();
                             var setter = prop.GetSetMethod();
-                        }
-                        @switch (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) {
-                            case Type t when t == typeof(string):
-                                <FancyTextBox Value="@(getter?.Invoke(PolicyData, null) as string)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></FancyTextBox>
-                                break;
-                            default:
-                                <p style="color: red;">Unsupported type: @prop.PropertyType</p>
-                                break;
+                            if (getter is null) {
+                                <p style="color: red;">Missing property getter: @prop.Name</p>
+                            }
+                            else {
+                                switch (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) {
+                                    case Type t when t == typeof(string):
+                                        <FancyTextBox Value="@(getter?.Invoke(PolicyData, null) as string)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></FancyTextBox>
+                                        break;
+                                    case Type t when t == typeof(DateTime):
+                                        if (!isNullable) {
+                                            <InputDate TValue="DateTime" Value="@(getter?.Invoke(PolicyData, null) as DateTime? ?? new DateTime())" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></InputDate>
+                                        }
+                                        else {
+                                            var value = getter?.Invoke(PolicyData, null) as DateTime?;
+                                            if (value is null) {
+                                                <button @onclick="() => { setter?.Invoke(PolicyData, [DateTime.Now]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); }">Add value</button>
+                                            }
+                                            else {
+                                                var notNullValue = Nullable.GetValueRefOrDefaultRef(ref value);
+                                                Console.WriteLine($"Value: {value?.ToString() ?? "null"}");
+                                                <InputDate TValue="DateTime" ValueExpression="@(() => notNullValue)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></InputDate>
+                                                <button @onclick="() => { setter?.Invoke(PolicyData, [null]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); }">Remove value</button>
+                                            }
+                                        }
+
+                                        break;
+                                    default:
+                                        <p style="color: red;">Unsupported type: @prop.PropertyType</p>
+                                        break;
+                                }
+                            }
                         }
                     </tr>
                 }
             </tbody>
         </table>
-        <br/>
-        <pre>
-            @PolicyEvent.ToJson(true, false)
-        </pre>
+        <details>
+            <summary>JSON data</summary>
+            <pre>
+                @PolicyEvent.ToJson(true, true)
+            </pre>
+        </details>
         <LinkButton OnClick="@(() => { OnClose.Invoke(); return Task.CompletedTask; })"> Cancel </LinkButton>
         <LinkButton OnClick="@(() => { OnSave.Invoke(PolicyEvent); return Task.CompletedTask; })"> Save </LinkButton>
-        @* <span>Target entity: </span> *@
-        @* <FancyTextBox @bind-Value="@policyData.Entity"></FancyTextBox><br/> *@
-        @* <span>Reason: </span> *@
-        @* <FancyTextBox @bind-Value="@policyData.Reason"></FancyTextBox> *@
     }
     else {
         <p>Policy data is null</p>
@@ -102,7 +124,7 @@
         .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
 
     private StateEventResponse? _policyEvent;
-    
+
     private string? MappedType {
         get => _policyEvent?.Type;
         set {
@@ -110,9 +132,9 @@
                 PolicyEvent.Type = value;
                 PolicyEvent.TypedContent ??= Activator.CreateInstance(PolicyTypes[value]) as PolicyRuleEventContent;
                 PolicyData = PolicyEvent.TypedContent as PolicyRuleEventContent;
+                PolicyData.Recommendation ??= "m.ban";
             }
         }
     }
-    
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomListItem.razor b/MatrixUtils.Web/Shared/RoomListItem.razor
index bfaa900..248cb59 100644
--- a/MatrixUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixUtils.Web/Shared/RoomListItem.razor
@@ -7,13 +7,15 @@
     <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 Homeserver="hs" 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)"/> *@
+            <MxcAvatar Homeserver="Homeserver" Circular="true" Size="32" MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/>
             <span class="centerVertical border75 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "")">
                 @(OwnMemberState?.DisplayName ?? GlobalProfile?.DisplayName ?? "Loading...")
             </span>
             <span class="centerVertical noLeftPadding">-></span>
         }
-        <MxcImage Circular="true" Height="32" Width="32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/>
+        @* <MxcImage Circular="true" Height="32" Width="32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/> *@
+        <MxcAvatar Homeserver="Homeserver" Circular="true" Size="32" MxcUri="@RoomInfo.RoomIcon"/>
         <div class="inlineBlock">
             <span class="centerVertical">@RoomInfo.RoomName</span>
             @if (ChildContent is not null) {
@@ -42,8 +44,6 @@ else {
         }
     }
 
-    
-
     [Parameter]
     public bool ShowOwnProfile { get; set; } = false;
 
@@ -61,6 +61,9 @@ else {
             OnParametersSetAsync();
         }
     }
+    
+    [Parameter]
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
 
     private bool HasOldRoomVersion { get; set; } = false;
     private bool HasDangerousRoomVersion { get; set; } = false;
@@ -68,20 +71,19 @@ else {
     private static SemaphoreSlim _semaphoreSlim = new(8);
     private RoomInfo? _roomInfo;
     private bool _loadData = false;
-    private static AuthenticatedHomeserverGeneric? hs { get; set; }
 
     private bool _hooked;
-    
+
     private async Task RoomInfoChanged() {
         RoomInfo.PropertyChanged += async (_, a) => {
             if (a.PropertyName == nameof(RoomInfo.CreationEventContent)) {
                 await CheckRoomVersion();
             }
-            
+
             StateHasChanged();
         };
     }
-    
+
     // protected override async Task OnParametersSetAsync() {
     //     if (RoomInfo != null) {
     //         if (!_hooked) {
@@ -127,21 +129,24 @@ else {
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
 
-        hs ??= await RMUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
+        // hs ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        // if (hs is null) return;
 
+        if (Homeserver is null) {
+            Console.WriteLine($"RoomListItem called without homeserver");
+        }
         await CheckRoomVersion();
     }
 
     private async Task LoadOwnProfile() {
         if (!ShowOwnProfile) return;
         try {
-    // OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.UserId)).TypedContent as RoomMemberEventContent;
-            GlobalProfile ??= await hs.GetProfileAsync(hs.UserId);
+            // OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.UserId)).TypedContent as RoomMemberEventContent;
+            GlobalProfile ??= await Homeserver.GetProfileAsync(Homeserver.UserId);
         }
         catch (MatrixException e) {
             if (e is { ErrorCode: "M_FORBIDDEN" }) {
-                Console.WriteLine($"Failed to get profile for {hs.UserId}: {e.Message}");
+                Console.WriteLine($"Failed to get profile for {Homeserver.UserId}: {e.Message}");
                 ShowOwnProfile = false;
             }
             else {
@@ -151,8 +156,8 @@ else {
     }
 
     private async Task CheckRoomVersion() {
-        if (RoomInfo?.CreationEventContent is null) return; 
-        
+        if (RoomInfo?.CreationEventContent is null) return;
+
         var ce = RoomInfo.CreationEventContent;
         if (int.TryParse(ce.RoomVersion, out var rv)) {
             if (rv < 10)
@@ -163,7 +168,7 @@ else {
 
         if (RoomConstants.DangerousRoomVersions.Contains(ce.RoomVersion)) {
             HasDangerousRoomVersion = true;
-    // RoomName = "Dangerous room: " + RoomName;
+            // RoomName = "Dangerous room: " + RoomName;
         }
     }
 
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
index 81956b0..98b5a6d 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
@@ -15,7 +15,7 @@
         }
         case "m.image": {
             <i>@currentEventContent.Body</i><br/>
-            <img src="@Homeserver.ResolveMediaUri(currentEventContent.Url)">
+            @* <img src="@Homeserver.ResolveMediaUri(currentEventContent.Url)"> *@
             break;
         }
         default: {
diff --git a/MatrixUtils.Web/wwwroot/index.html b/MatrixUtils.Web/wwwroot/index.html
index 5182193..7425de2 100644
--- a/MatrixUtils.Web/wwwroot/index.html
+++ b/MatrixUtils.Web/wwwroot/index.html
@@ -57,6 +57,22 @@
                     height: window.innerHeight
                 };
             }
+
+            setImageStream = async (element, imageStream) => {
+                if(!(element instanceof HTMLElement)) {
+                    console.error("Element is not an HTMLElement", element);
+                    return;
+                }
+                
+                const arrayBuffer = await imageStream.arrayBuffer();
+                const blob = new Blob([arrayBuffer]);
+                const url = URL.createObjectURL(blob);
+                const image = document.getElementById(imageElementId);
+                image.onload = () => {
+                    URL.revokeObjectURL(url);
+                }
+                image.src = url;
+            }
         </script>
         <script src="_framework/blazor.webassembly.js"></script>
 <!--        <script>navigator.serviceWorker.register('service-worker.js');</script>-->