about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--MatrixRoomUtils.Bot/Bot/Commands/CmdCommand.cs2
-rw-r--r--MatrixRoomUtils.Bot/MatrixRoomUtils.Bot.csproj1
-rw-r--r--MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs13
-rw-r--r--MatrixRoomUtils.Core/MatrixException.cs70
-rw-r--r--MatrixRoomUtils.Core/RoomTypes/GenericRoom.cs21
-rw-r--r--MatrixRoomUtils.Core/RoomTypes/SpaceRoom.cs17
-rw-r--r--MatrixRoomUtils.Core/Services/HomeserverProviderService.cs6
-rw-r--r--MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor2
-rw-r--r--MatrixRoomUtils.Web/Pages/RoomState/RoomStateRoomList.razor32
-rw-r--r--MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor3
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/Index.razor5
-rw-r--r--MatrixRoomUtils.Web/Shared/NavMenu.razor15
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomList.razor40
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor6
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor12
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor7
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListItem.razor131
17 files changed, 214 insertions, 169 deletions
diff --git a/MatrixRoomUtils.Bot/Bot/Commands/CmdCommand.cs b/MatrixRoomUtils.Bot/Bot/Commands/CmdCommand.cs
index c267298..66f3c4d 100644
--- a/MatrixRoomUtils.Bot/Bot/Commands/CmdCommand.cs
+++ b/MatrixRoomUtils.Bot/Bot/Commands/CmdCommand.cs
@@ -39,7 +39,7 @@ public class CmdCommand : ICommand {
             if ((output.Count > 0 && (msg + output[0]).Length > 64000) || output.Count == 0) {
                 await ctx.Room.SendMessageEventAsync("m.room.message", new() {
                     FormattedBody = $"```ansi\n{msg}\n```",
-                    Body = Markdig.Markdown.ToHtml(msg),
+                    // Body = Markdig.Markdown.ToHtml(msg),
                     Format = "org.matrix.custom.html"
                 });
                 msg = "";
diff --git a/MatrixRoomUtils.Bot/MatrixRoomUtils.Bot.csproj b/MatrixRoomUtils.Bot/MatrixRoomUtils.Bot.csproj
index 7012647..5eba4fd 100644
--- a/MatrixRoomUtils.Bot/MatrixRoomUtils.Bot.csproj
+++ b/MatrixRoomUtils.Bot/MatrixRoomUtils.Bot.csproj
@@ -22,7 +22,6 @@
   

   <ItemGroup>

     <PackageReference Include="ArcaneLibs" Version="1.0.0-preview3020494760.012ed3f" />

-    <PackageReference Include="Markdig" Version="0.31.0" />

     <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0-preview.5.23280.8" />

   </ItemGroup>

   <ItemGroup>

diff --git a/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs b/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs
index 852e1d8..060867d 100644
--- a/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs
+++ b/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs
@@ -1,3 +1,4 @@
+using System.Net.Http.Headers;
 using System.Reflection;
 using System.Text.Json;
 
@@ -19,6 +20,7 @@ public static class HttpClientExtensions {
 
 public class MatrixHttpClient : HttpClient {
     public override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
+    Console.WriteLine($"Sending request to {request.RequestUri}");
         try
         {
             HttpRequestOptionsKey<bool> WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
@@ -35,10 +37,10 @@ public class MatrixHttpClient : HttpClient {
         }
         var a = await base.SendAsync(request, cancellationToken);
         if (!a.IsSuccessStatusCode) {
-            Console.WriteLine($"Failed to send request: {a.StatusCode}");
             var content = await a.Content.ReadAsStringAsync(cancellationToken);
             if (content.StartsWith('{')) {
                 var ex = JsonSerializer.Deserialize<MatrixException>(content);
+            Console.WriteLine($"Failed to send request: {ex}");
                 if (ex?.RetryAfterMs is not null) {
                     await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
                     typeof(HttpRequestMessage).GetField("_sendStatus", BindingFlags.NonPublic | BindingFlags.Instance)?.SetValue(request, 0);
@@ -50,4 +52,13 @@ public class MatrixHttpClient : HttpClient {
         }
         return a;
     }
+    // GetFromJsonAsync
+    public async Task<T> GetFromJsonAsync<T>(string requestUri, CancellationToken cancellationToken = default) {
+        var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+        var response = await SendAsync(request, cancellationToken);
+        response.EnsureSuccessStatusCode();
+        await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+        return await JsonSerializer.DeserializeAsync<T>(responseStream, cancellationToken: cancellationToken);
+    }
 }
\ No newline at end of file
diff --git a/MatrixRoomUtils.Core/MatrixException.cs b/MatrixRoomUtils.Core/MatrixException.cs
index 50fae20..4795d6d 100644
--- a/MatrixRoomUtils.Core/MatrixException.cs
+++ b/MatrixRoomUtils.Core/MatrixException.cs
@@ -17,41 +17,41 @@ public class MatrixException : Exception {
     public int? RetryAfterMs { get; set; }
 
     public override string Message =>
-        ErrorCode switch {
+        $"{ErrorCode}: {ErrorCode switch {
             // common
-            "M_FORBIDDEN" => "You do not have permission to perform this action: " + Error,
-            "M_UNKNOWN_TOKEN" => "The access token specified was not recognised: " + Error + (SoftLogout == true ? " (soft logout)" : ""),
-            "M_MISSING_TOKEN" => "No access token was specified: " + Error,
-            "M_BAD_JSON" => "Request contained valid JSON, but it was malformed in some way: " + Error,
-            "M_NOT_JSON" => "Request did not contain valid JSON: " + Error,
-            "M_NOT_FOUND" => "The requested resource was not found: " + Error,
-            "M_LIMIT_EXCEEDED" => "Too many requests have been sent in a short period of time. Wait a while then try again: " + Error,
-            "M_UNRECOGNISED" => "The server did not recognise the request: " + Error,
-            "M_UNKOWN" => "The server encountered an unexpected error: " + Error,
+            "M_FORBIDDEN" => $"You do not have permission to perform this action: {Error}",
+            "M_UNKNOWN_TOKEN" => $"The access token specified was not recognised: {Error}{(SoftLogout == true ? " (soft logout)" : "")}",
+            "M_MISSING_TOKEN" => $"No access token was specified: {Error}",
+            "M_BAD_JSON" => $"Request contained valid JSON, but it was malformed in some way: {Error}",
+            "M_NOT_JSON" => $"Request did not contain valid JSON: {Error}",
+            "M_NOT_FOUND" => $"The requested resource was not found: {Error}",
+            "M_LIMIT_EXCEEDED" => $"Too many requests have been sent in a short period of time. Wait a while then try again: {Error}",
+            "M_UNRECOGNISED" => $"The server did not recognise the request: {Error}",
+            "M_UNKOWN" => $"The server encountered an unexpected error: {Error}",
             // endpoint specific
-            "M_UNAUTHORIZED" => "The request did not contain valid authentication information for the target of the request: " + Error,
-            "M_USER_DEACTIVATED" => "The user ID associated with the request has been deactivated: " + Error,
-            "M_USER_IN_USE" => "The user ID associated with the request is already in use: " + Error,
-            "M_INVALID_USERNAME" => "The requested user ID is not valid: " + Error,
-            "M_ROOM_IN_USE" => "The room alias requested is already taken: " + Error,
-            "M_INVALID_ROOM_STATE" => "The room associated with the request is not in a valid state to perform the request: " + Error,
-            "M_THREEPID_IN_USE" => "The threepid requested is already associated with a user ID on this server: " + Error,
-            "M_THREEPID_NOT_FOUND" => "The threepid requested is not associated with any user ID: " + Error,
-            "M_THREEPID_AUTH_FAILED" => "The provided threepid and/or token was invalid: " + Error,
-            "M_THREEPID_DENIED" => "The homeserver does not permit the third party identifier in question: " + Error,
-            "M_SERVER_NOT_TRUSTED" => "The homeserver does not trust the identity server: " + Error,
-            "M_UNSUPPORTED_ROOM_VERSION" => "The room version is not supported: " + Error,
-            "M_INCOMPATIBLE_ROOM_VERSION" => "The room version is incompatible: " + Error,
-            "M_BAD_STATE" => "The request was invalid because the state was invalid: " + Error,
-            "M_GUEST_ACCESS_FORBIDDEN" => "Guest access is forbidden: " + Error,
-            "M_CAPTCHA_NEEDED" => "Captcha needed: " + Error,
-            "M_CAPTCHA_INVALID" => "Captcha invalid: " + Error,
-            "M_MISSING_PARAM" => "Missing parameter: " + Error,
-            "M_INVALID_PARAM" => "Invalid parameter: " + Error,
-            "M_TOO_LARGE" => "The request or entity was too large: " + Error,
-            "M_EXCLUSIVE" => "The resource being requested is reserved by an application service, or the application service making the request has not created the resource: " + Error,
-            "M_RESOURCE_LIMIT_EXCEEDED" => "Exceeded resource limit: " + Error,
-            "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => "Cannot leave server notice room: " + Error,
-            _ => "Unknown error: " + new { ErrorCode, Error, SoftLogout, RetryAfterMs }.ToJson(ignoreNull: true)
-        };
+            "M_UNAUTHORIZED" => $"The request did not contain valid authentication information for the target of the request: {Error}",
+            "M_USER_DEACTIVATED" => $"The user ID associated with the request has been deactivated: {Error}",
+            "M_USER_IN_USE" => $"The user ID associated with the request is already in use: {Error}",
+            "M_INVALID_USERNAME" => $"The requested user ID is not valid: {Error}",
+            "M_ROOM_IN_USE" => $"The room alias requested is already taken: {Error}",
+            "M_INVALID_ROOM_STATE" => $"The room associated with the request is not in a valid state to perform the request: {Error}",
+            "M_THREEPID_IN_USE" => $"The threepid requested is already associated with a user ID on this server: {Error}",
+            "M_THREEPID_NOT_FOUND" => $"The threepid requested is not associated with any user ID: {Error}",
+            "M_THREEPID_AUTH_FAILED" => $"The provided threepid and/or token was invalid: {Error}",
+            "M_THREEPID_DENIED" => $"The homeserver does not permit the third party identifier in question: {Error}",
+            "M_SERVER_NOT_TRUSTED" => $"The homeserver does not trust the identity server: {Error}",
+            "M_UNSUPPORTED_ROOM_VERSION" => $"The room version is not supported: {Error}",
+            "M_INCOMPATIBLE_ROOM_VERSION" => $"The room version is incompatible: {Error}",
+            "M_BAD_STATE" => $"The request was invalid because the state was invalid: {Error}",
+            "M_GUEST_ACCESS_FORBIDDEN" => $"Guest access is forbidden: {Error}",
+            "M_CAPTCHA_NEEDED" => $"Captcha needed: {Error}",
+            "M_CAPTCHA_INVALID" => $"Captcha invalid: {Error}",
+            "M_MISSING_PARAM" => $"Missing parameter: {Error}",
+            "M_INVALID_PARAM" => $"Invalid parameter: {Error}",
+            "M_TOO_LARGE" => $"The request or entity was too large: {Error}",
+            "M_EXCLUSIVE" => $"The resource being requested is reserved by an application service, or the application service making the request has not created the resource: {Error}",
+            "M_RESOURCE_LIMIT_EXCEEDED" => $"Exceeded resource limit: {Error}",
+            "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => $"Cannot leave server notice room: {Error}",
+            _ => $"Unknown error: {new { ErrorCode, Error, SoftLogout, RetryAfterMs }.ToJson(ignoreNull: true)}"
+        }}";
 }
\ No newline at end of file
diff --git a/MatrixRoomUtils.Core/RoomTypes/GenericRoom.cs b/MatrixRoomUtils.Core/RoomTypes/GenericRoom.cs
index f57c855..879ae6b 100644
--- a/MatrixRoomUtils.Core/RoomTypes/GenericRoom.cs
+++ b/MatrixRoomUtils.Core/RoomTypes/GenericRoom.cs
@@ -44,7 +44,17 @@ public class GenericRoom {
         var url = $"/_matrix/client/v3/rooms/{RoomId}/state";
         if (!string.IsNullOrEmpty(type)) url += $"/{type}";
         if (!string.IsNullOrEmpty(stateKey)) url += $"/{stateKey}";
-        return await _httpClient.GetFromJsonAsync<T>(url);
+        try {
+            var resp = await _httpClient.GetFromJsonAsync<T>(url);
+            return resp;
+        }
+        catch (MatrixException e) {
+            if (e is not { ErrorCode: "M_NOT_FOUND" }) {
+                throw;
+            }
+            Console.WriteLine(e);
+            return default;
+        }
     }
 
     public async Task<MessagesResponse> GetMessagesAsync(string from = "", int limit = 10, string dir = "b",
@@ -56,8 +66,13 @@ public class GenericRoom {
     }
 
     public async Task<string> GetNameAsync() {
-        var res = await GetStateAsync<RoomNameEventData>("m.room.name");
-        return res.Name ?? RoomId;
+        try {
+            var res = await GetStateAsync<RoomNameEventData>("m.room.name");
+            return res?.Name ?? RoomId;
+        }
+        catch (MatrixException e) {
+            return $"{RoomId} ({e.ErrorCode})";
+        }
     }
 
     public async Task JoinAsync(string[]? homeservers = null, string? reason = null) {
diff --git a/MatrixRoomUtils.Core/RoomTypes/SpaceRoom.cs b/MatrixRoomUtils.Core/RoomTypes/SpaceRoom.cs
index 3be3130..1b93064 100644
--- a/MatrixRoomUtils.Core/RoomTypes/SpaceRoom.cs
+++ b/MatrixRoomUtils.Core/RoomTypes/SpaceRoom.cs
@@ -13,15 +13,16 @@ public class SpaceRoom : GenericRoom {
         _homeServer = homeServer;
     }
 
-    public async Task<List<GenericRoom>> GetRoomsAsync(bool includeRemoved = false) {
+    private static SemaphoreSlim _semaphore = new(1, 1);
+    public async IAsyncEnumerable<GenericRoom> GetRoomsAsync(bool includeRemoved = false) {
+        await _semaphore.WaitAsync();
         var rooms = new List<GenericRoom>();
-        var state = GetFullStateAsync().ToBlockingEnumerable().ToList();
-        var childStates = state.Where(x => x.Type == "m.space.child");
-        foreach (var stateEvent in childStates) {
-            if (stateEvent.TypedContent.ToJson() != "{}" || includeRemoved)
-                rooms.Add(await _homeServer.GetRoom(stateEvent.StateKey));
+        var state = GetFullStateAsync();
+        await foreach (var stateEvent in state) {
+            if (stateEvent.Type != "m.space.child") continue;
+            if (stateEvent.RawContent.ToJson() != "{}" || includeRemoved)
+                yield return await _homeServer.GetRoom(stateEvent.StateKey);
         }
-
-        return rooms;
+        _semaphore.Release();
     }
 }
\ No newline at end of file
diff --git a/MatrixRoomUtils.Core/Services/HomeserverProviderService.cs b/MatrixRoomUtils.Core/Services/HomeserverProviderService.cs
index 870e0d4..b2ea987 100644
--- a/MatrixRoomUtils.Core/Services/HomeserverProviderService.cs
+++ b/MatrixRoomUtils.Core/Services/HomeserverProviderService.cs
@@ -30,9 +30,9 @@ public class HomeserverProviderService {
                                   await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver);
         hs._httpClient.Dispose();
         hs._httpClient = new MatrixHttpClient { BaseAddress = new Uri(hs.FullHomeServerDomain) };
-        hs._httpClient.Timeout = TimeSpan.FromSeconds(5);
+        hs._httpClient.Timeout = TimeSpan.FromSeconds(120);
         hs._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
-        
+
         hs.WhoAmI = (await hs._httpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami"))!;
         return hs;
     }
@@ -43,7 +43,7 @@ public class HomeserverProviderService {
                                   await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver);
         hs._httpClient.Dispose();
         hs._httpClient = new MatrixHttpClient { BaseAddress = new Uri(hs.FullHomeServerDomain) };
-        hs._httpClient.Timeout = TimeSpan.FromSeconds(5);
+        hs._httpClient.Timeout = TimeSpan.FromSeconds(120);
         return hs;
     }
 
diff --git a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor b/MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor
index b2d28f6..8b2ff0c 100644
--- a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor
+++ b/MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor
@@ -1,4 +1,4 @@
-@page "/RoomStateViewer/{RoomId}/Edit"
+@page "/Rooms/{RoomId}/State/Edit"
 @using System.Net.Http.Headers
 @using System.Text.Json
 @using MatrixRoomUtils.Core.Responses
diff --git a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateRoomList.razor b/MatrixRoomUtils.Web/Pages/RoomState/RoomStateRoomList.razor
deleted file mode 100644
index 55c44d9..0000000
--- a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateRoomList.razor
+++ /dev/null
@@ -1,32 +0,0 @@
-@page "/RoomStateViewer"
-@inject ILocalStorageService LocalStorage
-@inject NavigationManager NavigationManager
-<h3>Room state viewer - Room list</h3>
-<hr/>
-@if (Rooms.Count == 0) {
-    <p>You are not in any rooms!</p>
-    @* <p>Loading progress: @checkedRoomCount/@totalRoomCount</p> *@
-}
-else {
-    @foreach (var room in Rooms) {
-        <a style="color: unset; text-decoration: unset;" href="/RoomStateViewer/@room.Replace('.', '~')">
-            <RoomListItem RoomId="@room"></RoomListItem>
-        </a>
-    }
-    <div style="margin-bottom: 4em;"></div>
-}
-
-<LogView></LogView>
-
-@code {
-    public List<string> Rooms { get; set; } = new();
-
-    protected override async Task OnInitializedAsync() {
-        await base.OnInitializedAsync();
-        var hs = await MRUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
-        Rooms = (await hs.GetJoinedRooms()).Select(x => x.RoomId).ToList();
-        Console.WriteLine("Fetched joined rooms!");
-    }
-
-}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor b/MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor
index a0072ab..09b38f0 100644
--- a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor
+++ b/MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor
@@ -1,4 +1,4 @@
-@page "/RoomStateViewer/{RoomId}"
+@page "/Rooms/{RoomId}/State/View"
 @using System.Net.Http.Headers
 @using System.Text.Json
 @using MatrixRoomUtils.Core.Responses
@@ -73,7 +73,6 @@
         await base.OnInitializedAsync();
         var hs = await MRUStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        RoomId = RoomId.Replace('~', '.');
         await LoadStatesAsync();
         Console.WriteLine("Policy list editor initialized!");
     }
diff --git a/MatrixRoomUtils.Web/Pages/Rooms/Index.razor b/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
index 20ddd0d..932748d 100644
--- a/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
@@ -1,18 +1,21 @@
 @page "/Rooms"
+@using MatrixRoomUtils.Core.StateEventTypes
 <h3>Room list</h3>
 
 @if (Rooms is not null) {
-    <RoomList Rooms="Rooms"></RoomList>
+    <RoomList Rooms="Rooms" GlobalProfile="@GlobalProfile"></RoomList>
 }
 
 
 @code {
 
     private List<GenericRoom> Rooms { get; set; }
+    private ProfileResponse GlobalProfile { get; set; }
     
     protected override async Task OnInitializedAsync() {
         var hs = await MRUStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
+        GlobalProfile = await hs.GetProfile(hs.WhoAmI.UserId);
         Rooms = await hs.GetJoinedRooms();
 
         await base.OnInitializedAsync();
diff --git a/MatrixRoomUtils.Web/Shared/NavMenu.razor b/MatrixRoomUtils.Web/Shared/NavMenu.razor
index 5f9ad8a..48d3196 100644
--- a/MatrixRoomUtils.Web/Shared/NavMenu.razor
+++ b/MatrixRoomUtils.Web/Shared/NavMenu.razor
@@ -23,19 +23,10 @@
             <h5 style="margin-left: 1em;">Main tools</h5>
             <hr style="margin-bottom: 0em;"/>
         </div>
+       
         <div class="nav-item px-3">
-            <NavLink class="nav-link" href="RoomManager">
-                <span class="oi oi-plus" aria-hidden="true"></span> Manage Rooms
-            </NavLink>
-        </div>
-        <div class="nav-item px-3">
-            <NavLink class="nav-link" href="PolicyListEditor">
-                <span class="oi oi-plus" aria-hidden="true"></span> Policy list editor
-            </NavLink>
-        </div>
-        <div class="nav-item px-3">
-            <NavLink class="nav-link" href="RoomStateViewer">
-                <span class="oi oi-plus" aria-hidden="true"></span> Room state viewer
+            <NavLink class="nav-link" href="Rooms">
+                <span class="oi oi-plus" aria-hidden="true"></span> Room list
             </NavLink>
         </div>
         @* <div class="nav-item px-3"> *@
diff --git a/MatrixRoomUtils.Web/Shared/RoomList.razor b/MatrixRoomUtils.Web/Shared/RoomList.razor
index ac2cbb3..7e002ed 100644
--- a/MatrixRoomUtils.Web/Shared/RoomList.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomList.razor
@@ -1,18 +1,29 @@
 @using MatrixRoomUtils.Web.Shared.RoomListComponents;
 @using MatrixRoomUtils.Core.StateEventTypes
 <p>@Rooms.Count rooms total, @RoomsWithTypes.Sum(x=>x.Value.Count) fetched so far...</p>
-@foreach (var category in RoomsWithTypes.OrderBy(x => x.Value.Count)) {
-    <RoomListCategory Category="@category"></RoomListCategory>
+@if(Rooms.Count != RoomsWithTypes.Sum(x=>x.Value.Count)) {
+    <p>Fetching more rooms...</p>
+    @foreach (var category in RoomsWithTypes.OrderBy(x => x.Value.Count)) {
+        <p>@category.Key (@category.Value.Count)</p>
+    }
+}
+else {
+    @foreach (var category in RoomsWithTypes.OrderBy(x => x.Value.Count)) {
+        <RoomListCategory Category="@category" GlobalProfile="@GlobalProfile"></RoomListCategory>
+    }
 }
 
 @code {
 
     [Parameter]
     public List<GenericRoom> Rooms { get; set; }
+    [Parameter]
+    public ProfileResponse? GlobalProfile { get; set; }
 
     Dictionary<string, List<GenericRoom>> RoomsWithTypes = new();
-
+    
     protected override async Task OnInitializedAsync() {
+        GlobalProfile ??= await (await MRUStorage.GetCurrentSession()!).GetProfile((await MRUStorage.GetCurrentSession()!).WhoAmI.UserId);
         if (RoomsWithTypes.Any()) return;
 
         var tasks = Rooms.Select(AddRoom);
@@ -29,27 +40,32 @@
         };
 
     
-    private static SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(8, 8);
+    private static SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(4, 4);
     private async Task AddRoom(GenericRoom room) {
         await _semaphoreSlim.WaitAsync();
-        var roomType = GetRoomTypeName((await room.GetCreateEventAsync()).Type);
+        string roomType;
+        try {
+            var createEvent = await room.GetCreateEventAsync();
+            roomType = GetRoomTypeName(createEvent.Type);
 
-        if (roomType == "Room") {
-            var shortcodeState = await room.GetStateAsync<MjolnirShortcodeEventData>("org.matrix.mjolnir.shortcode");
-            if (shortcodeState is not null) roomType = "Legacy policy room";
+            if (roomType == "Room") {
+                var shortcodeState = await room.GetStateAsync<MjolnirShortcodeEventData>("org.matrix.mjolnir.shortcode");
+                if (shortcodeState is not null) roomType = "Legacy policy room";
+            }
+        }
+        catch (MatrixException e) {
+            roomType = $"Error: {e.ErrorCode}";
         }
         
         if (!RoomsWithTypes.ContainsKey(roomType)) {
             RoomsWithTypes.Add(roomType, new List<GenericRoom>());
         }
         RoomsWithTypes[roomType].Add(room);
-
+        
     // if (RoomsWithTypes.Count % 10 == 0)
         StateHasChanged();
-        await Task.Delay(100);
+        // await Task.Delay(100);
         _semaphoreSlim.Release();
     }
 
-    private bool _isSpaceChildrenOpen = false;
-
 }
\ No newline at end of file
diff --git a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
index a7e9399..e860321 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
@@ -1,9 +1,12 @@
+@using MatrixRoomUtils.Core.StateEventTypes
 <details>
     <summary>@roomType (@rooms.Count)</summary>
     @foreach (var room in rooms) {
         <div class="room-list-item">
             <RoomListItem Room="@room" ShowOwnProfile="@(roomType == "Room")"></RoomListItem>
             <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{room.RoomId}/Timeline")">View timeline</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
+            <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{room.RoomId}/State/View")">View state</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
+            <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{room.RoomId}/State/Edit")">Edit state</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
             
             @if (roomType == "Space") {
                 <RoomListSpace Space="@room"></RoomListSpace>
@@ -17,6 +20,9 @@
 
     [Parameter]
     public KeyValuePair<string, List<GenericRoom>> Category { get; set; }
+    
+    [Parameter]
+    public ProfileResponse? GlobalProfile { get; set; }
 
     private string roomType => Category.Key;
     private List<GenericRoom> rooms => Category.Value;
diff --git a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor
new file mode 100644
index 0000000..f05ac7b
--- /dev/null
+++ b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor
@@ -0,0 +1,12 @@
+<LinkButton href="@($"/Rooms/{Room.RoomId}/Policies")">Manage policies</LinkButton>
+
+@code {
+
+    [Parameter]
+    public GenericRoom Room { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
index 5d106c3..73dc334 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
@@ -26,7 +26,12 @@
 
     protected override async Task OnInitializedAsync() {
         if (Breadcrumbs == null) throw new ArgumentNullException(nameof(Breadcrumbs));
-        Children = (await Space.AsSpace.GetRoomsAsync()).Where(x => !Breadcrumbs.Contains(x.RoomId)).ToList();
+        await Task.Delay(Random.Shared.Next(1000, 10000));
+        var rooms = Space.AsSpace.GetRoomsAsync();
+        await foreach (var room in rooms) {
+            if(Breadcrumbs.Contains(room.RoomId)) continue;
+            Children.Add(room);
+        }
         await base.OnInitializedAsync();
     }
 
diff --git a/MatrixRoomUtils.Web/Shared/RoomListItem.razor b/MatrixRoomUtils.Web/Shared/RoomListItem.razor
index 53219d6..13cc02d 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListItem.razor
@@ -2,15 +2,20 @@
 @using System.Text.Json
 @using MatrixRoomUtils.Core.Helpers
 @using MatrixRoomUtils.Core.StateEventTypes
-<div class="roomListItem" style="background-color: #ffffff11; border-radius: 25px; margin: 8px; width: fit-Content; @(hasDangerousRoomVersion ? "border: red 4px solid;" : hasOldRoomVersion ? "border: #FF0 1px solid;" : "")">
-    @if (ShowOwnProfile) {
-        <img class="imageUnloaded @(string.IsNullOrWhiteSpace(profileAvatar) ? "" : "imageLoaded")" style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height: 32px; border-radius: 50%; @(hasCustomProfileAvatar ? "border-color: red; border-width: 3px; border-style: dashed;" : "")" src="@(profileAvatar ?? "/icon-192.png")"/>
-        <span style="vertical-align: middle; margin-right: 8px; border-radius: 75px; @(hasCustomProfileName ? "background-color: red;" : "")">@(profileName ?? "Loading...")</span>
+<div class="roomListItem" id="@RoomId" style="background-color: #ffffff11; border-radius: 25px; margin: 8px; width: fit-Content; @(hasDangerousRoomVersion ? "border: red 4px solid;" : hasOldRoomVersion ? "border: #FF0 1px solid;" : "")">
+    @if (OwnMemberState != null) {
+        <img class="imageUnloaded @(string.IsNullOrWhiteSpace(OwnMemberState?.AvatarUrl ?? GlobalProfile?.AvatarUrl) ? "" : "imageLoaded")"
+             style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height: 32px; border-radius: 50%; @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "border-color: red; border-width: 3px; border-style: dashed;" : "")"
+             src="@MediaResolver.ResolveMediaUri(hs.FullHomeServerDomain, OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl ?? "/icon-192.png")"/>
+        <span style="vertical-align: middle; margin-right: 8px; border-radius: 75px; @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "background-color: red;" : "")">
+            @(OwnMemberState?.Displayname ?? GlobalProfile?.DisplayName ?? "Loading...")
+        </span>
         <span style="vertical-align: middle; padding-right: 8px; padding-left: 0px;">-></span>
     }
-    <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height:  32px; border-radius: 50%;" src="@roomIcon"/>
+    <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height:  32px; border-radius: 50%;"
+         src="@roomIcon"/>
     <div style="display: inline-block;">
-        <span style="vertical-align: middle; padding-right: 8px;">@RoomName</span>
+        <span style="vertical-align: middle; padding-right: 8px;">@roomName</span>
         @if (ChildContent is not null) {
             @ChildContent
         }
@@ -24,91 +29,105 @@
     public RenderFragment? ChildContent { get; set; }
 
     [Parameter]
-    public GenericRoom Room { get; set; }
+    public GenericRoom? Room { get; set; }
 
     [Parameter]
-    public string RoomId { get; set; }
+    public string? RoomId { get; set; }
 
     [Parameter]
     public bool ShowOwnProfile { get; set; } = false;
 
     [Parameter]
-    public string? RoomName { get; set; }
+    public RoomMemberEventData? OwnMemberState { get; set; }
 
-    private string? roomIcon { get; set; } = "/icon-192.png";
+    [Parameter]
+    public ProfileResponse? GlobalProfile { get; set; }
 
-    private string? profileAvatar { get; set; }
-    private string? profileName { get; set; }
-    private bool hasCustomProfileAvatar { get; set; } = false;
-    private bool hasCustomProfileName { get; set; } = false;
+    private string? roomName { get; set; }
+
+    private string? roomIcon { get; set; } = "/icon-192.png";
 
     private bool hasOldRoomVersion { get; set; } = false;
     private bool hasDangerousRoomVersion { get; set; } = false;
 
-    private static SemaphoreSlim _semaphoreSlim = new(128);
+    private static SemaphoreSlim _semaphoreSlim = new(8);
+    private static AuthenticatedHomeServer? hs { get; set; }
+    private static readonly string[] DangerousRoomVersions = { "1", "8" };
 
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
 
         await _semaphoreSlim.WaitAsync();
 
-        var hs = await MRUStorage.GetCurrentSessionOrNavigate();
+        hs ??= await MRUStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
 
-        if (Room == null) {
-            if (RoomId == null) {
-                throw new ArgumentNullException(nameof(RoomId));
-            }
-            Room = await hs.GetRoom(RoomId);
-        }
-        else {
-            RoomId = Room.RoomId;
+        if (Room is null && RoomId is null) {
+            throw new ArgumentNullException(nameof(RoomId));
         }
+        Room ??= await hs.GetRoom(RoomId);
+        RoomId = Room.RoomId;
 
-        RoomName ??= await Room.GetNameAsync() ?? "Unnamed room: " + RoomId;
+        await CheckRoomVersion();
+        await GetRoomInfo();
+        await LoadOwnProfile();
+        _semaphoreSlim.Release();
+    }
 
-        var ce = await Room.GetCreateEventAsync();
-        if (ce is not null) {
-            if (int.TryParse(ce.RoomVersion, out var rv) && rv < 10) {
-                hasOldRoomVersion = true;
+    private async Task LoadOwnProfile() {
+        if (!ShowOwnProfile) return;
+        try {
+            OwnMemberState ??= await Room.GetStateAsync<RoomMemberEventData>("m.room.member", hs.UserId);
+            GlobalProfile ??= await hs.GetProfile(hs.UserId, true);
+        }
+        catch (MatrixException e) {
+            if (e is { ErrorCode: "M_FORBIDDEN" }) {
+                Console.WriteLine($"Failed to get profile for {hs.UserId}: {e.Message}");
+                ShowOwnProfile = false;
             }
-            if (new[] { "1", "8" }.Contains(ce.RoomVersion)) {
-                hasDangerousRoomVersion = true;
-                RoomName = "Dangerous room: " + RoomName;
+            else {
+                throw;
             }
         }
+    }
 
-        var state = await Room.GetStateAsync<RoomAvatarEventData>("m.room.avatar");
-        if (state is not null) {
-            try {
-                var url = state.Url;
-                if (url is not null) {
-                    roomIcon = MediaResolver.ResolveMediaUri(hs.FullHomeServerDomain, url);
-                    Console.WriteLine($"Got avatar for room {RoomId}: {roomIcon} ({url})");
-                }
+    private async Task CheckRoomVersion() {
+        try {
+            var ce = await Room.GetCreateEventAsync();
+            if (int.TryParse(ce.RoomVersion, out var rv)) {
+                if (rv < 10)
+                    hasOldRoomVersion = true;
             }
-            catch (InvalidOperationException e) {
-                Console.WriteLine($"Failed to get avatar for room {RoomId}: {e.Message}\n{state.ToJson()}");
+            else // treat unstable room versions as dangerous
+                hasDangerousRoomVersion = true;
+
+            if (DangerousRoomVersions.Contains(ce.RoomVersion)) {
+                hasDangerousRoomVersion = true;
+                roomName = "Dangerous room: " + roomName;
             }
-            catch (Exception e) {
-                Console.WriteLine(e);
+        }
+        catch (MatrixException e) {
+            if (e is not { ErrorCode: "M_FORBIDDEN" }) {
+                throw;
             }
         }
+    }
+
+    private async Task GetRoomInfo() {
+        try {
+            roomName ??= await Room.GetNameAsync();
 
-        if (ShowOwnProfile) {
-            var profile = await hs.GetProfile(hs.UserId, true);
-
-            var memberState = await Room.GetStateAsync<RoomMemberEventData>("m.room.member", hs.UserId);
-            if (memberState is not null) {
-                
-                hasCustomProfileAvatar = memberState.AvatarUrl != profile.AvatarUrl;
-                profileAvatar = MediaResolver.ResolveMediaUri(hs.FullHomeServerDomain, memberState.AvatarUrl ?? profile.AvatarUrl ?? "/icon-192.png");
-                
-                hasCustomProfileName = memberState.Displayname != profile.DisplayName;
-                profileName = memberState.Displayname;
+            var state = await Room.GetStateAsync<RoomAvatarEventData>("m.room.avatar");
+            if (state?.Url is { } url) {
+                roomIcon = MediaResolver.ResolveMediaUri(hs.FullHomeServerDomain, url);
+                Console.WriteLine($"Got avatar for room {RoomId}: {roomIcon} ({url})");
+            }
+        }
+        catch (MatrixException e) {
+            if (e is not { ErrorCode: "M_FORBIDDEN" }) {
+                throw;
             }
         }
-        _semaphoreSlim.Release();
     }
 
 }
\ No newline at end of file