diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListCategoryComponent.razor b/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListCategoryComponent.razor
new file mode 100644
index 0000000..f818b62
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListCategoryComponent.razor
@@ -0,0 +1,66 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.RoomTypes
+<details>
+ <summary>
+ <span>
+ @($"{PolicyCollection.Name}: {PolicyCollection.TotalCount} policies")
+ </span>
+ <hr style="margin: revert;"/>
+ </summary>
+ <table class="table table-striped table-hover table-bordered align-middle">
+ <thead>
+ <tr>
+ @foreach (var name in PolicyCollection.PropertiesToDisplay!.Keys) {
+ <th>@name</th>
+ }
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var policy in PolicyCollection.ActivePolicies.Values.OrderBy(x => x.Policy.RawContent?["entity"]?.GetValue<string>())) {
+ <PolicyListRowComponent PolicyInfo="@policy" PolicyCollection="@PolicyCollection" Room="@Room"></PolicyListRowComponent>
+ }
+ </tbody>
+ </table>
+ <details>
+ <summary>
+ <u>
+ @("Invalid " + PolicyCollection.Name.ToLower())
+ </u>
+ </summary>
+ <table class="table table-striped table-hover table-bordered align-middle">
+ <thead>
+ <tr>
+ <th>State key</th>
+ <th>Json contents</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var policy in PolicyCollection.RemovedPolicies.Values) {
+ <tr>
+ <td>@policy.Policy.StateKey</td>
+ <td>
+ <pre>@policy.Policy.RawContent.ToJson(true, false)</pre>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </details>
+</details>
+
+@code {
+
+ [Parameter]
+ public required PolicyList.PolicyCollection PolicyCollection { get; set; }
+
+ [Parameter]
+ public required GenericRoom Room { get; set; }
+
+ protected override bool ShouldRender() {
+ // if (PolicyCollection is null) return false;
+
+ return true;
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListEditorHeader.razor b/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListEditorHeader.razor
new file mode 100644
index 0000000..e82f17d
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListEditorHeader.razor
@@ -0,0 +1,65 @@
+@using LibMatrix
+@using LibMatrix.EventTypes.Common
+@using LibMatrix.RoomTypes
+@using MatrixUtils.Web.Shared.PolicyEditorComponents
+<h3>Policy list editor - Editing @(RoomName ?? Room.RoomId)</h3>
+@if (!string.IsNullOrWhiteSpace(DraupnirShortcode)) {
+ <span style="margin-right: 2em;">Shortcode: @DraupnirShortcode</span>
+}
+@if (!string.IsNullOrWhiteSpace(RoomAlias)) {
+ <span>Alias: @RoomAlias</span>
+}
+<hr/>
+@* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
+<LinkButton OnClickAsync="@(() => {
+ CurrentlyEditingEvent = new() { Type = "", RawContent = new() };
+ return Task.CompletedTask;
+ })">Create new policy
+</LinkButton>
+<LinkButton OnClickAsync="@(() => {
+ MassCreatePolicies = true;
+ return Task.CompletedTask;
+ })">Create many new policies
+</LinkButton>
+<LinkButton OnClickAsync="@(() => ReloadStateAsync())">Refresh</LinkButton>
+
+@if (CurrentlyEditingEvent is not null) {
+ <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSaveAsync="@UpdatePolicyAsync"></PolicyEditorModal>
+}
+
+@if (MassCreatePolicies) {
+ <MassPolicyEditorModal Room="@Room" OnClose="@(() => MassCreatePolicies = false)" OnSaved="@(() => {
+ MassCreatePolicies = false;
+ // _ = LoadStatesAsync();
+ })"></MassPolicyEditorModal>
+}
+
+@code {
+ [Parameter]
+ public required GenericRoom Room { get; set; }
+
+ [Parameter]
+ public required Func<Task> ReloadStateAsync { get; set; }
+
+ private string? RoomName { get; set; }
+ private string? RoomAlias { get; set; }
+ private string? DraupnirShortcode { get; set; }
+
+ private StateEventResponse? CurrentlyEditingEvent { get; set { field = value; StateHasChanged(); } }
+ private bool MassCreatePolicies { get; set { field = value; StateHasChanged(); } }
+
+ protected override async Task OnInitializedAsync() {
+ await Task.WhenAll(
+ Task.Run(async () => { DraupnirShortcode = (await Room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId))?.Shortcode; }),
+ Task.Run(async () => { RoomAlias = (await Room.GetCanonicalAliasAsync())?.Alias; }),
+ Task.Run(async () => { RoomName = await Room.GetNameOrFallbackAsync(); })
+ );
+
+ StateHasChanged();
+ }
+
+ private async Task UpdatePolicyAsync(StateEventResponse evt) {
+ Console.WriteLine("UpdatePolicyAsync in PolicyListEditorHeader not yet implementeD!");
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListRowComponent.razor b/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListRowComponent.razor
new file mode 100644
index 0000000..11de82c
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyListComponents/PolicyListRowComponent.razor
@@ -0,0 +1,167 @@
+@using System.Reflection
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using LibMatrix.RoomTypes
+@using MatrixUtils.Web.Shared.PolicyEditorComponents
+
+@if (_isInitialized && IsVisible) {
+ <tr id="@PolicyInfo.Policy.EventId">
+ @foreach (var prop in PolicyCollection.PropertiesToDisplay.Values) {
+ if (prop.Name == "Entity") {
+ <td>
+ <span>@TruncateMxid(TypedContent.Entity)</span>
+ @foreach (var dup in PolicyInfo.MadeRedundantBy) {
+ <br/>
+ <span>Also matched by @dup.FriendlyTypeName.ToLower() <a href="@Anchor(dup.EventId)">@TruncateMxid(dup.RawContent["entity"].GetValue<string>())</a></span>
+ }
+ </td>
+ }
+ else {
+ <td>@prop.GetGetMethod()?.Invoke(TypedContent, null)</td>
+ }
+ }
+ <td>
+ <div style="display: flex; flex-direction: row; gap: 0.5em;">
+ @* @if (PowerLevels.UserHasStatePermission(Homeserver.WhoAmI.UserId, Policy.Type)) { *@
+ @if (true) {
+ <LinkButton OnClickAsync="@(() => {
+ IsEditing = true;
+ return Task.CompletedTask;
+ })">Edit
+ </LinkButton>
+ <LinkButton OnClickAsync="@RemovePolicyAsync">Remove</LinkButton>
+ @if (Policy.IsLegacyType) {
+ <LinkButton OnClickAsync="@RemovePolicyAsync">Update policy type</LinkButton>
+ }
+
+ @if (TypedContent.Entity?.StartsWith("@*:", StringComparison.Ordinal) == true) {
+ <LinkButton OnClickAsync="@ConvertToAclAsync">Convert to ACL</LinkButton>
+ }
+
+ @* @if (PolicyTypeIds[typeof(ServerPolicyRuleEventContent)].Contains(Policy.Type)) { *@
+ @* <LinkButton OnClickAsync="@(() => { *@
+ @* ServerPolicyToMakePermanent = Policy; *@
+ @* return Task.CompletedTask; *@
+ @* })">Make permanent *@
+ @* </LinkButton> *@
+ @* @if (CurrentUserIsDraupnir) { *@
+ @* <LinkButton Color="@(ActiveKicks.ContainsKey(Policy) ? "#FF0000" : null)" OnClick="@(() => DraupnirKickMatching(Policy))">Kick *@
+ @* users @(ActiveKicks.TryGetValue(Policy, out var kick) ? $"({kick})" : null) *@
+ @* </LinkButton> *@
+ @* } *@
+ // }
+ }
+ else {
+ <p>No permission to modify</p>
+ }
+ </div>
+ </td>
+ </tr>
+
+ @if (IsEditing) {
+ <PolicyEditorModal PolicyEvent="@Policy" OnClose="@(() => IsEditing = false)" OnSaveAsync="@UpdatePolicyAsync"></PolicyEditorModal>
+ }
+ @* TODO: Implement ability to turn ACLs into wildcards *@
+ @*@if (ServerPolicyToMakePermanent is not null) {
+ <ModalWindow Title="Make policy permanent">
+
+ </ModalWindow>
+ }*@
+}
+
+
+
+@code {
+
+ [Parameter]
+ public PolicyList.PolicyCollection.PolicyInfo PolicyInfo { get; set; }
+
+ [Parameter]
+ public GenericRoom Room { get; set; } = null!;
+
+ [Parameter]
+ public required PolicyList.PolicyCollection PolicyCollection { get; set; }
+
+ private StateEventResponse Policy => PolicyInfo.Policy;
+
+ private bool IsEditing {
+ get;
+ set {
+ field = value;
+ _isDirty = true;
+ StateHasChanged();
+ }
+ }
+
+ public bool IsVisible {
+ get;
+ set {
+ field = value;
+ _isDirty = true;
+ }
+ } = true;
+
+ private PolicyRuleEventContent TypedContent { get; set; }
+
+ private bool _isDirty = true;
+ private bool _isInitialized;
+
+ protected override bool ShouldRender() => _isDirty;
+
+ protected override void OnParametersSet() {
+ TypedContent = Policy.TypedContent as PolicyRuleEventContent ?? throw new InvalidOperationException("Policy must have a typed content of type PolicyRuleEventContent.");
+ _isDirty = true;
+ _isInitialized = true;
+ // Console.WriteLine($"ParametersSet {Policy.StateKey}");
+ }
+
+ private static string TruncateMxid(string? mxid) {
+ if (string.IsNullOrWhiteSpace(mxid)) return mxid;
+ var parts = mxid.Split(':', 2);
+ if (parts[0].Length > 50)
+ parts[0] = parts[0][..50] + "[...]";
+
+ if (parts is [_, { Length: > 50 }])
+ parts[1] = parts[1][..50] + "[...]";
+
+ return parts.Length == 1 ? parts[0] : $"{parts[0]}:{parts[1]}";
+ }
+
+ private async Task RemovePolicyAsync() {
+ await Room.SendStateEventAsync(Policy.Type, Policy.StateKey, new { });
+ IsVisible = false;
+ StateHasChanged();
+ // PolicyEventsByType[policyEvent.MappedType].Remove(policyEvent);
+ // await LoadStatesAsync();
+ }
+
+ private async Task UpdatePolicyAsync(StateEventResponse evt) {
+ await Room.SendStateEventAsync(Policy.Type, Policy.StateKey, Policy.RawContent);
+ // CurrentlyEditingEvent = null;
+ // await LoadStatesAsync();
+ }
+
+ private async Task UpgradePolicyAsync() {
+ Policy.RawContent["gay.rory.matrixutils.upgraded_from_type"] = Policy.Type;
+ // await LoadStatesAsync();
+ }
+
+ private async Task ConvertToAclAsync() {
+ if (Policy.RawContent.ContainsKey("entity")) {
+ var newContent = Policy.ContentAs<ServerPolicyRuleEventContent>();
+ newContent!.Entity = newContent.Entity!.Replace("@*:", "");
+ await Room.SendStateEventAsync(ServerPolicyRuleEventContent.EventId, newContent.GetDraupnir2StateKey(), newContent);
+ await Room.SendStateEventAsync(Policy.Type, Policy.StateKey!, new { });
+ IsVisible = false;
+ StateHasChanged();
+ }
+ else {
+ throw new InvalidOperationException("Policy event must contain an 'entity' field to convert to ACL.");
+ }
+ }
+
+ private string Anchor(string anchor) {
+ return $"{NavigationManager.Uri.Split('#')[0]}#{anchor}";
+ }
+
+}
\ No newline at end of file
|