diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b53cefb61..ede44db83 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,31 +2,39 @@ name: .NET on: push: - branches: [ "main", "dev" ] + branches: "**" env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: build: runs-on: self-hosted steps: - uses: actions/checkout@v3 - - uses: benjlevesque/short-sha@v2.2 + + - name: Get Short SHA id: short-sha - with: - length: 7 + run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Set Lowercase Variables + id: vars + shell: bash + run: | + echo "IMAGE_NAME=$(echo ${GITHUB_REPOSITORY} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + echo "BRANCH_NAME=$(echo ${GITHUB_REF_NAME} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Modify asset versions uses: mingjun97/file-regex-replace@v1 with: regex: '\$\(SHORTHASH\)' - replacement: '${{ env.SHA }}' - flags: "g" # Optional, defaults to "g" - include: '.*' # Optional, defaults to ".*" - exclude: '.^' # Optional, defaults to '.^' - encoding: 'utf8' # Optional, defaults to 'utf8' - path: '.' # Optional, defaults to '.' + replacement: '${{ steps.short-sha.outputs.short_sha }}' + flags: "g" + include: '.*' + exclude: '.^' + encoding: 'utf8' + path: '.' + - name: Delete .js files if corresponding .ts files exist run: | #!/bin/bash @@ -38,52 +46,68 @@ jobs: rm "$jsfile" fi done + - name: Setup .NET uses: actions/setup-dotnet@v3 with: dotnet-version: 9.0.100-rc.1.24452.12 include-prerelease: true + - name: Restore workload run: dotnet workload restore + - name: Clean run: dotnet clean + - name: Restore dependencies run: dotnet restore + + - name: Clean + run: dotnet clean + - name: Build run: dotnet build --no-restore + - name: Test run: dotnet test --no-build --verbosity normal + continue-on-error: true + + - name: Publish Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: Test Results + path: "**/test_results.trx" + - name: Publish Shared Nuget Package uses: alirezanet/publish-nuget@v3.1.0 with: PROJECT_FILE_PATH: Valour/Shared/Valour.Shared.csproj VERSION_FILE_PATH: Valour/Shared/Valour.Shared.csproj - NUGET_KEY: ${{secrets.NUGET_API_KEY}} + NUGET_KEY: ${{ secrets.NUGET_API_KEY }} - name: Publish API Nuget Package uses: alirezanet/publish-nuget@v3.1.0 with: PROJECT_FILE_PATH: Valour/Sdk/Valour.Sdk.csproj VERSION_FILE_PATH: Valour/Sdk/Valour.Sdk.csproj - NUGET_KEY: ${{secrets.NUGET_API_KEY}} + NUGET_KEY: ${{ secrets.NUGET_API_KEY }} - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + uses: docker/login-action@v2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Build and push Docker image - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + uses: docker/build-push-action@v2 with: context: . push: true - tags: '${{ steps.meta.outputs.tags }}-${{ env.SHA }}' - labels: ${{ steps.meta.outputs.labels }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.BRANCH_NAME }}-${{ steps.short-sha.outputs.short_sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.BRANCH_NAME }}-latest + labels: | + org.opencontainers.image.source=${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} diff --git a/LICENSE b/LICENSE index ada1a8176..6e2ff3ec2 100644 --- a/LICENSE +++ b/LICENSE @@ -659,3 +659,15 @@ specific requirements. if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . + +----------------------------------------- + +Additional Terms - Trademark Usage: + +The name "Valour" is a trademark of Valour Software LLC, and a trademark application is pending. While the project is open-source, use of the trademark must not imply endorsement by Valour Software LLC or mislead others regarding the origin of the project. + +Forks or derivative projects may not use the name "Valour" or related branding without prior written permission from Valour Software LLC. Any use of the trademark outside the scope of this project requires explicit permission. + +While use of the trademark is not permitted for forks of the project itself, use of the mark is allowed for Valour bots, plugins, or integrations. + + diff --git a/README.md b/README.md index f80e27fda..63abe8b39 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ ![Valour logo](Valour/Client/wwwroot/media/logo/wide/logo_wide_blue_black_trans.png) +![.NET Test](https://github.com/Valour-Software/Valour/actions/workflows/dotnet.yml/badge.svg) + # Valour ### Valour is an open-source, modern chat client designed by communities for communities. @@ -64,4 +66,10 @@ Building a platform is hard work. You can directly support Valour at the followi - Bitcoin: bc1qcdzt989gszygpudjlrre0cmvkws77gagjglhav - Ethereum: 0x40B56C98Fc115f4e503d8FaBa77F8DeF6d8412F1 +## Trademark Notice + +The name "Valour" is a trademark of Valour Software LLC, and a trademark application is pending. While the project is open-source, use of the trademark must not imply endorsement by Valour Software LLC or mislead others regarding the origin of the project. + +Forks or derivative projects may not use the name "Valour" or related branding without prior written permission from Valour Software LLC. Any use of the trademark outside the scope of this project requires explicit permission. +While use of the trademark is not permitted for forks of the project itself, use of the mark is allowed for Valour bots, plugins, or integrations. If you are unsure, contact us! diff --git a/Valour.sln b/Valour.sln index a99cde34a..6554e144a 100644 --- a/Valour.sln +++ b/Valour.sln @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Valour.Database", "Valour\D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Valour.Sdk", "Valour\Sdk\Valour.Sdk.csproj", "{B192A998-9AED-40F7-BF3D-0BF7D060BEAD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Valour.Tests", "Valour\Tests\Valour.Tests.csproj", "{E0ECFCA0-767D-4E71-B54D-66BB4C9BEEC9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,6 +52,10 @@ Global {B192A998-9AED-40F7-BF3D-0BF7D060BEAD}.Debug|Any CPU.Build.0 = Debug|Any CPU {B192A998-9AED-40F7-BF3D-0BF7D060BEAD}.Release|Any CPU.ActiveCfg = Release|Any CPU {B192A998-9AED-40F7-BF3D-0BF7D060BEAD}.Release|Any CPU.Build.0 = Release|Any CPU + {E0ECFCA0-767D-4E71-B54D-66BB4C9BEEC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0ECFCA0-767D-4E71-B54D-66BB4C9BEEC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0ECFCA0-767D-4E71-B54D-66BB4C9BEEC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0ECFCA0-767D-4E71-B54D-66BB4C9BEEC9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Valour/Client/Components/DockWindows/WindowDockComponent.razor b/Valour/Client/Components/DockWindows/WindowDockComponent.razor index b976623b0..bc4388df5 100644 --- a/Valour/Client/Components/DockWindows/WindowDockComponent.razor +++ b/Valour/Client/Components/DockWindows/WindowDockComponent.razor @@ -212,7 +212,7 @@ if (firstRender) { _resizeObserver = new ResizeObserver(); - _resizeObserver.ResizeEvent.AddHandler(OnWindowResize); + _resizeObserver.ResizeEvent += OnWindowResize; await _resizeObserver.Initialize(_ref, JsRuntime, 3); } @@ -239,9 +239,7 @@ public async ValueTask DisposeAsync() { if (_resizeObserver is not null) - { await _resizeObserver.DisposeAsync(); - } } } \ No newline at end of file diff --git a/Valour/Client/Components/Eco/GlobalPayComponent.razor b/Valour/Client/Components/Eco/GlobalPayComponent.razor index 1d2105a2e..0f4825ddb 100644 --- a/Valour/Client/Components/Eco/GlobalPayComponent.razor +++ b/Valour/Client/Components/Eco/GlobalPayComponent.razor @@ -155,7 +155,7 @@ else { _errorSpan = null; - var result = await LiveModel.CreateAsync(new EcoAccount() + var result = await ClientModel.CreateAsync(new EcoAccount() { PlanetId = ISharedPlanet.ValourCentralId, UserId = ValourClient.Self.Id, diff --git a/Valour/Client/Components/Eco/PlanetPayComponent.razor b/Valour/Client/Components/Eco/PlanetPayComponent.razor index b937765ac..f3e909f8a 100644 --- a/Valour/Client/Components/Eco/PlanetPayComponent.razor +++ b/Valour/Client/Components/Eco/PlanetPayComponent.razor @@ -329,7 +329,7 @@ else { _errorSpan = null; - var result = await LiveModel.CreateAsync(new EcoAccount() + var result = await ClientModel.CreateAsync(new EcoAccount() { PlanetId = _focusedPlanet.Id, UserId = ValourClient.Self.Id, diff --git a/Valour/Client/Components/Menus/Modals/BanModal.razor b/Valour/Client/Components/Menus/Modals/BanModal.razor index 174644db2..fc879af77 100644 --- a/Valour/Client/Components/Menus/Modals/BanModal.razor +++ b/Valour/Client/Components/Menus/Modals/BanModal.razor @@ -72,7 +72,7 @@ Reason = reason }; - _result = await LiveModel.CreateAsync(ban); + _result = await ClientModel.CreateAsync(ban); if (_result.Success) { diff --git a/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIDetailsComponent.razor b/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIDetailsComponent.razor index 6db3bb692..d37168ec7 100644 --- a/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIDetailsComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIDetailsComponent.razor @@ -51,7 +51,7 @@ Channel.Name = _nameValue; Channel.Description = _descValue; - var result = await LiveModel.UpdateAsync(Channel); + var result = await ClientModel.UpdateAsync(Channel); if (!result.Success) { _errorSpan = "Failed to save changes. Try again?"; diff --git a/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIPermissionsComponent.razor b/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIPermissionsComponent.razor index 4de6a6255..596dc737a 100644 --- a/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIPermissionsComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Channels/Edit/EditCLIPermissionsComponent.razor @@ -175,7 +175,7 @@ Target.InheritsPerms = !Target.InheritsPerms; - await LiveModel.UpdateAsync(Target); + await ClientModel.UpdateAsync(Target); StateHasChanged(); } @@ -220,7 +220,7 @@ { if (_needCreate[i]) { - var result = await LiveModel.CreateAsync(_nodes[i]); + var result = await ClientModel.CreateAsync(_nodes[i]); if (!result.Success) { Console.WriteLine("Error in channel permission node creation!"); @@ -235,7 +235,7 @@ } else if (_needUpdate[i]) { - var result = await LiveModel.UpdateAsync(_nodes[i]); + var result = await ClientModel.UpdateAsync(_nodes[i]); if (!result.Success) { Console.WriteLine("Error in channel permission node update!"); diff --git a/Valour/Client/Components/Menus/Modals/KickModal.razor b/Valour/Client/Components/Menus/Modals/KickModal.razor index bddfd8822..eeec0a5b7 100644 --- a/Valour/Client/Components/Menus/Modals/KickModal.razor +++ b/Valour/Client/Components/Menus/Modals/KickModal.razor @@ -31,7 +31,7 @@ private async Task OnConfirm() { - var result = await LiveModel.DeleteAsync(Data.Member); + var result = await ClientModel.DeleteAsync(Data.Member); Console.WriteLine(result); await CloseAsync(); } diff --git a/Valour/Client/Components/Menus/Modals/Planets/CreatePlanetComponent.razor b/Valour/Client/Components/Menus/Modals/Planets/CreatePlanetComponent.razor index 499b5146f..26c2ae1b8 100644 --- a/Valour/Client/Components/Menus/Modals/Planets/CreatePlanetComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Planets/CreatePlanetComponent.razor @@ -98,7 +98,7 @@ await ValourClient.AddJoinedPlanetAsync(planet); - var mainChannel = await planet.GetPrimaryChannelAsync(); + var mainChannel = planet.GetPrimaryChannel(); await Data.Window.Layout.AddTab(await ChatChannelWindowComponent.GetDefaultContent(mainChannel)); } diff --git a/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetComponent.razor b/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetComponent.razor index c596ef80a..6a4b169ec 100644 --- a/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetComponent.razor @@ -102,7 +102,7 @@ async () => { Console.WriteLine("Confirmed planet deletion."); - var result = await LiveModel.DeleteAsync(Data.Planet); + var result = await ClientModel.DeleteAsync(Data.Planet); if (result.Success) { diff --git a/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetEconomyComponent.razor b/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetEconomyComponent.razor index 3083ac13a..d89baf097 100644 --- a/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetEconomyComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Planets/Edit/EditPlanetEconomyComponent.razor @@ -176,11 +176,11 @@ // New currency if (Currency.Id == 0) { - result = await LiveModel.CreateAsync(Currency); + result = await ClientModel.CreateAsync(Currency); } else { - result = await LiveModel.UpdateAsync(Currency); + result = await ClientModel.UpdateAsync(Currency); } if (!result.Success) @@ -204,7 +204,7 @@ PlanetId = Currency.PlanetId, }; - var result = await LiveModel.CreateAsync(newAccount); + var result = await ClientModel.CreateAsync(newAccount); if (!result.Success) { _accountError = result.Message; @@ -218,7 +218,7 @@ private async Task OnClickSaveAccount(EcoAccount account) { - var result = await LiveModel.UpdateAsync(account); + var result = await ClientModel.UpdateAsync(account); if (!result.Success) { _accountError = result.Message; @@ -233,7 +233,7 @@ private async Task OnClickDeleteAccount(EcoAccount account) { - var result = await LiveModel.DeleteAsync(account); + var result = await ClientModel.DeleteAsync(account); if (!result.Success) { _accountError = result.Message; diff --git a/Valour/Client/Components/Menus/Modals/Planets/Edit/EditRoles/EditPlanetRolesComponent.razor b/Valour/Client/Components/Menus/Modals/Planets/Edit/EditRoles/EditPlanetRolesComponent.razor index 3ec22a76c..25ee79a6c 100644 --- a/Valour/Client/Components/Menus/Modals/Planets/Edit/EditRoles/EditPlanetRolesComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Planets/Edit/EditRoles/EditPlanetRolesComponent.razor @@ -484,13 +484,13 @@ // If the id is not set, this is a new role to be created if (_role.Id == 0) { - response = await LiveModel.CreateAsync(_role); + response = await ClientModel.CreateAsync(_role); creating = true; } // Otherwise we are editing a prior role else { - response = await LiveModel.UpdateAsync(_role); + response = await ClientModel.UpdateAsync(_role); } _result = response; diff --git a/Valour/Client/Components/Menus/Modals/Users/Edit/EditThemeComponent.razor b/Valour/Client/Components/Menus/Modals/Users/Edit/EditThemeComponent.razor index 7c8a2986f..4ef7a315e 100644 --- a/Valour/Client/Components/Menus/Modals/Users/Edit/EditThemeComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Users/Edit/EditThemeComponent.razor @@ -1,7 +1,7 @@ @inject IJSRuntime JsRuntime -@using Valour.Sdk.Extensions @using System.Net.Http.Headers +@using Valour.Shared.Extensions @* Editing page *@ @if (_editingTheme is not null) { @@ -423,12 +423,12 @@ else if (_editingTheme.Id == 0) { // New theme - _editingResult = await LiveModel.CreateAsync(_editingTheme); + _editingResult = await ClientModel.CreateAsync(_editingTheme); } else { // Existing theme - _editingResult = await LiveModel.UpdateAsync(_editingTheme); + _editingResult = await ClientModel.UpdateAsync(_editingTheme); } if (!_editingResult.Value.Success) diff --git a/Valour/Client/Components/Menus/Modals/Users/Edit/EditUserInfoComponent.razor b/Valour/Client/Components/Menus/Modals/Users/Edit/EditUserInfoComponent.razor index 60882a871..33f96f75a 100644 --- a/Valour/Client/Components/Menus/Modals/Users/Edit/EditUserInfoComponent.razor +++ b/Valour/Client/Components/Menus/Modals/Users/Edit/EditUserInfoComponent.razor @@ -93,8 +93,9 @@ { var oldStatus = User.Status; User.Status = _status; - _statusChangeResult = await LiveModel.UpdateAsync(User); + _statusChangeResult = await ClientModel.UpdateAsync(User); + // Rollback change if it failed if (!_statusChangeResult.Success) { @@ -126,7 +127,8 @@ var oldTag = User.Tag; User.Tag = _tag; - _tagChangeResult = await LiveModel.UpdateAsync(User); + + _tagChangeResult = await ClientModel.UpdateAsync(User); // Rollback change if it failed if (!_tagChangeResult.Success) @@ -142,7 +144,8 @@ { var oldStatusCode = User.UserStateCode; User.UserStateCode = _statusCode; - _statusCodeChangeResult = await LiveModel.UpdateAsync(User); + + _statusCodeChangeResult = await ClientModel.UpdateAsync(User); // Rollback change if it failed if (!_statusCodeChangeResult.Success) diff --git a/Valour/Client/Components/Messages/MessageComponent.razor b/Valour/Client/Components/Messages/MessageComponent.razor index 419eddb2f..ac4df1476 100644 --- a/Valour/Client/Components/Messages/MessageComponent.razor +++ b/Valour/Client/Components/Messages/MessageComponent.razor @@ -461,7 +461,7 @@ await BuildMessage(false); if (User != null) - User.OnUpdated += OnUserUpdated; + User.Updated += OnUserUpdated; if (!IsInnerReply && !Ghost) { @@ -478,7 +478,7 @@ if (_member is not null) // There is a chance they were deleted { _member.OnRoleModified += OnMemberRoleChange; - _member.OnUpdated += OnMemberUpdated; + _member.Updated += OnMemberUpdated; } } } @@ -607,7 +607,7 @@ if (_member is not null) { - _member.OnUpdated -= OnMemberUpdated; + _member.Updated -= OnMemberUpdated; _member.OnRoleModified -= OnMemberRoleChange; } } diff --git a/Valour/Client/Components/Oauth/EditAppComponent.razor b/Valour/Client/Components/Oauth/EditAppComponent.razor index 85bcf28ae..fc7992164 100644 --- a/Valour/Client/Components/Oauth/EditAppComponent.razor +++ b/Valour/Client/Components/Oauth/EditAppComponent.razor @@ -84,7 +84,7 @@ { Data.App.RedirectUrl = _redirect; - var updateResult = await Valour.Sdk.Items.LiveModel.UpdateAsync(Data.App); + var updateResult = await Valour.Sdk.Items.ClientModel.UpdateAsync(Data.App); Console.WriteLine(updateResult.Message); @@ -105,7 +105,7 @@ "Confirm", "Cancel", async () => { - var res = await LiveModel.DeleteAsync(Data.App); + var res = await ClientModel.DeleteAsync(Data.App); if (res.Success) _iconOutput = "Deleted."; diff --git a/Valour/Client/Components/PlanetsList/PlanetListItemComponents.razor b/Valour/Client/Components/PlanetsList/PlanetListItemComponents.razor new file mode 100644 index 000000000..a82b4049b --- /dev/null +++ b/Valour/Client/Components/PlanetsList/PlanetListItemComponents.razor @@ -0,0 +1,23 @@ +@code { + + public static RenderFragment PlanetContent => + @
+ Planet +
; + + public static RenderFragment CategoryContent => + @
+ Category +
; + + public static RenderFragment ChatChannelContent => + @
+ Chat Channel +
; + + public static RenderFragment VoiceChannelContent => + @
+ Voice Channel +
; + +} \ No newline at end of file diff --git a/Valour/Client/Components/PlanetsList/PlanetListItems.cs b/Valour/Client/Components/PlanetsList/PlanetListItems.cs new file mode 100644 index 000000000..c64c26f25 --- /dev/null +++ b/Valour/Client/Components/PlanetsList/PlanetListItems.cs @@ -0,0 +1,31 @@ +using Valour.Client.Components.Utility.DragList; +using Valour.Sdk.Models; +using Valour.Shared.Models; + +namespace Valour.Client.Components.PlanetsList; + +public class PlanetListItem : DragListItem +{ + public Planet Planet { get; set; } + + public override int Depth => 0; + public override int Position => Planet.Name.FirstOrDefault(); + + public PlanetListItem(Planet planet) + { + Planet = planet; + } +} + +public class ChannelListItem : DragListItem +{ + public Channel Channel { get; set; } + + public override int Depth => Channel.Depth; + public override int Position => Channel.Position; + + public ChannelListItem(Channel channel) + { + Channel = channel; + } +} \ No newline at end of file diff --git a/Valour/Client/Components/Sidebar/ChannelList/ChannelListItem.razor b/Valour/Client/Components/Sidebar/ChannelList/ChannelListItem.razor index b37f55acf..62d60bce2 100644 --- a/Valour/Client/Components/Sidebar/ChannelList/ChannelListItem.razor +++ b/Valour/Client/Components/Sidebar/ChannelList/ChannelListItem.razor @@ -125,7 +125,7 @@ public ChannelListItem ParentComponent { get; set; } [Parameter] - public List AllChannels { get; set; } + public IReadOnlyList AllChannels { get; set; } [Parameter] public Channel Channel { get; set; } @@ -204,8 +204,8 @@ { _children.Sort((x, y) => { - var a = x.Position.GetValueOrDefault(); - var b = y.Position.GetValueOrDefault(); + var a = x.Position; + var b = y.Position; return a.CompareTo(b); }); } diff --git a/Valour/Client/Components/Sidebar/ChannelList/PlanetListComponent.razor b/Valour/Client/Components/Sidebar/ChannelList/PlanetListComponent.razor index 524e8ebf4..e1cd5a5c3 100644 --- a/Valour/Client/Components/Sidebar/ChannelList/PlanetListComponent.razor +++ b/Valour/Client/Components/Sidebar/ChannelList/PlanetListComponent.razor @@ -33,7 +33,7 @@ { } @@ -49,12 +49,10 @@ public bool Open { get; set; } = true; public List TopChannels; - public List AllChannels { get; set; } protected override async Task OnInitializedAsync() { // Initialize collections - AllChannels = new List(); TopChannels = new List(); // Handle list change @@ -65,7 +63,6 @@ ValourClient.OnCategoryOrderUpdate += OnOrderUpdate; - await GetAllChannels(); GetTopLevelItems(); } @@ -128,8 +125,8 @@ { TopChannels.Sort((x, y) => { - var a = x.Position.GetValueOrDefault(); - var b = y.Position.GetValueOrDefault(); + var a = x.Position; + var b = y.Position; return a.CompareTo(b); }); } @@ -149,11 +146,11 @@ private void GetTopLevelItems() { TopChannels.Clear(); - foreach (var item in AllChannels) + foreach (var channel in Planet.Channels) { - if (item.ParentId is null) + if (channel.ParentId is null) { - TopChannels.Add(item); + TopChannels.Add(channel); } } @@ -161,11 +158,6 @@ Console.WriteLine($"Found {TopChannels.Count} top level channels and categories"); } - - private async Task GetAllChannels() - { - AllChannels = await Planet.GetAllChannelsAsync(); - } private async Task OnClickPlanetInfo() { diff --git a/Valour/Client/Components/Theme/ThemeInfoModal.razor b/Valour/Client/Components/Theme/ThemeInfoModal.razor index 050c768a4..976c38d35 100644 --- a/Valour/Client/Components/Theme/ThemeInfoModal.razor +++ b/Valour/Client/Components/Theme/ThemeInfoModal.razor @@ -153,7 +153,7 @@ if (_myVote.Sentiment) { // delete - var result = await LiveModel.DeleteAsync(_myVote); + var result = await ClientModel.DeleteAsync(_myVote); if (result.Success) { _upvotes--; @@ -166,7 +166,7 @@ { _myVote.Sentiment = true; - var result = await LiveModel.UpdateAsync(_myVote); + var result = await ClientModel.UpdateAsync(_myVote); if (result.Success) { _upvotes++; @@ -188,7 +188,7 @@ UserId = ValourClient.Self.Id, }; - var result = await LiveModel.CreateAsync(vote); + var result = await ClientModel.CreateAsync(vote); if (result.Success) { _upvotes++; @@ -208,7 +208,7 @@ if (!_myVote.Sentiment) { // delete - var result = await LiveModel.DeleteAsync(_myVote); + var result = await ClientModel.DeleteAsync(_myVote); if (result.Success) { _downvotes--; @@ -221,7 +221,7 @@ { _myVote.Sentiment = false; - var result = await LiveModel.UpdateAsync(_myVote); + var result = await ClientModel.UpdateAsync(_myVote); if (result.Success) { _downvotes++; @@ -241,7 +241,7 @@ UserId = ValourClient.Self.Id, }; - var result = await LiveModel.CreateAsync(vote); + var result = await ClientModel.CreateAsync(vote); if (result.Success) { _downvotes++; diff --git a/Valour/Client/Components/Users/UserInfoComponent.razor b/Valour/Client/Components/Users/UserInfoComponent.razor index 927fa5747..074b45e95 100644 --- a/Valour/Client/Components/Users/UserInfoComponent.razor +++ b/Valour/Client/Components/Users/UserInfoComponent.razor @@ -111,7 +111,7 @@ if (User is not null) { UserId = User.Id; - User.OnUpdated += OnUserUpdate; + User.Updated += OnUserUpdate; } else { @@ -119,14 +119,14 @@ if (UserId != 0) { User = await User.FindAsync(UserId); - User.OnUpdated += OnUserUpdate; + User.Updated += OnUserUpdate; } } if (Member is not null) { UserId = Member.UserId; - Member.OnUpdated += OnMemberUpdate; + Member.Updated += OnMemberUpdate; Member.OnRoleModified += OnMemberRoleModified; } @@ -149,12 +149,12 @@ { if (User is not null) { - User.OnUpdated -= OnUserUpdate; + User.Updated -= OnUserUpdate; } if (Member is not null) { - Member.OnUpdated -= OnMemberUpdate; + Member.Updated -= OnMemberUpdate; Member.OnRoleModified -= OnMemberRoleModified; } } diff --git a/Valour/Client/Components/Utility/DragList/DragListComponent.razor b/Valour/Client/Components/Utility/DragList/DragListComponent.razor new file mode 100644 index 000000000..4d094c82b --- /dev/null +++ b/Valour/Client/Components/Utility/DragList/DragListComponent.razor @@ -0,0 +1,63 @@ +@inherits ControlledRenderComponentBase + +@{ + if (_items is null) + { + return; + } + + foreach (var item in _items) + { + +
+ @item.Content +
+
+ + } +} + +@code { + + [Parameter] + public float NestingMargin { get; set; } = 8f; + + [Parameter] + public float ItemHeight { get; set; } = 24f; + + // all items, sorted for render + private List _items = new(); + + /// + /// Sets the items of this drag list. Note that changes to the list will + /// pass down (stored by reference) but you must manually trigger rebuilds. + /// + public void SetTopLevelItems(List items) + { + _items = items; + } + + /// + /// Orders, + /// + public void Rebuild(bool render = true) + { + OrderItems(); + + if (render) + { + ReRender(); + } + } + + // Orders the items for display + private void OrderItems() + { + if (_items is null || _items.Count == 0) + { + return; + } + + _items.Sort(DragListItem.Compare); + } +} \ No newline at end of file diff --git a/Valour/Client/Components/Utility/DragList/DragListItem.cs b/Valour/Client/Components/Utility/DragList/DragListItem.cs new file mode 100644 index 000000000..522a7b700 --- /dev/null +++ b/Valour/Client/Components/Utility/DragList/DragListItem.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Components; + +namespace Valour.Client.Components.Utility.DragList; + +public abstract class DragListItem +{ + /// + /// The drag list this item belongs to + /// + public DragListComponent DragList { get; set; } + + /// + /// True if this item can contain other items + /// + public bool Container { get; set; } + + /// + /// True if this item is opened. Only applies to containers. + /// + public bool Open { get; set; } + + /// + /// The amount of margin to apply to children. Only applies to containers. + /// + public int ChildMargin { get; set; } + + public abstract int Depth { get; } + + public abstract int Position { get; } + + public virtual Task OnClick() + { + if (Container) + { + // Switch the open state + Open = !Open; + + // Re-render drag list + DragList.ReRender(); + } + + return Task.CompletedTask; + } + + /// + /// The content to render for this item + /// + public RenderFragment Content { get; set; } + + public static int Compare(DragListItem a, DragListItem b) + { + // Compare position + return a.Position.CompareTo(b.Position); + } +} \ No newline at end of file diff --git a/Valour/Client/Components/Windows/ChannelWindows/ChatChannelWindowComponent.razor b/Valour/Client/Components/Windows/ChannelWindows/ChatChannelWindowComponent.razor index 1048c81b1..22b067078 100644 --- a/Valour/Client/Components/Windows/ChannelWindows/ChatChannelWindowComponent.razor +++ b/Valour/Client/Components/Windows/ChannelWindows/ChatChannelWindowComponent.razor @@ -91,13 +91,13 @@ _planet = await Channel.GetPlanetAsync(); _selfMember = await _planet.GetSelfMemberAsync(); - _planet.OnDeleted += OnPlanetDeleted; - _selfMember.OnDeleted += OnSelfMemberDeleted; + _planet.Deleted += OnPlanetDeleted; + _selfMember.Deleted += OnSelfMemberDeleted; ChannelPermissions = await Channel.GetFlattenedPermissionsAsync(_selfMember.Id); } - Channel.OnUpdated += OnChatChannelUpdate; + Channel.Updated += OnChatChannelUpdate; ReRender(); @@ -221,13 +221,11 @@ await HandleWindowClose(); if (_planet is not null) - { - _planet.OnDeleted -= OnPlanetDeleted; - } - - if (_selfMember is not null) - _selfMember.OnDeleted -= OnSelfMemberDeleted; + _planet.Deleted -= OnPlanetDeleted; + if (_selfMember is not null) + _selfMember.Deleted -= OnSelfMemberDeleted; + ValourClient.OnMessageReceived -= OnReceiveMessage; _thisRef.Dispose(); } diff --git a/Valour/Client/Components/Windows/ChannelWindows/MentionSelectComponent.razor b/Valour/Client/Components/Windows/ChannelWindows/MentionSelectComponent.razor index 80dc2ea04..6ec2d9060 100644 --- a/Valour/Client/Components/Windows/ChannelWindows/MentionSelectComponent.razor +++ b/Valour/Client/Components/Windows/ChannelWindows/MentionSelectComponent.razor @@ -60,7 +60,7 @@ private Planet _planet; - private List _chatChannels; + private IReadOnlyList _chatChannels; private List _members; private List _roles; private List _users; @@ -102,7 +102,7 @@ { _planet = await Channel.GetPlanetAsync(); _members = await _planet.GetMembersAsync(); - _chatChannels = await _planet.GetChatChannelsAsync(); + _chatChannels = _planet.ChatChannels; _roles = await _planet.GetRolesAsync(); } else diff --git a/Valour/Client/Components/Windows/HomeWindows/PlanetCardComponent.razor b/Valour/Client/Components/Windows/HomeWindows/PlanetCardComponent.razor index 390444a49..e7fd3eca9 100644 --- a/Valour/Client/Components/Windows/HomeWindows/PlanetCardComponent.razor +++ b/Valour/Client/Components/Windows/HomeWindows/PlanetCardComponent.razor @@ -171,11 +171,7 @@ private async Task OnClick() { - var channel = await Planet.GetPrimaryChannelAsync(); - if (channel is null) - { - channel = (await Planet.GetChatChannelsAsync()).FirstOrDefault(); - } + var channel = Planet.GetPrimaryChannel(); if (channel is null) { // Show failure toast diff --git a/Valour/Client/ContextMenu/Menus/ChannelContextMenu.razor b/Valour/Client/ContextMenu/Menus/ChannelContextMenu.razor index c54a0f6c1..5da70b5b7 100644 --- a/Valour/Client/ContextMenu/Menus/ChannelContextMenu.razor +++ b/Valour/Client/ContextMenu/Menus/ChannelContextMenu.razor @@ -82,10 +82,9 @@ if (Data.Channel.ChannelType == ChannelTypeEnum.PlanetCategory){ var planet = await Data.Channel.GetPlanetAsync(); - var channels = await planet.GetAllChannelsAsync(); // Ensure category has no children if we are deleting it - if (channels.Any(x => x.ParentId == Data.Channel.Id)){ + if (planet.Channels.Any(x => x.ParentId == Data.Channel.Id)){ var data = new InfoModalComponent.ModalParams( "You can't delete this!", @@ -111,7 +110,7 @@ async () => { Console.WriteLine("Confirmed channel model deletion."); - var result = await LiveModel.DeleteAsync(Data.Channel); + var result = await ClientModel.DeleteAsync(Data.Channel); }, () => { diff --git a/Valour/Client/Utility/HybridEvent.cs b/Valour/Client/Utility/HybridEvent.cs deleted file mode 100644 index bf7b71251..000000000 --- a/Valour/Client/Utility/HybridEvent.cs +++ /dev/null @@ -1,182 +0,0 @@ -using Microsoft.Extensions.ObjectPool; - -namespace Valour.Client.Utility; - -/// -/// The hybrid event handler allows a given method signature to be called both -/// synchronously and asynchronously. Built for efficiency using two separate -/// lists of delegates, with list pooling to minimize allocations. -/// -public class HybridEvent : IDisposable -{ - // Synchronous and asynchronous handler lists - private readonly List> _syncHandlers = new(); - private readonly List> _asyncHandlers = new(); - - // Lock object for synchronous and asynchronous handler access - private readonly object _syncLock = new(); - private readonly object _asyncLock = new(); - - // Object pool for list reuse - private readonly ObjectPool>> _syncListPool; - private readonly ObjectPool>> _asyncListPool; - - public HybridEvent() - { - // Initialize object pools for reusing lists - _syncListPool = new DefaultObjectPool>>(new ListPolicy>()); - _asyncListPool = new DefaultObjectPool>>(new ListPolicy>()); - } - - // Add a synchronous handler - public void AddHandler(Action handler) - { - lock (_syncLock) - { - _syncHandlers.Add(handler); - } - } - - // Add an asynchronous handler - public void AddHandler(Func handler) - { - lock (_asyncLock) - { - _asyncHandlers.Add(handler); - } - } - - // Remove a synchronous handler - public void RemoveHandler(Action handler) - { - lock (_syncLock) - { - _syncHandlers.Remove(handler); - } - } - - // Remove an asynchronous handler - public void RemoveHandler(Func handler) - { - lock (_asyncLock) - { - _asyncHandlers.Remove(handler); - } - } - - // Invoke all synchronous handlers with list pooling to prevent allocation - private void InvokeSyncHandlers(TEventData data) - { - // Get a pooled list for copying handlers - var handlersCopy = _syncListPool.Get(); - - // Copy handlers while locking to prevent concurrent modifications - lock (_syncLock) - { - handlersCopy.AddRange(_syncHandlers); // Copy handlers into pooled list - } - - try - { - // Invoke all handlers - for (int i = 0; i < handlersCopy.Count; i++) - { - if (handlersCopy[i] is not null) - { - handlersCopy[i].Invoke(data); // No allocations, just iterating over the pooled list - } - } - } - finally - { - // Clear and return the list to the pool - handlersCopy.Clear(); - _syncListPool.Return(handlersCopy); - } - } - - // Invoke all asynchronous handlers concurrently with list pooling - private async Task InvokeAsyncHandlers(TEventData data) - { - // Get a pooled list for copying async handlers - var handlersCopy = _asyncListPool.Get(); - - // Copy handlers while locking to prevent concurrent modifications - lock (_asyncLock) - { - handlersCopy.AddRange(_asyncHandlers); // Copy async handlers into pooled list - } - - try - { - // Invoke all async handlers in parallel using Task.WhenAll - var tasks = new List(handlersCopy.Count); - for (int i = 0; i < handlersCopy.Count; i++) - { - if (handlersCopy[i] is not null) - { - tasks.Add(handlersCopy[i].Invoke(data)); // Add tasks to list - } - } - - await Task.WhenAll(tasks); // Wait for all async handlers to complete - } - finally - { - // Clear and return the list to the pool - handlersCopy.Clear(); - _asyncListPool.Return(handlersCopy); - } - } - - // Invoke both sync and async handlers - public async Task Invoke(TEventData data) - { - InvokeSyncHandlers(data); // Call synchronous handlers first - await InvokeAsyncHandlers(data); // Then call asynchronous handlers - } - - // Enable += and -= operators for adding/removing handlers - public static HybridEvent operator +(HybridEvent handler, Action action) - { - handler.AddHandler(action); - return handler; - } - - public static HybridEvent operator +(HybridEvent handler, Func action) - { - handler.AddHandler(action); - return handler; - } - - public static HybridEvent operator -(HybridEvent handler, Action action) - { - handler.RemoveHandler(action); - return handler; - } - - public static HybridEvent operator -(HybridEvent handler, Func action) - { - handler.RemoveHandler(action); - return handler; - } - - // Cleanup everything - public void Dispose() - { - _syncHandlers.Clear(); - _asyncHandlers.Clear(); - } - - // Custom object pooling policy for List - private class ListPolicy : PooledObjectPolicy> - { - public override List Create() => new List(); - public override bool Return(List obj) - { - obj.Clear(); // Clear the list before returning to pool - return true; - } - } -} - diff --git a/Valour/Client/Utility/NotificationNavigator.cs b/Valour/Client/Utility/NotificationNavigator.cs index b10451b84..27e61b3dc 100644 --- a/Valour/Client/Utility/NotificationNavigator.cs +++ b/Valour/Client/Utility/NotificationNavigator.cs @@ -22,7 +22,7 @@ public static async Task NavigateTo(Notification notification) if (planet is null) break; - var channel = (await planet.GetChatChannelsAsync()).FirstOrDefault(x => x.Id == notification.ChannelId); + var channel = planet.ChatChannels.FirstOrDefault(x => x.Id == notification.ChannelId); if (channel is null) break; diff --git a/Valour/Client/Utility/ResizeObserver.cs b/Valour/Client/Utility/ResizeObserver.cs index 7bcfef438..215c9735a 100644 --- a/Valour/Client/Utility/ResizeObserver.cs +++ b/Valour/Client/Utility/ResizeObserver.cs @@ -1,11 +1,12 @@ using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; +using Valour.Shared.Utilities; namespace Valour.Client.Utility; public class ResizeObserver : IAsyncDisposable { - public readonly HybridEvent ResizeEvent = new(); + public HybridEvent ResizeEvent; private ElementReference _element; private IJSRuntime _runtime; @@ -33,7 +34,9 @@ public async ValueTask DisposeAsync() _dotnetRef.Dispose(); await _service.DisposeAsync(); await _jsModule.DisposeAsync(); - ResizeEvent.Dispose(); + + if (ResizeEvent is not null) + ResizeEvent.Dispose(); GC.SuppressFinalize(this); } @@ -41,6 +44,7 @@ public async ValueTask DisposeAsync() [JSInvokable("NotifyResize")] public async Task NotifyResize(ElementDimensions dimensions) { - await ResizeEvent.Invoke(dimensions); + if (ResizeEvent is not null) + await ResizeEvent.Invoke(dimensions); } } \ No newline at end of file diff --git a/Valour/Client/Valour.Client.csproj b/Valour/Client/Valour.Client.csproj index 4f040f191..8ee8d760e 100644 --- a/Valour/Client/Valour.Client.csproj +++ b/Valour/Client/Valour.Client.csproj @@ -20,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Valour/Client/_Imports.razor b/Valour/Client/_Imports.razor index 3afe7bad0..dc0bdf935 100644 --- a/Valour/Client/_Imports.razor +++ b/Valour/Client/_Imports.razor @@ -64,6 +64,7 @@ @using Valour.Client.Components.Theme @using Valour.Client.Components.Users @using Valour.Client.Components.Utility +@using Valour.Client.Components.Utility.DragList @using Valour.Client.Components.Windows @using Valour.Client.Components.Windows.HomeWindows @using Valour.Client.Components.Windows.PlanetInfo diff --git a/Valour/Database/Channel.cs b/Valour/Database/Channel.cs index cea146254..aaefb60b7 100644 --- a/Valour/Database/Channel.cs +++ b/Valour/Database/Channel.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("channels")] -public class Channel : Item, ISharedChannel +public class Channel : Model, ISharedChannel { /////////////////////////// // Relational Properties // @@ -79,17 +79,23 @@ public class Channel : Item, ISharedChannel /// The position of the channel in the channel list /// [Column("position")] - public int? Position { get; set; } + public int Position { get; set; } /// /// If this channel inherits permissions from its parent /// [Column("inherits_perms")] - public bool? InheritsPerms { get; set; } + public bool InheritsPerms { get; set; } /// /// True if this is the default chat channel /// [Column("is_default")] - public bool? IsDefault { get; set; } + public bool IsDefault { get; set; } + + //////////////////////// + // Shared Non-columns // + //////////////////////// + public int Depth => ISharedChannel.GetDepth(this); + public int LocalPosition => ISharedChannel.GetLocalPosition(this); } diff --git a/Valour/Database/Message.cs b/Valour/Database/Message.cs index 76a012283..33be49e40 100644 --- a/Valour/Database/Message.cs +++ b/Valour/Database/Message.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("messages")] -public class Message : Item, ISharedMessage +public class Message : Model, ISharedMessage { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/Item.cs b/Valour/Database/Model.cs similarity index 89% rename from Valour/Database/Item.cs rename to Valour/Database/Model.cs index 48e1de5f3..5566ca718 100644 --- a/Valour/Database/Item.cs +++ b/Valour/Database/Model.cs @@ -1,24 +1,24 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using Valour.Shared.Models; - -namespace Valour.Database; - -public abstract class Item : ISharedItem -{ - /////////////////////// - // Entity Properties // - /////////////////////// - - [Key] - [Column("id")] - public long Id { get; set; } - - /// - /// Database items should never be directly returned to the client, - /// and they don't really have a node name. - /// - [NotMapped] - public string NodeName => "Database"; -} - +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Valour.Shared.Models; + +namespace Valour.Database; + +public abstract class Model : ISharedModel +{ + /////////////////////// + // Entity Properties // + /////////////////////// + + [Key] + [Column("id")] + public long Id { get; set; } + + /// + /// Database items should never be directly returned to the client, + /// and they don't really have a node name. + /// + [NotMapped] + public string NodeName => "Database"; +} + diff --git a/Valour/Database/OauthApp.cs b/Valour/Database/OauthApp.cs index da904b41a..dd047d08a 100644 --- a/Valour/Database/OauthApp.cs +++ b/Valour/Database/OauthApp.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("oauth_apps")] -public class OauthApp : Item, ISharedOauthApp +public class OauthApp : Model, ISharedOauthApp { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/PermissionsNode.cs b/Valour/Database/PermissionsNode.cs index 7d8c9f300..37b5f3aee 100644 --- a/Valour/Database/PermissionsNode.cs +++ b/Valour/Database/PermissionsNode.cs @@ -10,7 +10,7 @@ namespace Valour.Database; */ [Table("permissions_nodes")] -public class PermissionsNode : Item, ISharedPermissionsNode +public class PermissionsNode : Model, ISharedPermissionsNode { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/Planet.cs b/Valour/Database/Planet.cs index d3592a8cd..6d03f3f55 100644 --- a/Valour/Database/Planet.cs +++ b/Valour/Database/Planet.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("planets")] -public class Planet : Item, ISharedPlanet +public class Planet : Model, ISharedPlanet { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/PlanetBan.cs b/Valour/Database/PlanetBan.cs index 01a9cd5ea..c293f2907 100644 --- a/Valour/Database/PlanetBan.cs +++ b/Valour/Database/PlanetBan.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("planet_bans")] -public class PlanetBan : Item, ISharedPlanetBan +public class PlanetBan : Model, ISharedPlanetBan { /////////////////////////// diff --git a/Valour/Database/PlanetInvite.cs b/Valour/Database/PlanetInvite.cs index 505c43de8..c0ecd3000 100644 --- a/Valour/Database/PlanetInvite.cs +++ b/Valour/Database/PlanetInvite.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("planet_invites")] -public class PlanetInvite : Item, ISharedPlanetInvite +public class PlanetInvite : Model, ISharedPlanetInvite { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/PlanetMember.cs b/Valour/Database/PlanetMember.cs index 29e784d78..c9197ec01 100644 --- a/Valour/Database/PlanetMember.cs +++ b/Valour/Database/PlanetMember.cs @@ -8,7 +8,7 @@ namespace Valour.Database; /// Database model for a planet member /// [Table("planet_members")] -public class PlanetMember : Item, ISharedPlanetMember +public class PlanetMember : Model, ISharedPlanetMember { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/PlanetRole.cs b/Valour/Database/PlanetRole.cs index a3fb23305..4aaf036b3 100644 --- a/Valour/Database/PlanetRole.cs +++ b/Valour/Database/PlanetRole.cs @@ -5,7 +5,7 @@ namespace Valour.Database; [Table("planet_roles")] -public class PlanetRole : Item, ISharedPlanetRole +public class PlanetRole : Model, ISharedPlanetRole { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/PlanetRoleMember.cs b/Valour/Database/PlanetRoleMember.cs index 4007f95bd..45c67eedb 100644 --- a/Valour/Database/PlanetRoleMember.cs +++ b/Valour/Database/PlanetRoleMember.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("planet_role_members")] -public class PlanetRoleMember : Item, ISharedPlanetRoleMember +public class PlanetRoleMember : Model, ISharedPlanetRoleMember { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/TenorFavorite.cs b/Valour/Database/TenorFavorite.cs index c608ce04a..0576c0948 100644 --- a/Valour/Database/TenorFavorite.cs +++ b/Valour/Database/TenorFavorite.cs @@ -7,7 +7,7 @@ namespace Valour.Database; /// Represents a favorite gif or media from Tenor /// [Table("tenor_favorites")] -public class TenorFavorite : Item, ISharedTenorFavorite +public class TenorFavorite : Model, ISharedTenorFavorite { /////////////////////// // Entity Properties // diff --git a/Valour/Database/User.cs b/Valour/Database/User.cs index 171bd7783..52c182e01 100644 --- a/Valour/Database/User.cs +++ b/Valour/Database/User.cs @@ -4,7 +4,7 @@ namespace Valour.Database; [Table("users")] -public class User : Item, ISharedUser +public class User : Model, ISharedUser { /////////////////////////// // Relational Properties // diff --git a/Valour/Database/UserFriend.cs b/Valour/Database/UserFriend.cs index 3b6da62db..47ff809ff 100644 --- a/Valour/Database/UserFriend.cs +++ b/Valour/Database/UserFriend.cs @@ -10,7 +10,7 @@ namespace Valour.Database; /// ... I'll be your friend! /// [Table("user_friends")] -public class UserFriend : Item, ISharedUserFriend +public class UserFriend : Model, ISharedUserFriend { /////////////////////////// // Relational Properties // diff --git a/Valour/Sdk/Client/ValourCache.cs b/Valour/Sdk/Client/ValourCache.cs index e8ddfe857..4bebdd789 100644 --- a/Valour/Sdk/Client/ValourCache.cs +++ b/Valour/Sdk/Client/ValourCache.cs @@ -18,7 +18,7 @@ public static class ValourCache /// /// Places an item into the cache /// - public static async Task Put(object id, T obj, bool skipEvent = false, int flags = 0) where T : LiveModel + public static async Task Put(object id, T obj, bool skipEvent = false, int flags = 0) where T : ClientModel { // Empty object is ignored if (obj == null) @@ -45,7 +45,7 @@ public static async Task Put(object id, T obj, bool skipEvent = false, int fl /// /// Returns true if the cache contains the item /// - public static bool Contains(object id) where T : LiveModel + public static bool Contains(object id) where T : ClientModel { var type = typeof(T); @@ -73,7 +73,7 @@ public static IEnumerable GetAll() where T : class /// /// Returns the item for the given id, or null if it does not exist /// - public static T Get(object id) where T : LiveModel + public static T Get(object id) where T : ClientModel { var type = typeof(T); @@ -87,7 +87,7 @@ public static T Get(object id) where T : LiveModel /// /// Removes an item if present in the cache /// - public static void Remove(object id) where T : LiveModel + public static void Remove(object id) where T : ClientModel { var type = typeof(T); diff --git a/Valour/Sdk/Client/ValourClient.cs b/Valour/Sdk/Client/ValourClient.cs index 683dabaf0..95ad969ef 100644 --- a/Valour/Sdk/Client/ValourClient.cs +++ b/Valour/Sdk/Client/ValourClient.cs @@ -3,12 +3,12 @@ using System.Net.Http.Json; using System.Text.Json; using System.Web; -using Valour.Sdk.Extensions; using Valour.Sdk.Models.Messages.Embeds; using Valour.Sdk.Models.Economy; using Valour.Sdk.Nodes; using Valour.Shared; using Valour.Shared.Channels; +using Valour.Shared.Extensions; using Valour.Shared.Models; namespace Valour.Sdk.Client; @@ -394,7 +394,7 @@ public static async Task LeavePlanetAsync(Planet planet) { // Get member var member = await planet.GetMemberByUserAsync(ValourClient.Self.Id); - var result = await LiveModel.DeleteAsync(member); + var result = await ClientModel.DeleteAsync(member); if (result.Success) { @@ -670,7 +670,7 @@ public static async Task OpenPlanetConnection(Planet planet, string key) tasks.Add(planet.LoadMemberDataAsync()); // Load channels - tasks.Add(planet.LoadChannelsAsync()); + tasks.Add(planet.FetchChannelsAsync()); // Load permissions nodes tasks.Add(planet.LoadPermissionsNodesAsync()); @@ -952,7 +952,7 @@ public static async Task HandleUpdateChannelState(ChannelStateUpdate update) /// /// Updates an item's properties /// - public static async Task UpdateItem(T updated, int flags, bool skipEvent = false) where T : LiveModel + public static async Task UpdateItem(T updated, int flags, bool skipEvent = false) where T : ClientModel { // printing to console is SLOW, only turn on for debugging reasons //Console.WriteLine("Update for " + updated.Id + ", skipEvent is " + skipEvent); @@ -1013,7 +1013,7 @@ public static async Task UpdateItem(T updated, int flags, bool skipEvent = fa /// /// Updates an item's properties /// - public static async Task DeleteItem(T item) where T : LiveModel + public static async Task DeleteItem(T item) where T : ClientModel { var local = ValourCache.Get(item.Id); @@ -1478,10 +1478,8 @@ public static async Task JoinAllChannelsAsync() await ValourCache.Put(planet.Id, planet); await OpenPlanetConnection(planet, "bot-init"); - - var channels = await planet.GetChatChannelsAsync(); - - foreach (var channel in channels) + + foreach (var channel in planet.Channels) { await OpenPlanetChannelConnection(channel, "bot-init"); } diff --git a/Valour/Sdk/Extensions/CopyToExtension.cs b/Valour/Sdk/Extensions/CopyToExtension.cs deleted file mode 100644 index c4318d296..000000000 --- a/Valour/Sdk/Extensions/CopyToExtension.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Valour.Sdk.Extensions -{ - public static class CopyToExtension - { - public static void CopyAllTo(this T source, T target) - { - var type = typeof(T); - foreach (var sourceProperty in type.GetProperties()) - { - var targetProperty = type.GetProperty(sourceProperty.Name); - if (targetProperty.CanWrite) - targetProperty.SetValue(target, sourceProperty.GetValue(source, null), null); - } - foreach (var sourceField in type.GetFields()) - { - var targetField = type.GetField(sourceField.Name); - if (!targetField.IsStatic) - targetField.SetValue(target, sourceField.GetValue(source)); - } - } - - public static void CopyAllNonDefaultTo(this T source, T target) - { - var type = typeof(T); - foreach (var sourceProperty in type.GetProperties()) - { - var targetProperty = type.GetProperty(sourceProperty.Name); - if (targetProperty.CanWrite) - { - var sourceValue = sourceProperty.GetValue(source, null); - if (sourceValue != null && !IsDefaultValue(sourceValue)) - { - targetProperty.SetValue(target, sourceValue, null); - } - } - } - foreach (var sourceField in type.GetFields()) - { - var targetField = type.GetField(sourceField.Name); - if (!targetField.IsStatic) - { - var sourceValue = sourceField.GetValue(source); - if (sourceValue != null && !IsDefaultValue(sourceValue)) - { - targetField.SetValue(target, sourceValue); - } - } - } - } - - private static bool IsDefaultValue(T value) - { - return EqualityComparer.Default.Equals(value, default(T)); - } - } -} diff --git a/Valour/Sdk/Items/LiveModel.cs b/Valour/Sdk/Items/ClientModel.cs similarity index 85% rename from Valour/Sdk/Items/LiveModel.cs rename to Valour/Sdk/Items/ClientModel.cs index afb927ef0..4957a2543 100644 --- a/Valour/Sdk/Items/LiveModel.cs +++ b/Valour/Sdk/Items/ClientModel.cs @@ -1,147 +1,148 @@ -using System.Text.Json.Serialization; -using Valour.Sdk.Client; -using Valour.Sdk.Nodes; -using Valour.Shared; -using Valour.Shared.Models; - -namespace Valour.Sdk.Items -{ - /// - /// A live model is a model that is updated in real time - /// - public abstract class LiveModel : ISharedItem - { - public long Id { get; set; } - - [JsonIgnore] - public virtual string IdRoute => $"{BaseRoute}/{Id}"; - - [JsonIgnore] - public virtual string BaseRoute => $"api/{GetType().Name}"; - - /// - /// Ran when this item is updated - /// - public event Func OnUpdated; - - /// - /// Ran when this item is deleted - /// - public event Func OnDeleted; - - /// - /// Custom logic on model update - /// - public virtual Task OnUpdate(ModelUpdateEvent eventData) - { - return Task.CompletedTask; - } - - /// - /// Custom logic on model deletion - /// - public virtual Task OnDelete() - { - return Task.CompletedTask; - } - - [JsonIgnore] - public Node Node - { - get - { - switch (this) - { - case Planet planet: - // Planets have node known - return NodeManager.GetNodeFromName(planet.NodeName); - case IPlanetModel planetItem: - { - // Doesn't actually have a planet - if (planetItem.Id == -1) - return ValourClient.PrimaryNode; - - // Planet items can just check their planet - return NodeManager.GetKnownByPlanet(planetItem.PlanetId); - } - default: - // Everything else can just use the primary node - return ValourClient.PrimaryNode; - } - } - } - - /// - /// This exists because of some type weirdness in C# - /// Basically, if we do not use a generic, for some reason the cache does not - /// insert into the right type. So yes, it's weird the item has to be passed in to - /// its own method, but it works. - /// - public virtual async Task AddToCache(T item, bool skipEvent = false) where T : LiveModel - { - await ValourCache.Put(this.Id, item, skipEvent); - } - - /// - /// Safely invokes the updated event - /// - public async Task InvokeUpdatedEventAsync(ModelUpdateEvent eventData) - { - await OnUpdate(eventData); - - if (OnUpdated != null) - await OnUpdated.Invoke(eventData); - } - - /// - /// Safely invokes the deleted event - /// - public async Task InvokeDeletedEventAsync() - { - await OnDelete(); - - if (OnDeleted != null) - await OnDeleted.Invoke(); - } - - /// - /// Attempts to create this item - /// - /// The type of object being created - /// The item to create - /// The result, with the created item (if successful) - public static async Task> CreateAsync(T item) where T : LiveModel - { - Node node; - - if (item is IPlanetModel planetItem) - node = await NodeManager.GetNodeForPlanetAsync(planetItem.PlanetId); - else - node = ValourClient.PrimaryNode; - - return await node.PostAsyncWithResponse(item.BaseRoute, item); - } - - /// - /// Attempts to update this item - /// - /// The type of object being created - /// The item to update - /// The result, with the updated item (if successful) - public static async Task> UpdateAsync(T item) where T : LiveModel - { - return await item.Node.PutAsyncWithResponse(item.IdRoute, item); - } - - /// - /// Attempts to delete this item - /// - /// The type of object being deleted - /// The item to delete - /// The result - public static async Task DeleteAsync(T item) where T : LiveModel - { - return await item.Node.DeleteAsync(item.IdRoute); - } - } -} +using System.Text.Json.Serialization; +using Valour.Sdk.Client; +using Valour.Sdk.Nodes; +using Valour.Shared; +using Valour.Shared.Models; +using Valour.Shared.Utilities; + +namespace Valour.Sdk.Items +{ + /// + /// A live model is a model that is updated in real time + /// + public abstract class ClientModel : ISharedModel + { + public long Id { get; set; } + + [JsonIgnore] + public virtual string IdRoute => $"{BaseRoute}/{Id}"; + + [JsonIgnore] + public virtual string BaseRoute => $"api/{GetType().Name}"; + + /// + /// Ran when this item is updated + /// + public HybridEvent Updated; + + /// + /// Ran when this item is deleted + /// + public HybridEvent Deleted; + + /// + /// Custom logic on model update + /// + protected virtual Task OnUpdated(ModelUpdateEvent eventData) + { + return Task.CompletedTask; + } + + /// + /// Custom logic on model deletion + /// + protected virtual Task OnDeleted() + { + return Task.CompletedTask; + } + + [JsonIgnore] + public Node Node + { + get + { + switch (this) + { + case Planet planet: + // Planets have node known + return NodeManager.GetNodeFromName(planet.NodeName); + case IPlanetModel planetItem: + { + // Doesn't actually have a planet + if (planetItem.Id == -1) + return ValourClient.PrimaryNode; + + // Planet items can just check their planet + return NodeManager.GetKnownByPlanet(planetItem.PlanetId); + } + default: + // Everything else can just use the primary node + return ValourClient.PrimaryNode; + } + } + } + + /// + /// This exists because of some type weirdness in C# + /// Basically, if we do not use a generic, for some reason the cache does not + /// insert into the right type. So yes, it's weird the item has to be passed in to + /// its own method, but it works. + /// + public virtual async Task AddToCache(T item, bool skipEvent = false) where T : ClientModel + { + await ValourCache.Put(this.Id, item, skipEvent); + } + + /// + /// Safely invokes the updated event + /// + public async Task InvokeUpdatedEventAsync(ModelUpdateEvent eventData) + { + await OnUpdated(eventData); + + if (Updated != null) + await Updated.Invoke(eventData); + } + + /// + /// Safely invokes the deleted event + /// + public async Task InvokeDeletedEventAsync() + { + await OnDeleted(); + + if (Deleted != null) + await Deleted.Invoke(); + } + + /// + /// Attempts to create this item + /// + /// The type of object being created + /// The item to create + /// The result, with the created item (if successful) + public static async Task> CreateAsync(T item) where T : ClientModel + { + Node node; + + if (item is IPlanetModel planetItem) + node = await NodeManager.GetNodeForPlanetAsync(planetItem.PlanetId); + else + node = ValourClient.PrimaryNode; + + return await node.PostAsyncWithResponse(item.BaseRoute, item); + } + + /// + /// Attempts to update this item + /// + /// The type of object being created + /// The item to update + /// The result, with the updated item (if successful) + public static async Task> UpdateAsync(T item) where T : ClientModel + { + return await item.Node.PutAsyncWithResponse(item.IdRoute, item); + } + + /// + /// Attempts to delete this item + /// + /// The type of object being deleted + /// The item to delete + /// The result + public static async Task DeleteAsync(T item) where T : ClientModel + { + return await item.Node.DeleteAsync(item.IdRoute); + } + } +} diff --git a/Valour/Sdk/Items/ModelObserver.cs b/Valour/Sdk/Items/ModelObserver.cs index 7632f50c9..544b16c62 100644 --- a/Valour/Sdk/Items/ModelObserver.cs +++ b/Valour/Sdk/Items/ModelObserver.cs @@ -5,7 +5,7 @@ namespace Valour.Sdk.Items /// /// The ModelObserver class allows global events to be hooked for entire item types /// - public static class ModelObserver where T : LiveModel + public static class ModelObserver where T : ClientModel { /// /// Run when any of this item type is updated diff --git a/Valour/Sdk/Models/Channel.cs b/Valour/Sdk/Models/Channel.cs index e914d12c1..6951f46f3 100644 --- a/Valour/Sdk/Models/Channel.cs +++ b/Valour/Sdk/Models/Channel.cs @@ -11,12 +11,17 @@ namespace Valour.Sdk.Models; -public class Channel : LiveModel, IChannel, ISharedChannel, IPlanetModel +public class Channel : ClientModel, IChannel, ISharedChannel, IPlanetModel { // Cached values // Will only be used for planet channels - private List PermissionsNodes { get; set; } - private List MemberUsers { get; set; } + private List _permissionNodes; + private List _memberUsers; + + /// + /// Cached parent which should be linked when channels are received + /// + public Channel Parent { get; set; } public override string BaseRoute => $"api/channels"; @@ -78,21 +83,37 @@ long IPlanetModel.PlanetId /// The id of the parent of the channel, if any /// public long? ParentId { get; set; } + + /// + /// The position of the channel. Works as the following: + /// [8 bits]-[8 bits]-[8 bits]-[8 bits] + /// Each 8 bits is a category, with the first category being the top level + /// So for example, if a channel is in the 3rd category of the 2nd category of the 1st category, + /// [00000011]-[00000010]-[00000001]-[00000000] + /// This does limit the depth of categories to 4, and the highest position + /// to 254 (since 000 means no position) + /// + public int Position { get; set; } + + /// + /// The depth, or how many categories deep the channel is + /// + public int Depth => ISharedChannel.GetDepth(this); /// - /// The position of the channel in the channel list + /// The position of the channel within its parent /// - public int? Position { get; set; } + public int LocalPosition => ISharedChannel.GetLocalPosition(this); /// /// If this channel inherits permissions from its parent /// - public bool? InheritsPerms { get; set; } + public bool InheritsPerms { get; set; } /// /// If this channel is the default channel /// - public bool? IsDefault { get; set; } + public bool IsDefault { get; set; } /// /// Returns the channel for the given id. Requires planetId for @@ -271,10 +292,10 @@ public async Task GetPermNodeAsync(long roleId, ChannelTypeEnum if (type is null) type = ChannelType; - if (PermissionsNodes is null || refresh) + if (_permissionNodes is null || refresh) await LoadPermissionNodesAsync(refresh); - return PermissionsNodes!.FirstOrDefault(x => x.RoleId == roleId && x.TargetType == type); + return _permissionNodes!.FirstOrDefault(x => x.RoleId == roleId && x.TargetType == type); } /// @@ -285,15 +306,15 @@ private async Task LoadPermissionNodesAsync(bool refresh = false) var planet = await GetPlanetAsync(); var allPermissions = await planet.GetPermissionsNodesAsync(refresh); - if (PermissionsNodes is not null) - PermissionsNodes.Clear(); + if (_permissionNodes is not null) + _permissionNodes.Clear(); else - PermissionsNodes = new List(); + _permissionNodes = new List(); foreach (var node in allPermissions) { if (node.TargetId == Id) - PermissionsNodes.Add(node); + _permissionNodes.Add(node); } } @@ -353,8 +374,7 @@ public async Task HasPermissionAsync(PlanetMember member, Permission permi var target = this; // Move up until no longer inheriting - while (target.InheritsPerms is not null && - target.InheritsPerms.Value && + while (target.InheritsPerms && target.ParentId is not null) { target = await target.GetParentAsync(); @@ -537,12 +557,12 @@ public async Task> GetChannelMemberUsersAsync(bool refresh = false) { var result = await ValourClient.PrimaryNode.GetJsonAsync>(IdRoute + "/nonPlanetMembers"); if (result.Success) - MemberUsers = result.Data; + _memberUsers = result.Data; else return new List(); } - return MemberUsers; + return _memberUsers; } public string GetDescription() @@ -668,4 +688,9 @@ public async Task Close(string key) break; } } + + public int Compare(Channel x, Channel y) + { + return x.Position.CompareTo(y.Position); + } } diff --git a/Valour/Sdk/Models/ChannelMember.cs b/Valour/Sdk/Models/ChannelMember.cs index 5f377f46a..2ee94f0f3 100644 --- a/Valour/Sdk/Models/ChannelMember.cs +++ b/Valour/Sdk/Models/ChannelMember.cs @@ -4,7 +4,7 @@ namespace Valour.Sdk.Models; /// Channel members represent members of a channel that is not a planet channel /// In direct message channels there will only be two members, but in group channels there can be more /// -public class ChannelMember : LiveModel +public class ChannelMember : ClientModel { /// /// Id of the channel this member belongs to diff --git a/Valour/Sdk/Models/Economy/Currency.cs b/Valour/Sdk/Models/Economy/Currency.cs index 28d854931..cd08dfcb3 100644 --- a/Valour/Sdk/Models/Economy/Currency.cs +++ b/Valour/Sdk/Models/Economy/Currency.cs @@ -7,7 +7,7 @@ namespace Valour.Sdk.Models.Economy; /// /// Currencies represent one *type* of cash, declared by a community. /// -public class Currency : LiveModel, ISharedCurrency +public class Currency : ClientModel, ISharedCurrency { public override string BaseRoute => "api/eco/currencies"; diff --git a/Valour/Sdk/Models/Economy/EcoAccount.cs b/Valour/Sdk/Models/Economy/EcoAccount.cs index 7efbe397c..76b4d5912 100644 --- a/Valour/Sdk/Models/Economy/EcoAccount.cs +++ b/Valour/Sdk/Models/Economy/EcoAccount.cs @@ -20,7 +20,7 @@ namespace Valour.Sdk.Models.Economy; /// If you have a currency with two decimal places, and you attempt to /// subtract 0.333... from cash, it will end up subtracting 0.33. /// -public class EcoAccount : LiveModel, ISharedEcoAccount +public class EcoAccount : ClientModel, ISharedEcoAccount { public override string BaseRoute => "api/eco/accounts"; diff --git a/Valour/Sdk/Models/IChannel.cs b/Valour/Sdk/Models/IChannel.cs index 931582a73..04888c269 100644 --- a/Valour/Sdk/Models/IChannel.cs +++ b/Valour/Sdk/Models/IChannel.cs @@ -1,7 +1,7 @@ using Valour.Shared.Models; namespace Valour.Sdk.Models; -public interface IChannel : ISharedItem +public interface IChannel : ISharedModel { public string Name { get; set; } public string Description { get; set; } diff --git a/Valour/Sdk/Models/IOrderedModel.cs b/Valour/Sdk/Models/IOrderedModel.cs deleted file mode 100644 index 6be750f94..000000000 --- a/Valour/Sdk/Models/IOrderedModel.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Valour.Sdk.Models; - -public interface IOrderedModel -{ - public int Position { get; set; } -} \ No newline at end of file diff --git a/Valour/Sdk/Models/IPlanetModel.cs b/Valour/Sdk/Models/IPlanetModel.cs index 24eccdb4b..5d17499ef 100644 --- a/Valour/Sdk/Models/IPlanetModel.cs +++ b/Valour/Sdk/Models/IPlanetModel.cs @@ -2,7 +2,7 @@ namespace Valour.Sdk.Models { - public interface IPlanetModel : ISharedItem + public interface IPlanetModel : ISharedModel { public long PlanetId { get; set; } diff --git a/Valour/Sdk/Models/InitialPlanetData.cs b/Valour/Sdk/Models/InitialPlanetData.cs new file mode 100644 index 000000000..835e28ae3 --- /dev/null +++ b/Valour/Sdk/Models/InitialPlanetData.cs @@ -0,0 +1,34 @@ +namespace Valour.Sdk.Models; + +/// +/// Rather than using multiple API calls to get the initial data for a planet, +/// we can use this class to store all the data we need in one go. +/// +public class InitialPlanetData +{ + /// + /// The planet this data was requested for + /// + public long PlanetId { get; set; } + + /// + /// The roles within the planet + /// + public List Roles { get; set; } + + /// + /// The channels within the planet that the user has access to + /// + public List Channels { get; set; } + + /// + /// Initial member data. Will include most recently active members, but may not + /// include all members + /// + public List MemberData { get; set; } + + /// + /// The + /// + public List Permissions { get; set; } +} \ No newline at end of file diff --git a/Valour/Sdk/Models/Message.cs b/Valour/Sdk/Models/Message.cs index 7b48a2ab5..ffe272b47 100644 --- a/Valour/Sdk/Models/Message.cs +++ b/Valour/Sdk/Models/Message.cs @@ -8,7 +8,7 @@ namespace Valour.Sdk.Models; -public class Message : LiveModel, ISharedMessage +public class Message : ClientModel, ISharedMessage { public override string BaseRoute => $"api/channels/{ChannelId}/messages"; diff --git a/Valour/Sdk/Models/Notification.cs b/Valour/Sdk/Models/Notification.cs index 1112086a1..78c080849 100644 --- a/Valour/Sdk/Models/Notification.cs +++ b/Valour/Sdk/Models/Notification.cs @@ -2,7 +2,7 @@ namespace Valour.Sdk.Models; -public class Notification : LiveModel, ISharedNotification +public class Notification : ClientModel, ISharedNotification { /// /// The user the notification was sent to diff --git a/Valour/Sdk/Models/OauthApp.cs b/Valour/Sdk/Models/OauthApp.cs index 1243090d9..ddf3f63b6 100644 --- a/Valour/Sdk/Models/OauthApp.cs +++ b/Valour/Sdk/Models/OauthApp.cs @@ -4,7 +4,7 @@ namespace Valour.Sdk.Models; -public class OauthApp : LiveModel, ISharedOauthApp +public class OauthApp : ClientModel, ISharedOauthApp { #region IPlanetModel implementation diff --git a/Valour/Sdk/Models/PermissionsNode.cs b/Valour/Sdk/Models/PermissionsNode.cs index 65c2dc559..4febb22fe 100644 --- a/Valour/Sdk/Models/PermissionsNode.cs +++ b/Valour/Sdk/Models/PermissionsNode.cs @@ -10,7 +10,7 @@ namespace Valour.Sdk.Models; * A copy of the license should be included - if not, see */ -public class PermissionsNode : LiveModel, ISharedPermissionsNode +public class PermissionsNode : ClientModel, ISharedPermissionsNode { /// /// The planet this node belongs to diff --git a/Valour/Sdk/Models/Planet.cs b/Valour/Sdk/Models/Planet.cs index 49d1914ff..a446f07ef 100644 --- a/Valour/Sdk/Models/Planet.cs +++ b/Valour/Sdk/Models/Planet.cs @@ -11,17 +11,50 @@ namespace Valour.Sdk.Models; * This program is subject to the GNU Affero General Public license * A copy of the license should be included - if not, see */ -public class Planet : LiveModel, ISharedPlanet +public class Planet : ClientModel, ISharedPlanet { public override string BaseRoute => $"api/planets"; // Cached values - private PlanetModelObserver AllChannels { get; set; } - private PlanetModelObserver ChatChannels { get; set; } - private PlanetModelObserver VoiceChannels { get; set; } - private PlanetModelObserver Categories { get; set; } + // A note to future Spike: + // These are created at construction because they can be referred to and will *never* have their + // reference change. The lists are updated in realtime which means UI watching the lists do not + // need to get an updated list. Do not second guess this decision. It is correct. + // - Spike, 10/05/2024 + + /// + /// The channels in this planet + /// + public IReadOnlyList Channels { get; private set; } + + /// + /// The chat channels in this planet + /// + public IReadOnlyList ChatChannels { get; private set; } + + /// + /// The voice channels in this planet + /// + public IReadOnlyList VoiceChannels { get; private set; } + + /// + /// The categories in this planet + /// + public IReadOnlyList Categories { get; private set; } + + // Internal channel lists + private List _channels; + private List _chatChannels; + private List _voiceChannels; + private List _categories; + + /// + /// The primary (default) chat channel of the planet + /// + public Channel PrimaryChatChannel { get; set; } + private List Roles { get; set; } private List Members { get; set; } private List Invites { get; set; } @@ -72,16 +105,15 @@ public class Planet : LiveModel, ISharedPlanet /// public bool Nsfw { get; set; } - public Planet() + #region Child Event Handlers + + public void NotifyChannelUpdate(Channel channel, ModelUpdateEvent eventData) { - // Setup self-observing collections - AllChannels = new(this); - ChatChannels = new(this); - VoiceChannels = new(this); - Categories = new(this); + if (_channels is null || channel.PlanetId != Id) + return; + + InsertChannelIntoLists(channel); } - - #region Child Event Handlers public Task NotifyRoleUpdateAsync(PlanetRole role, ModelUpdateEvent eventData) @@ -162,12 +194,10 @@ public static async ValueTask FindAsync(long id, bool refresh = false) /// /// Returns the primary channel of the planet /// - public async ValueTask GetPrimaryChannelAsync(bool refresh = false) + public Channel GetPrimaryChannel() { - if (!ChatChannels.Initialized || refresh) - await LoadChannelsAsync(); - - return ChatChannels.FirstOrDefault(x => x.IsDefault == true); + var primary = _chatChannels.FirstOrDefault(x => x.IsDefault == true); + return primary ?? _chatChannels.FirstOrDefault(); } public async ValueTask GetDefaultRoleAsync(bool refresh = false) @@ -178,83 +208,140 @@ public async ValueTask GetDefaultRoleAsync(bool refresh = false) return Roles?.FirstOrDefault(x => x.IsDefault); } - public async ValueTask> GetAllChannelsAsync(bool refresh = false) + private void ClearChannels() { - if (!AllChannels.Initialized || refresh) - await LoadChannelsAsync(); + if (_channels is null) + { + _channels = new(); + Channels = _channels; + } + else + { + _channels.Clear(); + } - return AllChannels.GetContents(); + if (_chatChannels is null) + { + _chatChannels = new(); + ChatChannels = _chatChannels; + } + else + { + _chatChannels.Clear(); + } + + if (_voiceChannels is null) + { + _voiceChannels = new(); + VoiceChannels = _voiceChannels; + } + else + { + _voiceChannels.Clear(); + } + + if (_categories is null) + { + _categories = new(); + Categories = _categories; + } + else + { + _categories.Clear(); + } } - /// - /// Returns the categories of this planet - /// - public async ValueTask> GetCategoriesAsync(bool refresh = false) + public void SortChannels() { - if (!Categories.Initialized || refresh) - await LoadChannelsAsync(); - - return Categories.GetContents(); + _channels.Sort(ISortableModel.Compare); + _chatChannels.Sort(ISortableModel.Compare); + _voiceChannels.Sort(ISortableModel.Compare); + _categories.Sort(ISortableModel.Compare); } /// - /// Returns the channels of a planet + /// Inserts a channel into the planet's channel lists. + /// If sort is true, the lists will be sorted after insertion. /// - public async ValueTask> GetChatChannelsAsync(bool refresh = false) + private void InsertChannelIntoLists(Channel channel, bool sort = true) { - if (!ChatChannels.Initialized || refresh) - await LoadChannelsAsync(); + // We already have this channel inserted + if (_channels.Contains(channel)) + return; - return ChatChannels.GetContents(); + + _channels.Add(channel); + if (sort) + _channels.Sort(ISortableModel.Compare); + + // Note: We don't need to check if the channel is already in these lists + // because channels are always added to the main list. If it's not there, + // it's not in any of the other lists. + + switch (channel.ChannelType) + { + case ChannelTypeEnum.PlanetChat: + { + _chatChannels.Add(channel); + if (sort) + _chatChannels.Sort(ISortableModel.Compare); + + if (channel.IsDefault) + PrimaryChatChannel = channel; + + break; + } + case ChannelTypeEnum.PlanetCategory: + { + _categories.Add(channel); + if (sort) + _categories.Sort(ISortableModel.Compare); + + break; + } + case ChannelTypeEnum.PlanetVoice: + { + _voiceChannels.Add(channel); + if (sort) + _voiceChannels.Sort(ISortableModel.Compare); + + break; + } + default: + Console.WriteLine("[!!!] Planet returned unknown or non-planet channel type!"); + break; + } } - + /// - /// Requests and caches channels from the server + /// Applies the given channels to the planet, inserting and sorting + /// them where necessary. Clears any existing channels. Only use this + /// if you know what you're doing. /// - public async Task LoadChannelsAsync() + public void ApplyChannels(List channels) { - var channels = (await Node.GetJsonAsync>($"{IdRoute}/channels")).Data; - if (channels is null) - return; - - List chatChannels = new(); - List voiceChannels = new(); - List categories = new(); + ClearChannels(); foreach (var channel in channels) { - switch (channel.ChannelType) - { - case ChannelTypeEnum.PlanetChat: - chatChannels.Add(channel); - break; - case ChannelTypeEnum.PlanetCategory: - categories.Add(channel); - break; - case ChannelTypeEnum.PlanetVoice: - voiceChannels.Add(channel); - break; - default: - Console.WriteLine("[!!!] Planet returned unknown or non-planet channel type!"); - break; - } + // Sort is false because we will sort at the end + InsertChannelIntoLists(channel, false); } - - await AllChannels.Initialize(channels); - await ChatChannels.Initialize(chatChannels); - await Categories.Initialize(categories); - await VoiceChannels.Initialize(voiceChannels); + + SortChannels(); } /// - /// Returns the voice channels of a planet + /// Requests an updated list of channels from the server. + /// Generally should not be necessary if using SignalR data feeds. /// - public async ValueTask> GetVoiceChannelsAsync(bool refresh = false) + public async Task FetchChannelsAsync() { - if (!VoiceChannels.Initialized || refresh) - await LoadChannelsAsync(); - - return VoiceChannels.GetContents(); + var newData = (await Node.GetJsonAsync>($"{IdRoute}/channels")).Data; + if (newData is null) + return; + + ApplyChannels(newData); } /// diff --git a/Valour/Sdk/Models/PlanetBan.cs b/Valour/Sdk/Models/PlanetBan.cs index 5a6631c79..e723e2b8a 100644 --- a/Valour/Sdk/Models/PlanetBan.cs +++ b/Valour/Sdk/Models/PlanetBan.cs @@ -2,7 +2,7 @@ namespace Valour.Sdk.Models; -public class PlanetBan : LiveModel, ISharedPlanetBan +public class PlanetBan : ClientModel, ISharedPlanetBan { #region IPlanetModel implementation diff --git a/Valour/Sdk/Models/PlanetInvite.cs b/Valour/Sdk/Models/PlanetInvite.cs index 2bbe86b7e..404fa062d 100644 --- a/Valour/Sdk/Models/PlanetInvite.cs +++ b/Valour/Sdk/Models/PlanetInvite.cs @@ -10,7 +10,7 @@ namespace Valour.Sdk.Models; * A copy of the license should be included - if not, see */ -public class PlanetInvite : LiveModel, IPlanetModel, ISharedPlanetInvite +public class PlanetInvite : ClientModel, IPlanetModel, ISharedPlanetInvite { #region IPlanetModel implementation diff --git a/Valour/Sdk/Models/PlanetMember.cs b/Valour/Sdk/Models/PlanetMember.cs index ee9de5369..a6c77724c 100644 --- a/Valour/Sdk/Models/PlanetMember.cs +++ b/Valour/Sdk/Models/PlanetMember.cs @@ -11,7 +11,7 @@ namespace Valour.Sdk.Models; * A copy of the license should be included - if not, see */ -public class PlanetMember : LiveModel, IPlanetModel, ISharedPlanetMember +public class PlanetMember : ClientModel, IPlanetModel, ISharedPlanetMember { #region IPlanetModel implementation @@ -71,13 +71,13 @@ public static async ValueTask FindAsync(long id, long planetId, bo return member; } - public override async Task OnUpdate(ModelUpdateEvent eventData) + protected override async Task OnUpdated(ModelUpdateEvent eventData) { var planet = await GetPlanetAsync(); await planet.NotifyMemberUpdateAsync(this, eventData); } - public override async Task OnDelete() + protected override async Task OnDeleted() { var planet = await GetPlanetAsync(); await planet.NotifyMemberDeleteAsync(this); diff --git a/Valour/Sdk/Models/PlanetModelObserver.cs b/Valour/Sdk/Models/PlanetModelObserver.cs index 536212951..24b365609 100644 --- a/Valour/Sdk/Models/PlanetModelObserver.cs +++ b/Valour/Sdk/Models/PlanetModelObserver.cs @@ -4,7 +4,7 @@ namespace Valour.Sdk.Models; -public class PlanetModelObserver : IEnumerable, IDisposable where T : LiveModel, IPlanetModel +public class PlanetModelObserver : IEnumerable, IDisposable where T : ClientModel, IPlanetModel { /// /// If true, this collection will sort when necessary @@ -31,7 +31,7 @@ public PlanetModelObserver(Planet planet) _planet = planet; // If the model is ordered, set the flag for sorting - if (typeof(IOrderedModel).IsAssignableFrom(typeof(T))) + if (typeof(ISortableModel).IsAssignableFrom(typeof(T))) _sorted = true; } @@ -155,10 +155,15 @@ public void Sort() if (_sorted && _models is not null) { // TODO: This is a lot of casting. Can probably be optimized. - _models.Sort((a, b) => ((IOrderedModel)a).Position.CompareTo(((IOrderedModel)b).Position)); + _models.Sort(Sorter); } } + private int Sorter (T a, T b) + { + return ISortableModel.Compare((ISortableModel)a, (ISortableModel)b); + } + #region IDisposable /// diff --git a/Valour/Sdk/Models/PlanetRole.cs b/Valour/Sdk/Models/PlanetRole.cs index 101412717..19e29cf09 100644 --- a/Valour/Sdk/Models/PlanetRole.cs +++ b/Valour/Sdk/Models/PlanetRole.cs @@ -11,7 +11,7 @@ namespace Valour.Sdk.Models; * A copy of the license should be included - if not, see */ -public class PlanetRole : LiveModel, IPlanetModel, ISharedPlanetRole +public class PlanetRole : ClientModel, IPlanetModel, ISharedPlanetRole { #region IPlanetModel implementation @@ -142,13 +142,13 @@ public static async Task FindAsync(long id, long planetId, bool refr return item; } - public override async Task OnUpdate(ModelUpdateEvent eventData) + protected override async Task OnUpdated(ModelUpdateEvent eventData) { var planet = await GetPlanetAsync(); await planet.NotifyRoleUpdateAsync(this, eventData); } - public override async Task OnDelete() + protected override async Task OnDeleted() { var planet = await GetPlanetAsync(); await planet.NotifyRoleDeleteAsync(this); diff --git a/Valour/Sdk/Models/PlanetRoleMember.cs b/Valour/Sdk/Models/PlanetRoleMember.cs index 54371650b..0a86de73b 100644 --- a/Valour/Sdk/Models/PlanetRoleMember.cs +++ b/Valour/Sdk/Models/PlanetRoleMember.cs @@ -2,7 +2,7 @@ namespace Valour.Sdk.Models; -public class PlanetRoleMember : LiveModel, ISharedPlanetRoleMember +public class PlanetRoleMember : ClientModel, ISharedPlanetRoleMember { public long UserId { get; set; } public long RoleId { get; set; } diff --git a/Valour/Sdk/Models/TenorFavorite.cs b/Valour/Sdk/Models/TenorFavorite.cs index eec814ed2..703862b38 100644 --- a/Valour/Sdk/Models/TenorFavorite.cs +++ b/Valour/Sdk/Models/TenorFavorite.cs @@ -4,7 +4,7 @@ namespace Valour.Sdk.Models; -public class TenorFavorite : LiveModel, ISharedTenorFavorite +public class TenorFavorite : ClientModel, ISharedTenorFavorite { #region IPlanetModel implementation diff --git a/Valour/Sdk/Models/Themes/Theme.cs b/Valour/Sdk/Models/Themes/Theme.cs index 77920f661..fbe084b45 100644 --- a/Valour/Sdk/Models/Themes/Theme.cs +++ b/Valour/Sdk/Models/Themes/Theme.cs @@ -5,7 +5,7 @@ namespace Valour.Sdk.Models.Themes; -public class Theme : LiveModel, ISharedTheme +public class Theme : ClientModel, ISharedTheme { public override string BaseRoute => "api/themes"; diff --git a/Valour/Sdk/Models/Themes/ThemeVote.cs b/Valour/Sdk/Models/Themes/ThemeVote.cs index 1c32ebebc..79aa8087c 100644 --- a/Valour/Sdk/Models/Themes/ThemeVote.cs +++ b/Valour/Sdk/Models/Themes/ThemeVote.cs @@ -2,7 +2,7 @@ namespace Valour.Sdk.Models.Themes; -public class ThemeVote : LiveModel, ISharedThemeVote +public class ThemeVote : ClientModel, ISharedThemeVote { public override string BaseRoute => $"api/themes/{ThemeId}/votes"; diff --git a/Valour/Sdk/Models/User.cs b/Valour/Sdk/Models/User.cs index 40563c027..388a469f7 100644 --- a/Valour/Sdk/Models/User.cs +++ b/Valour/Sdk/Models/User.cs @@ -5,7 +5,7 @@ namespace Valour.Sdk.Models; -public class User : LiveModel, ISharedUser +public class User : ClientModel, ISharedUser { #region IPlanetModel implementation diff --git a/Valour/Sdk/Models/UserFriend.cs b/Valour/Sdk/Models/UserFriend.cs index f059c267a..b282993f6 100644 --- a/Valour/Sdk/Models/UserFriend.cs +++ b/Valour/Sdk/Models/UserFriend.cs @@ -14,7 +14,7 @@ namespace Valour.Sdk.Models; /// /// ... I'll be your friend! /// -public class UserFriend : LiveModel, ISharedUserFriend +public class UserFriend : ClientModel, ISharedUserFriend { #region IPlanetModel implementation diff --git a/Valour/Sdk/Models/UserProfile.cs b/Valour/Sdk/Models/UserProfile.cs index 2ff4e79b3..d6580ba45 100644 --- a/Valour/Sdk/Models/UserProfile.cs +++ b/Valour/Sdk/Models/UserProfile.cs @@ -2,7 +2,7 @@ namespace Valour.Sdk.Models; -public class UserProfile : LiveModel, ISharedUserProfile +public class UserProfile : ClientModel, ISharedUserProfile { public override string BaseRoute => "api/userProfiles"; diff --git a/Valour/Sdk/Nodes/Node.cs b/Valour/Sdk/Nodes/Node.cs index 93f0e43b2..17a622e75 100644 --- a/Valour/Sdk/Nodes/Node.cs +++ b/Valour/Sdk/Nodes/Node.cs @@ -222,8 +222,8 @@ public async Task HookSignalREvents() await Logger.Log("[Item Events]: Hooking events.", "yellow"); // For every single item... - foreach (var type in Assembly.GetAssembly(typeof(LiveModel)).GetTypes() - .Where(x => x.IsClass && !x.IsAbstract && x.IsSubclassOf(typeof(LiveModel)))) + foreach (var type in Assembly.GetAssembly(typeof(ClientModel)).GetTypes() + .Where(x => x.IsClass && !x.IsAbstract && x.IsSubclassOf(typeof(ClientModel)))) { // Console.WriteLine(type.Name); diff --git a/Valour/Sdk/Valour.Sdk.csproj b/Valour/Sdk/Valour.Sdk.csproj index 36593c126..bc1dcdd05 100644 --- a/Valour/Sdk/Valour.Sdk.csproj +++ b/Valour/Sdk/Valour.Sdk.csproj @@ -27,7 +27,7 @@ - + @@ -36,6 +36,7 @@ + diff --git a/Valour/Server/Api/DynamicAPI.cs b/Valour/Server/Api/DynamicAPI.cs index 412c671bb..d25575ebf 100644 --- a/Valour/Server/Api/DynamicAPI.cs +++ b/Valour/Server/Api/DynamicAPI.cs @@ -3,7 +3,7 @@ namespace Valour.Server.API; /// -/// The Item API allows for easy construction of routes +/// The ServerModel API allows for easy construction of routes /// relating to Valour Items. /// public class DynamicAPI where T : class diff --git a/Valour/Server/Api/NodeAPI.cs b/Valour/Server/Api/NodeAPI.cs index 232a4533f..ade71ba96 100644 --- a/Valour/Server/Api/NodeAPI.cs +++ b/Valour/Server/Api/NodeAPI.cs @@ -26,10 +26,10 @@ public class NodeHandshakeResponse { app.MapGet("api/node/name", () => NodeConfig.Instance.Name); - app.MapGet("api/node/handshake", (NodeService service) => new NodeHandshakeResponse() + app.MapGet("api/node/handshake", (NodeService service, HostedPlanetService hostedService) => new NodeHandshakeResponse() { Version = service.Version, - PlanetIds = service.Planets + PlanetIds = hostedService.GetHostedPlanetIds() }); app.MapGet("api/node/planet/{id}", async (PlanetService planetService, NodeService service, long id) => @@ -44,14 +44,16 @@ public class NodeHandshakeResponse return db.NodeStats.FirstOrDefaultAsync(x => x.Name == NodeConfig.Instance.Name); }); - app.MapGet("api/nodestats/detailed", async (HttpContext ctx, NodeService service, ValourDB db) => { + app.MapGet("api/nodestats/detailed", async (HttpContext ctx, NodeService service, HostedPlanetService hostedService, ValourDB db) => { + var hostedPlanetIds = hostedService.GetHostedPlanetIds(); + DetailedNodeStats stats = new() { Name = NodeConfig.Instance.Name, ConnectionCount = ConnectionTracker.ConnectionIdentities.Count, ConnectionGroupCount = ConnectionTracker.ConnectionGroups.Count, - PlanetCount = service.Planets.Count, + PlanetCount = hostedPlanetIds.Count(), GroupConnections = ConnectionTracker.GroupConnections, GroupUserIds = ConnectionTracker.GroupUserIds, @@ -70,7 +72,7 @@ await ctx.Response.WriteAsync($"

Node: {NodeConfig.Instance.Name}

\n" + $"

" + $"
Connections: {ConnectionTracker.ConnectionIdentities.Count}
\n" + $"
Groups: {ConnectionTracker.ConnectionGroups.Count}
\n" + - $"
Planets: {service.Planets.Count}
\n" + + $"
HostedPlanets: {hostedPlanetIds.Count()}
\n" + $"
"); await ctx.Response.WriteAsync($"

Group Connections:

\n"); diff --git a/Valour/Server/Models/Channel.cs b/Valour/Server/Models/Channel.cs index 1ef3c34ae..e2019de46 100644 --- a/Valour/Server/Models/Channel.cs +++ b/Valour/Server/Models/Channel.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class Channel : Item, ISharedChannel +public class Channel : ServerModel, ISharedChannel { ///////////////////////////////// // Shared between all channels // @@ -45,17 +45,33 @@ public class Channel : Item, ISharedChannel public long? ParentId { get; set; } /// - /// The position of the channel in the channel list + /// The position of the channel. Works as the following: + /// [8 bits]-[8 bits]-[8 bits]-[8 bits] + /// Each 8 bits is a category, with the first category being the top level + /// So for example, if a channel is in the 3rd category of the 2nd category of the 1st category, + /// [00000011]-[00000010]-[00000001]-[00000000] + /// This does limit the depth of categories to 4, and the highest position + /// to 254 (since 000 means no position) /// - public int? Position { get; set; } + public int Position { get; set; } + + /// + /// The depth, or how many categories deep the channel is + /// + public int Depth => ISharedChannel.GetDepth(this); + + /// + /// The position of the channel within its parent + /// + public int LocalPosition => ISharedChannel.GetLocalPosition(this); /// /// If this channel inherits permissions from its parent /// - public bool? InheritsPerms { get; set; } + public bool InheritsPerms { get; set; } /// /// If this channel is the default channel /// - public bool? IsDefault { get; set; } + public bool IsDefault { get; set; } } diff --git a/Valour/Server/Models/HostedPlanet.cs b/Valour/Server/Models/HostedPlanet.cs new file mode 100644 index 000000000..e146bc235 --- /dev/null +++ b/Valour/Server/Models/HostedPlanet.cs @@ -0,0 +1,29 @@ +using Valour.Server.Utilities; +using Valour.Shared.Extensions; + +namespace Valour.Server.Models; + +/// +/// The HostedPlanet class is used for caching planet information on the server +/// for planets which are directly hosted by that node +/// +public class HostedPlanet : IHasId +{ + public Planet Planet { get; private set; } + + public SortedModelCache Roles { get; private set; } + + object IHasId.Id => Planet.Id; + + public HostedPlanet(Planet planet) + { + Planet = planet; + } + + public void Update(Planet updated) + { + Planet.CopyAllTo(updated); + } + + +} \ No newline at end of file diff --git a/Valour/Server/Models/IHasId.cs b/Valour/Server/Models/IHasId.cs new file mode 100644 index 000000000..8b3b9bf81 --- /dev/null +++ b/Valour/Server/Models/IHasId.cs @@ -0,0 +1,6 @@ +namespace Valour.Server.Models; + +public interface IHasId +{ + public object Id { get; } +} \ No newline at end of file diff --git a/Valour/Server/Models/Item.cs b/Valour/Server/Models/Item.cs index 91f19412c..c70f0e4e0 100644 --- a/Valour/Server/Models/Item.cs +++ b/Valour/Server/Models/Item.cs @@ -2,11 +2,13 @@ namespace Valour.Server.Models; -public abstract class Item : ISharedItem +public abstract class ServerModel : ISharedModel, IHasId { /// /// The id of this item /// public long Id { get; set; } + + object IHasId.Id => Id; } diff --git a/Valour/Server/Models/Message.cs b/Valour/Server/Models/Message.cs index 022eb5d98..bedba54ec 100644 --- a/Valour/Server/Models/Message.cs +++ b/Valour/Server/Models/Message.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class Message : Item, ISharedMessage +public class Message : ServerModel, ISharedMessage { public Message ReplyTo { get; set; } diff --git a/Valour/Server/Models/OauthApp.cs b/Valour/Server/Models/OauthApp.cs index cc55d7fe5..1eb4dfaa6 100644 --- a/Valour/Server/Models/OauthApp.cs +++ b/Valour/Server/Models/OauthApp.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class OauthApp : Item, ISharedOauthApp +public class OauthApp : ServerModel, ISharedOauthApp { /// /// The secret key for the app diff --git a/Valour/Server/Models/PermissionsNode.cs b/Valour/Server/Models/PermissionsNode.cs index 96a1ee579..4bde59ecc 100644 --- a/Valour/Server/Models/PermissionsNode.cs +++ b/Valour/Server/Models/PermissionsNode.cs @@ -3,7 +3,7 @@ namespace Valour.Server.Models; -public class PermissionsNode : Item, ISharedPermissionsNode +public class PermissionsNode : ServerModel, ISharedPermissionsNode { /// /// The id of the planet this node belongs to diff --git a/Valour/Server/Models/Planet.cs b/Valour/Server/Models/Planet.cs index 6d2fb8140..2e3a0bdd5 100644 --- a/Valour/Server/Models/Planet.cs +++ b/Valour/Server/Models/Planet.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class Planet : Item, ISharedPlanet +public class Planet : ServerModel, ISharedPlanet { /// /// The Id of the owner of this planet diff --git a/Valour/Server/Models/PlanetBan.cs b/Valour/Server/Models/PlanetBan.cs index 1010296fd..891ec0e1b 100644 --- a/Valour/Server/Models/PlanetBan.cs +++ b/Valour/Server/Models/PlanetBan.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class PlanetBan : Item, ISharedPlanetBan +public class PlanetBan : ServerModel, ISharedPlanetBan { /// /// The id of the planet this ban is for diff --git a/Valour/Server/Models/PlanetInvite.cs b/Valour/Server/Models/PlanetInvite.cs index 685fa0c6f..8b5834688 100644 --- a/Valour/Server/Models/PlanetInvite.cs +++ b/Valour/Server/Models/PlanetInvite.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class PlanetInvite : Item, ISharedPlanetInvite +public class PlanetInvite : ServerModel, ISharedPlanetInvite { /// /// The id of the planet this belongs to diff --git a/Valour/Server/Models/PlanetMember.cs b/Valour/Server/Models/PlanetMember.cs index 938833416..a2ce4fa34 100644 --- a/Valour/Server/Models/PlanetMember.cs +++ b/Valour/Server/Models/PlanetMember.cs @@ -5,7 +5,7 @@ namespace Valour.Server.Models; /// /// Service model for a planet member /// -public class PlanetMember : Item, ISharedPlanetMember +public class PlanetMember : ServerModel, ISharedPlanetMember { /// /// The user id of the member diff --git a/Valour/Server/Models/PlanetRole.cs b/Valour/Server/Models/PlanetRole.cs index 4319e0e27..b9855b395 100644 --- a/Valour/Server/Models/PlanetRole.cs +++ b/Valour/Server/Models/PlanetRole.cs @@ -3,7 +3,7 @@ namespace Valour.Server.Models; -public class PlanetRole : Item, ISharedPlanetRole +public class PlanetRole : ServerModel, ISharedPlanetRole { public static PlanetRole DefaultRole = new PlanetRole() { diff --git a/Valour/Server/Models/PlanetRoleMember.cs b/Valour/Server/Models/PlanetRoleMember.cs index 29f34c7c6..a6b47988b 100644 --- a/Valour/Server/Models/PlanetRoleMember.cs +++ b/Valour/Server/Models/PlanetRoleMember.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class PlanetRoleMember : Item, ISharedPlanetRoleMember +public class PlanetRoleMember : ServerModel, ISharedPlanetRoleMember { /// /// The planet id this role member belongs to diff --git a/Valour/Server/Models/TenorFavorite.cs b/Valour/Server/Models/TenorFavorite.cs index f09d80b52..0de599c71 100644 --- a/Valour/Server/Models/TenorFavorite.cs +++ b/Valour/Server/Models/TenorFavorite.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class TenorFavorite : Item, ISharedTenorFavorite +public class TenorFavorite : ServerModel, ISharedTenorFavorite { public new long Id { get; set; } diff --git a/Valour/Server/Models/User.cs b/Valour/Server/Models/User.cs index 0a944d6b1..50879de73 100644 --- a/Valour/Server/Models/User.cs +++ b/Valour/Server/Models/User.cs @@ -2,7 +2,7 @@ namespace Valour.Server.Models; -public class User : Item, ISharedUser +public class User : ServerModel, ISharedUser { /// /// True if the user has a custom profile picture diff --git a/Valour/Server/Models/UserFriend.cs b/Valour/Server/Models/UserFriend.cs index 0f3392903..d9e513599 100644 --- a/Valour/Server/Models/UserFriend.cs +++ b/Valour/Server/Models/UserFriend.cs @@ -8,7 +8,7 @@ namespace Valour.Server.Models; /// /// ... I'll be your friend! /// -public class UserFriend : Item, ISharedUserFriend +public class UserFriend : ServerModel, ISharedUserFriend { /// /// The id of the user who added the friend diff --git a/Valour/Server/Program.cs b/Valour/Server/Program.cs index 4a17ef439..ce1ee0abe 100644 --- a/Valour/Server/Program.cs +++ b/Valour/Server/Program.cs @@ -305,6 +305,8 @@ public static void ConfigureServices(WebApplicationBuilder builder) services.AddSingleton(); services.TryAddSingleton(); + services.AddSingleton(); + services.AddScoped(); services.AddScoped(); diff --git a/Valour/Server/Services/ChannelService.cs b/Valour/Server/Services/ChannelService.cs index 79e906fc7..c7454f9e1 100644 --- a/Valour/Server/Services/ChannelService.cs +++ b/Valour/Server/Services/ChannelService.cs @@ -102,14 +102,15 @@ public async ValueTask GetDirectChatAsync(long userOneId, long userTwoI ChannelType = ChannelTypeEnum.DirectChat, LastUpdateTime = DateTime.UtcNow, IsDeleted = false, + + Position = 0, + InheritsPerms = false, + IsDefault = false, // These are null and technically we don't have to show this // but I am showing it so you know it SHOULD be null! PlanetId = null, ParentId = null, - Position = null, - InheritsPerms = null, - IsDefault = null }; await _db.Channels.AddAsync(channel); @@ -561,7 +562,7 @@ public async Task> PostMessageAsync(Message message) // Handle node planet ownership if (message.PlanetId is not null) { - if (!await _nodeService.IsPlanetHostedLocally(message.PlanetId.Value)) + if (!await _nodeService.IsHostingPlanet(message.PlanetId.Value)) { return TaskResult.FromError("Planet belongs to another node."); } @@ -658,7 +659,7 @@ public async Task> PostMessageAsync(Message message) var at = ((EmbedMediaItem)item).Attachment; var result = MediaUriHelper.ScanMediaUri(at); if (!result.Success) - return TaskResult.FromError($"Error scanning media URI in embed | Page {page.Id} | Item {item.Id}) | URI {at.Location}"); + return TaskResult.FromError($"Error scanning media URI in embed | Page {page.Id} | ServerModel {item.Id}) | URI {at.Location}"); } } } diff --git a/Valour/Server/Services/CoreHubService.cs b/Valour/Server/Services/CoreHubService.cs index af843c7a1..e35ce3112 100644 --- a/Valour/Server/Services/CoreHubService.cs +++ b/Valour/Server/Services/CoreHubService.cs @@ -108,17 +108,17 @@ public async void NotifyCategoryOrderChange(CategoryOrderEvent eventData) => public async void NotifyUserChannelStateUpdate(long userId, UserChannelState state) => await _hub.Clients.Group($"u-{userId}").SendAsync("UserChannelState-Update", state); - public async void NotifyPlanetItemChange(long planetId, Item item, int flags = 0) => - await _hub.Clients.Group($"p-{planetId}").SendAsync($"{item.GetType().Name}-Update", item, flags); + public async void NotifyPlanetItemChange(long planetId, ServerModel serverModel, int flags = 0) => + await _hub.Clients.Group($"p-{planetId}").SendAsync($"{serverModel.GetType().Name}-Update", serverModel, flags); - public async void NotifyPlanetItemChange(ISharedPlanetItem item, int flags = 0) => - await _hub.Clients.Group($"p-{item.PlanetId}").SendAsync($"{item.GetType().Name}-Update", item, flags); + public async void NotifyPlanetItemChange(ISharedPlanetModel model, int flags = 0) => + await _hub.Clients.Group($"p-{model.PlanetId}").SendAsync($"{model.GetType().Name}-Update", model, flags); - public async void NotifyPlanetItemDelete(ISharedPlanetItem item) => - await _hub.Clients.Group($"p-{item.PlanetId}").SendAsync($"{item.GetType().Name}-Delete", item); + public async void NotifyPlanetItemDelete(ISharedPlanetModel model) => + await _hub.Clients.Group($"p-{model.PlanetId}").SendAsync($"{model.GetType().Name}-Delete", model); - public async void NotifyPlanetItemDelete(long planetId, Item item) => - await _hub.Clients.Group($"p-{planetId}").SendAsync($"{item.GetType().Name}-Delete", item); + public async void NotifyPlanetItemDelete(long planetId, ServerModel serverModel) => + await _hub.Clients.Group($"p-{planetId}").SendAsync($"{serverModel.GetType().Name}-Delete", serverModel); public async void NotifyPlanetChange(Planet item, int flags = 0) => await _hub.Clients.Group($"p-{item.Id}").SendAsync($"{item.GetType().Name}-Update", item, flags); diff --git a/Valour/Server/Services/HostedPlanetService.cs b/Valour/Server/Services/HostedPlanetService.cs new file mode 100644 index 000000000..4b425cc4a --- /dev/null +++ b/Valour/Server/Services/HostedPlanetService.cs @@ -0,0 +1,40 @@ +using System.Collections.Concurrent; +using Valour.Server.Utilities; + +namespace Valour.Server.Services; + +public class HostedPlanetService +{ + /// + /// A cache that holds planets hosted by this node. Nodes keep their hosted + /// planets in-memory to reduce database load. + /// + private readonly ModelCache _hostedPlanets = new(); + + private readonly HashSet _hostedPlanetIds = new(); + + public HostedPlanet Get(long id) + { + _hostedPlanets.Lookup.TryGetValue(id, out var planet); + return planet; + } + + public async Task Add(Planet planet) + { + var hosted = new HostedPlanet(planet); + _hostedPlanets.Add(hosted); + _hostedPlanetIds.Add(planet.Id); + } + + public async Task Remove(long id) + { + _hostedPlanets.Remove(id); + _hostedPlanetIds.Remove(id); + } + + public IEnumerable GetHostedPlanetIds() + { + return _hostedPlanetIds; + } + +} \ No newline at end of file diff --git a/Valour/Server/Services/NodeService.cs b/Valour/Server/Services/NodeService.cs index ce3bd591c..a2a83cc43 100644 --- a/Valour/Server/Services/NodeService.cs +++ b/Valour/Server/Services/NodeService.cs @@ -17,7 +17,8 @@ public class NodeService public readonly string Name; public readonly string Location; public readonly string Version; - public HashSet Planets { get; } + + private HashSet _hostedPlanets; private readonly IDatabase _nodeRecords; private readonly ILogger _logger; @@ -44,15 +45,15 @@ public NodeService(IConnectionMultiplexer redis, ILogger logger, IH Location = config.Location; Version = typeof(Valour.Shared.Models.ISharedUser).Assembly.GetName().Version.ToString(); - Planets = new(); + _hostedPlanets = new(); } /// /// Returns if the given planet is hosted on this node /// - public async Task IsPlanetHostedLocally(long planetId) + public async Task IsHostingPlanet(long planetId) { - if (Planets.Contains((planetId))) + if (_hostedPlanets.Contains((planetId))) return true; return await GetPlanetNodeAsync(planetId) == Name; @@ -95,7 +96,7 @@ public async Task IsNodeAliveAsync(string node) /// public async Task GetPlanetNodeAsync(long planetId) { - if (Planets.Contains(planetId)) + if (_hostedPlanets.Contains(planetId)) return Name; // We are hosting the planet (this is a local request) var key = $"planet:{planetId}"; @@ -110,7 +111,7 @@ public async Task GetPlanetNodeAsync(long planetId) if (NodeConfig.Instance.LogInfo) _logger.LogInformation("Resuming hosting of {PlanetId}", planetId); - Planets.Add(planetId); + _hostedPlanets.Add(planetId); } if (NodeConfig.Instance.LogInfo) @@ -205,7 +206,7 @@ public async Task AnnouncePlanetHostedAsync(long planetId) if (NodeConfig.Instance.LogInfo) _logger.LogInformation("Taking ownership of planet {PlanetId}", planetId); - Planets.Add(planetId); + _hostedPlanets.Add(planetId); var key = $"planet:{planetId}"; await _nodeRecords.StringSetAsync(key, Name); } diff --git a/Valour/Server/Services/PlanetPermissionService.cs b/Valour/Server/Services/PlanetPermissionService.cs new file mode 100644 index 000000000..6244fb86f --- /dev/null +++ b/Valour/Server/Services/PlanetPermissionService.cs @@ -0,0 +1,30 @@ +namespace Valour.Server.Services; + +// A note to those looking: +// The idea of using role combinations and sequential hashing to provide +// an extremely fast and low-storage alternative to traditional RBAC +// is a concept that I have been working on for a while. When I did the +// math and realized just how efficient it could be, I thought about +// monetizing this or patenting it to prevent a company like Discord +// from stealing it. But then I realized that this is a concept that +// should be free and open to everyone. So feel free to use this +// concept in your own projects, and if you want to credit me, that's +// cool too. And also mention Valour :) + +// I'm going to also give this system the name HACKR-AUTH +// (HAshed Combined Role Keyed AUTHorization) +// because it sounds cool and I like acronyms. + +// There is one slight downside: for a community with 100 roles, there +// is a 1 in 368 quadrillion chance of a hash collision. That's a risk +// I'm willing to take. + +// - Spike, 2024 + +/// +/// Provides methods for checking and enforcing permissions in planets +/// +public class PlanetPermissionService +{ + private readonly PlanetRoleService _roleService; +} \ No newline at end of file diff --git a/Valour/Server/Services/PlanetService.cs b/Valour/Server/Services/PlanetService.cs index b3fbc1955..d5b836f89 100644 --- a/Valour/Server/Services/PlanetService.cs +++ b/Valour/Server/Services/PlanetService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Valour.Server.Database; using Valour.Shared; using Valour.Shared.Authorization; @@ -12,17 +13,23 @@ public class PlanetService private readonly CoreHubService _coreHub; private readonly ILogger _logger; private readonly ChannelAccessService _accessService; + private readonly NodeService _nodeService; + private readonly HostedPlanetService _hostedPlanetService; public PlanetService( ValourDB db, CoreHubService coreHub, ILogger logger, - ChannelAccessService accessService) + ChannelAccessService accessService, + NodeService nodeService, + HostedPlanetService hostedPlanetService) { _db = db; _coreHub = coreHub; _logger = logger; _accessService = accessService; + _nodeService = nodeService; + _hostedPlanetService = hostedPlanetService; } /// @@ -34,17 +41,22 @@ public async Task ExistsAsync(long id) => /// /// Returns the planet with the given id /// - public async Task GetAsync(long id) => - (await _db.Planets.FindAsync(id)).ToModel(); + public async Task GetAsync(long id) + { + var hosted = _hostedPlanetService.Get(id); + if (hosted is not null) + return hosted.Planet; + + // get planet from db + return (await _db.Planets.FindAsync(id)).ToModel(); + } /// /// Returns the primary channel for the given planet /// public async Task GetPrimaryChannelAsync(long planetId) => (await _db.Channels.FirstOrDefaultAsync(x => - x.PlanetId == planetId && - x.IsDefault != null && - x.IsDefault.Value)).ToModel(); + x.PlanetId == planetId && x.IsDefault)).ToModel(); /// /// Returns the default role for the given planet @@ -512,8 +524,12 @@ public async Task InsertChildAsync(long? categoryId, long insertId, } var children = await _db.Channels - .Where(x => x.ParentId == categoryId) + .Where(x => x.ParentId == categoryId && x.PlanetId == insert.PlanetId) .OrderBy(x => x.Position) + .Select(x => + new { + Id = x.Id, ChannelType = x.ChannelType + }) .ToListAsync(); var position = inPosition ?? children.Count + 1; @@ -563,23 +579,29 @@ public async Task InsertChildAsync(long? categoryId, long insertId, insert.ParentId = categoryId; insert.PlanetId = insert.PlanetId; insert.Position = position; + + var insertData = new + { + insert.Id, + insert.ChannelType + }; if (position >= children.Count) { - children.Add(insert); + children.Add(insertData); } else { - children.Insert(position, insert); + children.Insert(position, insertData); } // Update all positions - var pos = 0; + // var pos = 0; foreach (var child in children) { - child.Position = pos; + // child.Position = pos; newCategoryOrder.Add(new(child.Id, child.ChannelType)); - pos++; + // pos++; } await _db.SaveChangesAsync(); diff --git a/Valour/Server/Services/RegisterService.cs b/Valour/Server/Services/RegisterService.cs index 0005276ed..e4f4facf1 100644 --- a/Valour/Server/Services/RegisterService.cs +++ b/Valour/Server/Services/RegisterService.cs @@ -14,7 +14,7 @@ public class RegisterService private const string ValourWelcome = "## Welcome to Valour!\nI'm *Victor*, the Valour mascot. I'm here to help you get started. " + "If you have any questions, feel free to ask me. I may not be fast to respond (I am run by humans!) " + "You can also ask other users, or check out the Valour Central planet for more information." + - " Some basics: \n- Valour communities are named Planets! You can join or create planets for your interests. " + + " Some basics: \n- Valour communities are named HostedPlanets! You can join or create planets for your interests. " + "\n- You can also add friends and direct message them. " + "\n- Valour (desktop) supports opening multiple windows with controls on the top right of each window. "; diff --git a/Valour/Server/Utilities/ModelCache.cs b/Valour/Server/Utilities/ModelCache.cs new file mode 100644 index 000000000..b2f7f9006 --- /dev/null +++ b/Valour/Server/Utilities/ModelCache.cs @@ -0,0 +1,142 @@ +using Valour.Shared.Extensions; +using Valour.Shared.Models; + +namespace Valour.Server.Utilities; + +/// +/// The ModelCache is used for +/// caching collections of models that are frequently accessed and updated. +/// It performs no allocations and protects the internal store. +/// +public class ModelCache where T : IHasId +{ + public IReadOnlyList Values { get; private set; } + public IReadOnlyDictionary Lookup { get; private set; } + + private List _cache; + private Dictionary _lookup; + + public ModelCache() + { + _cache = new(); + _lookup = new(); + } + + public ModelCache(List initial) + { + _cache = initial; + _lookup = _cache.ToDictionary(x => x.Id); + } + + public void Reset(List initial) + { + _cache = initial; + _lookup = _cache.ToDictionary(x => x.Id); + } + + public void Add(T item) + { + _cache.Add(item); + _lookup.Add(item.Id, item); + } + + public void Remove(long id) + { + if (_lookup.TryGetValue(id, out var item)) + { + _cache.Remove(item); + _lookup.Remove(id); + } + } + + public void Update(T updated) + { + if (_lookup.TryGetValue(updated.Id, out var old)) + { + updated.CopyAllTo(old); + } + else + { + Add(updated); + } + } + + public T Get(long id) + { + _lookup.TryGetValue(id, out var item); + return item; + } +} + +public class SortedModelCache where T : ISortableModel, IHasId +{ + public IReadOnlyList Values { get; private set; } + public IReadOnlyDictionary Lookup { get; private set; } + + private List _cache; + private Dictionary _lookup; + + public SortedModelCache() + { + _cache = new(); + _lookup = new(); + } + + public SortedModelCache(List initial) + { + _cache = initial; + _lookup = _cache.ToDictionary(x => x.Id); + + if (_cache.Count > 0) + { + _cache.Sort(ISortableModel.Compare); + } + } + + public void Reset(List initial) + { + _cache = initial; + _lookup = _cache.ToDictionary(x => x.Id); + } + + public void Add(T item) + { + _cache.Add(item); + _lookup.Add(item.Id, item); + _cache.Sort(ISortableModel.Compare); + } + + public void Remove(long id) + { + if (_lookup.TryGetValue(id, out var item)) + { + _cache.Remove(item); + _lookup.Remove(id); + } + } + + public void Update(T updated) + { + if (_lookup.TryGetValue(updated.Id, out var old)) + { + updated.CopyAllTo(old); + + // check if the position has changed + if (old.GetSortPosition() != updated.GetSortPosition()) + { + _cache.Sort(ISortableModel.Compare); + } + } + else + { + Add(updated); + _cache.Sort(ISortableModel.Compare); + } + } + + public T Get(long id) + { + _lookup.TryGetValue(id, out var item); + return item; + } +} \ No newline at end of file diff --git a/Valour/Server/Valour.Server.csproj b/Valour/Server/Valour.Server.csproj index 89b130932..391ffc203 100644 --- a/Valour/Server/Valour.Server.csproj +++ b/Valour/Server/Valour.Server.csproj @@ -17,7 +17,7 @@ - + diff --git a/Valour/Shared/Extensions/CopyToExtensions.cs b/Valour/Shared/Extensions/CopyToExtensions.cs new file mode 100644 index 000000000..582396804 --- /dev/null +++ b/Valour/Shared/Extensions/CopyToExtensions.cs @@ -0,0 +1,55 @@ +namespace Valour.Shared.Extensions; + +public static class CopyToExtension +{ + public static void CopyAllTo(this T source, T target) + { + var type = typeof(T); + foreach (var sourceProperty in type.GetProperties()) + { + var targetProperty = type.GetProperty(sourceProperty.Name); + if (targetProperty.CanWrite) + targetProperty.SetValue(target, sourceProperty.GetValue(source, null), null); + } + foreach (var sourceField in type.GetFields()) + { + var targetField = type.GetField(sourceField.Name); + if (!targetField.IsStatic) + targetField.SetValue(target, sourceField.GetValue(source)); + } + } + + public static void CopyAllNonDefaultTo(this T source, T target) + { + var type = typeof(T); + foreach (var sourceProperty in type.GetProperties()) + { + var targetProperty = type.GetProperty(sourceProperty.Name); + if (targetProperty.CanWrite) + { + var sourceValue = sourceProperty.GetValue(source, null); + if (sourceValue != null && !IsDefaultValue(sourceValue)) + { + targetProperty.SetValue(target, sourceValue, null); + } + } + } + foreach (var sourceField in type.GetFields()) + { + var targetField = type.GetField(sourceField.Name); + if (!targetField.IsStatic) + { + var sourceValue = sourceField.GetValue(source); + if (sourceValue != null && !IsDefaultValue(sourceValue)) + { + targetField.SetValue(target, sourceValue); + } + } + } + } + + private static bool IsDefaultValue(T value) + { + return EqualityComparer.Default.Equals(value, default(T)); + } +} \ No newline at end of file diff --git a/Valour/Shared/Models/ISharedChannel.cs b/Valour/Shared/Models/ISharedChannel.cs index 66ed98e04..4cd9b0193 100644 --- a/Valour/Shared/Models/ISharedChannel.cs +++ b/Valour/Shared/Models/ISharedChannel.cs @@ -29,7 +29,7 @@ public class SharedChannelNames }; } -public interface ISharedChannel : ISharedItem +public interface ISharedChannel : ISharedModel, ISortableModel { public static string GetTypeName(ChannelTypeEnum type) { @@ -85,9 +85,73 @@ public static string GetTypeName(ChannelTypeEnum type) /// DateTime LastUpdateTime { get; set; } - ///////////////////////////// - // Only on planet channels // - ///////////////////////////// + /// + /// The position of the channel. Works as the following: + /// [8 bits]-[8 bits]-[8 bits]-[8 bits] + /// Each 8 bits is a category, with the first category being the top level + /// So for example, if a channel is in the 3rd category of the 2nd category of the 1st category, + /// [00000011]-[00000010]-[00000001]-[00000000] + /// This does limit the depth of categories to 4, and the highest position + /// to 254 (since 000 means no position) + /// + int Position { get; set; } + + /// + /// The depth, or how many categories deep the channel is + /// + int Depth { get; } + + /// + /// The position of the channel within its parent + /// + int LocalPosition { get; } + + public static int GetDepth(ISharedChannel channel) + { + // Check if the third and fourth bytes (depth 3 and 4) are present + if ((channel.Position & 0x0000FFFF) == 0) + { + // If they are not, we must be in the first or second layer + if ((channel.Position & 0x00FF0000) == 0) + { + // If the second byte is also zero, it's in the first layer (top level) + return 1; + } + // Otherwise, it's in the second layer + return 2; + } + else + { + // Check the lowest byte first (fourth layer) + if ((channel.Position & 0x000000FF) == 0) + { + // If the fourth byte is zero, it's in the third layer + return 3; + } + + // If none of the previous checks matched, it’s in the fourth layer + return 4; + } + } + + public static int GetLocalPosition(ISharedChannel channel) + { + var depth = GetDepth(channel); + // use depth to determine amount to shift + var shift = 8 * (4 - depth); + var shifted = channel.Position >> shift; + // now clear the higher bits + return shifted & 0xFF; + } + + int ISortableModel.GetSortPosition() + { + return Position; + } + + ///////////////////////////////////// + // Only applies to planet channels // + ///////////////////////////////////// /// /// The id of the planet this channel belongs to, if any @@ -99,18 +163,13 @@ public static string GetTypeName(ChannelTypeEnum type) /// long? ParentId { get; set; } - /// - /// The position of the channel in the channel list - /// - int? Position { get; set; } - /// /// If this channel inherits permissions from its parent /// - bool? InheritsPerms { get; set; } + bool InheritsPerms { get; set; } /// /// If this channel is the default channel /// - bool? IsDefault { get; set; } + bool IsDefault { get; set; } } diff --git a/Valour/Shared/Models/ISharedMessage.cs b/Valour/Shared/Models/ISharedMessage.cs index 167140b14..2536eee70 100644 --- a/Valour/Shared/Models/ISharedMessage.cs +++ b/Valour/Shared/Models/ISharedMessage.cs @@ -8,7 +8,7 @@ namespace Valour.Shared.Models; -public interface ISharedMessage : ISharedItem +public interface ISharedMessage : ISharedModel { /// /// The planet this message belongs to (if any) diff --git a/Valour/Shared/Models/ISharedItem.cs b/Valour/Shared/Models/ISharedModel.cs similarity index 64% rename from Valour/Shared/Models/ISharedItem.cs rename to Valour/Shared/Models/ISharedModel.cs index c6ce1f264..3a65632d7 100644 --- a/Valour/Shared/Models/ISharedItem.cs +++ b/Valour/Shared/Models/ISharedModel.cs @@ -1,8 +1,8 @@ -namespace Valour.Shared.Models; - -public interface ISharedItem -{ - long Id { get; set; } -} - - +namespace Valour.Shared.Models; + +public interface ISharedModel +{ + long Id { get; set; } +} + + diff --git a/Valour/Shared/Models/ISharedOauthApp.cs b/Valour/Shared/Models/ISharedOauthApp.cs index fff94c385..036a65ceb 100644 --- a/Valour/Shared/Models/ISharedOauthApp.cs +++ b/Valour/Shared/Models/ISharedOauthApp.cs @@ -10,7 +10,7 @@ /// Oauth apps allow an organization or person to issue tokens on behalf of a user /// which can be easily tracked and revoked /// -public interface ISharedOauthApp : ISharedItem +public interface ISharedOauthApp : ISharedModel { /// /// The secret key for the app diff --git a/Valour/Shared/Models/ISharedPermissionsNode.cs b/Valour/Shared/Models/ISharedPermissionsNode.cs index 7b061c345..caabc665c 100644 --- a/Valour/Shared/Models/ISharedPermissionsNode.cs +++ b/Valour/Shared/Models/ISharedPermissionsNode.cs @@ -19,7 +19,7 @@ public interface ISharedPermissionsTarget /// /// A permission node is a set of permissions for a specific thing /// -public interface ISharedPermissionsNode : ISharedPlanetItem +public interface ISharedPermissionsNode : ISharedPlanetModel { /// diff --git a/Valour/Shared/Models/ISharedPlanet.cs b/Valour/Shared/Models/ISharedPlanet.cs index 9a479ab62..acfe9eb00 100644 --- a/Valour/Shared/Models/ISharedPlanet.cs +++ b/Valour/Shared/Models/ISharedPlanet.cs @@ -8,7 +8,7 @@ namespace Valour.Shared.Models; -public interface ISharedPlanet : ISharedItem +public interface ISharedPlanet : ISharedModel { /// /// The Id of Valour Central, used for some platform-wide features diff --git a/Valour/Shared/Models/ISharedPlanetBan.cs b/Valour/Shared/Models/ISharedPlanetBan.cs index c063fda51..634caf234 100644 --- a/Valour/Shared/Models/ISharedPlanetBan.cs +++ b/Valour/Shared/Models/ISharedPlanetBan.cs @@ -1,6 +1,6 @@ namespace Valour.Shared.Models; -public interface ISharedPlanetBan : ISharedPlanetItem +public interface ISharedPlanetBan : ISharedPlanetModel { /// /// The user that was banned diff --git a/Valour/Shared/Models/ISharedPlanetInvite.cs b/Valour/Shared/Models/ISharedPlanetInvite.cs index 80f53d726..e3c045d3e 100644 --- a/Valour/Shared/Models/ISharedPlanetInvite.cs +++ b/Valour/Shared/Models/ISharedPlanetInvite.cs @@ -10,7 +10,7 @@ namespace Valour.Shared.Models; /// /// This represents a user within a planet and is used to represent membership /// -public interface ISharedPlanetInvite : ISharedPlanetItem +public interface ISharedPlanetInvite : ISharedPlanetModel { /// /// the invite code diff --git a/Valour/Shared/Models/ISharedPlanetMember.cs b/Valour/Shared/Models/ISharedPlanetMember.cs index fb3322b75..392c05d67 100644 --- a/Valour/Shared/Models/ISharedPlanetMember.cs +++ b/Valour/Shared/Models/ISharedPlanetMember.cs @@ -10,7 +10,7 @@ /// /// This represents a user within a planet and is used to represent membership /// -public interface ISharedPlanetMember : ISharedPlanetItem +public interface ISharedPlanetMember : ISharedPlanetModel { /// /// The user within the planet diff --git a/Valour/Shared/Models/ISharedPlanetItem.cs b/Valour/Shared/Models/ISharedPlanetModel.cs similarity index 72% rename from Valour/Shared/Models/ISharedPlanetItem.cs rename to Valour/Shared/Models/ISharedPlanetModel.cs index 8b8a392d7..2ff5c7fdb 100644 --- a/Valour/Shared/Models/ISharedPlanetItem.cs +++ b/Valour/Shared/Models/ISharedPlanetModel.cs @@ -1,9 +1,9 @@ -namespace Valour.Shared.Models; - -/// -/// Planet items are items which are owned by a planet -/// -public interface ISharedPlanetItem : ISharedItem -{ - long PlanetId { get; set; } -} +namespace Valour.Shared.Models; + +/// +/// Planet items are items which are owned by a planet +/// +public interface ISharedPlanetModel : ISharedModel +{ + long PlanetId { get; set; } +} diff --git a/Valour/Shared/Models/ISharedPlanetRole.cs b/Valour/Shared/Models/ISharedPlanetRole.cs index 54e589f2d..7a0a9a596 100644 --- a/Valour/Shared/Models/ISharedPlanetRole.cs +++ b/Valour/Shared/Models/ISharedPlanetRole.cs @@ -3,18 +3,13 @@ namespace Valour.Shared.Models; -public interface ISharedPlanetRole : ISharedPlanetItem +public interface ISharedPlanetRole : ISharedPlanetModel, ISortableModel { /// /// True if this is an admin role - meaning that it overrides all permissions /// bool IsAdmin { get; set; } - /// - /// The position of the role: Lower has more authority - /// - int Position { get; set; } - /// /// True if this is the default (everyone) role /// @@ -54,6 +49,11 @@ public interface ISharedPlanetRole : ISharedPlanetItem /// True if the role can be mentioned by non-admins /// bool AnyoneCanMention { get; set; } + + /// + /// The position of the role: Lower has more authority + /// + int Position { get; set; } public int GetAuthority() => ISharedPlanetRole.GetAuthority(this); @@ -67,5 +67,9 @@ public static int GetAuthority(ISharedPlanetRole role) => public static bool HasPermission(ISharedPlanetRole role, PlanetPermission perm) => Permission.HasPermission(role.Permissions, perm); + int ISortableModel.GetSortPosition() + { + return Position; + } } diff --git a/Valour/Shared/Models/ISharedPlanetRoleMember.cs b/Valour/Shared/Models/ISharedPlanetRoleMember.cs index fd91f8c82..a9dbaa920 100644 --- a/Valour/Shared/Models/ISharedPlanetRoleMember.cs +++ b/Valour/Shared/Models/ISharedPlanetRoleMember.cs @@ -8,7 +8,7 @@ namespace Valour.Shared.Models; -public interface ISharedPlanetRoleMember : ISharedPlanetItem +public interface ISharedPlanetRoleMember : ISharedPlanetModel { long UserId { get; set; } long RoleId { get; set; } diff --git a/Valour/Shared/Models/ISharedUser.cs b/Valour/Shared/Models/ISharedUser.cs index e6f200393..5d729f147 100644 --- a/Valour/Shared/Models/ISharedUser.cs +++ b/Valour/Shared/Models/ISharedUser.cs @@ -9,7 +9,7 @@ namespace Valour.Shared.Models; -public interface ISharedUser : ISharedItem +public interface ISharedUser : ISharedModel { const long VictorUserId = 20579262493097984; diff --git a/Valour/Shared/Models/ISharedUserFriend.cs b/Valour/Shared/Models/ISharedUserFriend.cs index d03a3e79a..a723b459c 100644 --- a/Valour/Shared/Models/ISharedUserFriend.cs +++ b/Valour/Shared/Models/ISharedUserFriend.cs @@ -12,7 +12,7 @@ /// /// ... I'll be your friend! /// -public interface ISharedUserFriend : ISharedItem +public interface ISharedUserFriend : ISharedModel { /// /// The user who added the friend diff --git a/Valour/Shared/Models/ISortableModel.cs b/Valour/Shared/Models/ISortableModel.cs new file mode 100644 index 000000000..9bc731690 --- /dev/null +++ b/Valour/Shared/Models/ISortableModel.cs @@ -0,0 +1,16 @@ +namespace Valour.Shared.Models; + +public interface ISortableModel +{ + public int GetSortPosition(); + + public static int Compare(ISortableModel x, ISortableModel y) + { + return x.GetSortPosition().CompareTo(y.GetSortPosition()); + } + + public static int Compare(T x, T y) where T : ISortableModel + { + return x.GetSortPosition().CompareTo(y.GetSortPosition()); + } +} \ No newline at end of file diff --git a/Valour/Shared/Models/ModelUpdateEvent.cs b/Valour/Shared/Models/ModelUpdateEvent.cs index 1e0af284e..da5242227 100644 --- a/Valour/Shared/Models/ModelUpdateEvent.cs +++ b/Valour/Shared/Models/ModelUpdateEvent.cs @@ -16,7 +16,7 @@ public class ModelUpdateEvent public int? Flags { get; set; } } -public class ModelUpdateEvent where T : ISharedItem +public class ModelUpdateEvent where T : ISharedModel { /// /// The new or updated item diff --git a/Valour/Shared/Utilities/HybridEvent.cs b/Valour/Shared/Utilities/HybridEvent.cs new file mode 100644 index 000000000..4e2e97d31 --- /dev/null +++ b/Valour/Shared/Utilities/HybridEvent.cs @@ -0,0 +1,441 @@ +using Microsoft.Extensions.ObjectPool; + +namespace Valour.Shared.Utilities; + +/// +/// The hybrid event handler allows a given method signature to be called both +/// synchronously and asynchronously. Built for efficiency using two separate +/// lists of delegates, with list pooling to minimize allocations. +/// +public class HybridEvent : IDisposable +{ + // Synchronous and asynchronous handler lists + private List> _syncHandlers; + private List> _asyncHandlers; + + // Init is false until the handler lists are initialized + private bool _init; + + // Lock object for synchronous and asynchronous handler access + private readonly object _syncLock = new(); + private readonly object _asyncLock = new(); + + // Object pool for list reuse + // This is static because it is shared across all instances of HybridEvent + private static readonly ObjectPool>> SyncListPool = + new DefaultObjectPool>>(new ListPolicy>()); + private static readonly ObjectPool>> AsyncListPool = + new DefaultObjectPool>>(new ListPolicy>()); + + // Object pool for task list + private static readonly ObjectPool> TaskListPool = + new DefaultObjectPool>(new ListPolicy()); + + private void InitIfNeeded() + { + if (!_init) + { + // set up handler lists + lock (_syncLock) + { + _syncHandlers = SyncListPool.Get(); + } + + lock (_asyncLock) + { + _asyncHandlers = AsyncListPool.Get(); + } + + _init = true; + } + } + + // Add a synchronous handler + public void AddHandler(Action handler) + { + InitIfNeeded(); + + lock (_syncLock) + { + _syncHandlers.Add(handler); + } + } + + // Add an asynchronous handler + public void AddHandler(Func handler) + { + InitIfNeeded(); + + lock (_asyncLock) + { + _asyncHandlers.Add(handler); + } + } + + // Remove a synchronous handler + public void RemoveHandler(Action handler) + { + lock (_syncLock) + { + _syncHandlers.Remove(handler); + } + } + + // Remove an asynchronous handler + public void RemoveHandler(Func handler) + { + lock (_asyncLock) + { + _asyncHandlers.Remove(handler); + } + } + + // Invoke all synchronous handlers with list pooling to prevent allocation + private void InvokeSyncHandlers(TEventData data) + { + // Get a pooled list for copying handlers + var handlersCopy = SyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_syncLock) + { + handlersCopy.AddRange(_syncHandlers); // Copy handlers into pooled list + } + + try + { + // Invoke all handlers + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + handlersCopy[i].Invoke(data); // No allocations, just iterating over the pooled list + } + } + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + SyncListPool.Return(handlersCopy); + } + } + + // Invoke all asynchronous handlers concurrently with list pooling + private async Task InvokeAsyncHandlers(TEventData data) + { + // Get a pooled list for copying async handlers + var handlersCopy = AsyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_asyncLock) + { + handlersCopy.AddRange(_asyncHandlers); // Copy async handlers into pooled list + } + + var tasks = TaskListPool.Get(); + + try + { + // Invoke all async handlers in parallel using Task.WhenAll + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + tasks.Add(handlersCopy[i].Invoke(data)); // Add tasks to list + } + } + + await Task.WhenAll(tasks); // Wait for all async handlers to complete + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + AsyncListPool.Return(handlersCopy); + TaskListPool.Return(tasks); + } + } + + // Invoke both sync and async handlers + public async Task Invoke(TEventData data) + { + InvokeSyncHandlers(data); // Call synchronous handlers first + await InvokeAsyncHandlers(data); // Then call asynchronous handlers + } + + // Enable += and -= operators for adding/removing handlers + public static HybridEvent operator +(HybridEvent handler, Action action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator +(HybridEvent handler, Func action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Action action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Func action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + // Cleanup everything + public void Dispose() + { + _syncHandlers.Clear(); + _asyncHandlers.Clear(); + } + + // Custom object pooling policy for List + private class ListPolicy : PooledObjectPolicy> + { + public override List Create() => new List(); + public override bool Return(List obj) + { + obj.Clear(); // Clear the list before returning to pool + return true; + } + } +} + +/// +/// The hybrid event handler allows a given method signature to be called both +/// synchronously and asynchronously. Built for efficiency using two separate +/// lists of delegates, with list pooling to minimize allocations. +/// +public class HybridEvent : IDisposable +{ + // Synchronous and asynchronous handler lists + private List _syncHandlers; + private List> _asyncHandlers; + + // Init is false until the handler lists are initialized + private bool _init; + + // Lock object for synchronous and asynchronous handler access + private readonly object _syncLock = new(); + private readonly object _asyncLock = new(); + + // Object pool for list reuse + // This is static because it is shared across all instances of HybridEvent + private static readonly ObjectPool> SyncListPool = + new DefaultObjectPool>(new ListPolicy()); + private static readonly ObjectPool>> AsyncListPool = + new DefaultObjectPool>>(new ListPolicy>()); + + // Object pool for task list + private static readonly ObjectPool> TaskListPool = + new DefaultObjectPool>(new ListPolicy()); + + private void InitIfNeeded() + { + if (!_init) + { + // set up handler lists + lock (_syncLock) + { + _syncHandlers = SyncListPool.Get(); + } + + lock (_asyncLock) + { + _asyncHandlers = AsyncListPool.Get(); + } + + _init = true; + } + } + + // Add a synchronous handler + public void AddHandler(Action handler) + { + InitIfNeeded(); + + lock (_syncLock) + { + _syncHandlers.Add(handler); + } + } + + // Add an asynchronous handler + public void AddHandler(Func handler) + { + InitIfNeeded(); + + lock (_asyncLock) + { + _asyncHandlers.Add(handler); + } + } + + // Remove a synchronous handler + public void RemoveHandler(Action handler) + { + lock (_syncLock) + { + _syncHandlers.Remove(handler); + } + } + + // Remove an asynchronous handler + public void RemoveHandler(Func handler) + { + lock (_asyncLock) + { + _asyncHandlers.Remove(handler); + } + } + + // Invoke all synchronous handlers with list pooling to prevent allocation + private void InvokeSyncHandlers() + { + // Get a pooled list for copying handlers + var handlersCopy = SyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_syncLock) + { + handlersCopy.AddRange(_syncHandlers); // Copy handlers into pooled list + } + + try + { + // Invoke all handlers + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + handlersCopy[i].Invoke(); // No allocations, just iterating over the pooled list + } + } + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + SyncListPool.Return(handlersCopy); + } + } + + // Invoke all asynchronous handlers concurrently with list pooling + private async Task InvokeAsyncHandlers() + { + // Get a pooled list for copying async handlers + var handlersCopy = AsyncListPool.Get(); + + // Copy handlers while locking to prevent concurrent modifications + lock (_asyncLock) + { + handlersCopy.AddRange(_asyncHandlers); // Copy async handlers into pooled list + } + + var tasks = TaskListPool.Get(); + + try + { + // Invoke all async handlers in parallel using Task.WhenAll + for (int i = 0; i < handlersCopy.Count; i++) + { + if (handlersCopy[i] is not null) + { + tasks.Add(handlersCopy[i].Invoke()); // Add tasks to list + } + } + + await Task.WhenAll(tasks); // Wait for all async handlers to complete + } + finally + { + // Clear and return the list to the pool + handlersCopy.Clear(); + AsyncListPool.Return(handlersCopy); + TaskListPool.Return(tasks); + } + } + + // Invoke both sync and async handlers + public async Task Invoke() + { + InvokeSyncHandlers(); // Call synchronous handlers first + await InvokeAsyncHandlers(); // Then call asynchronous handlers + } + + // Enable += and -= operators for adding/removing handlers + public static HybridEvent operator +(HybridEvent handler, Action action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator +(HybridEvent handler, Func action) + { + if (handler is null) + handler = new HybridEvent(); + + handler.AddHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Action action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + public static HybridEvent operator -(HybridEvent handler, Func action) + { + if (handler is null) + return null; + + handler.RemoveHandler(action); + return handler; + } + + // Cleanup everything + public void Dispose() + { + _syncHandlers.Clear(); + _asyncHandlers.Clear(); + } + + // Custom object pooling policy for List + private class ListPolicy : PooledObjectPolicy> + { + public override List Create() => new List(); + public override bool Return(List obj) + { + obj.Clear(); // Clear the list before returning to pool + return true; + } + } +} + + diff --git a/Valour/Shared/Valour.Shared.csproj b/Valour/Shared/Valour.Shared.csproj index 02ab64812..b2fedb0a5 100644 --- a/Valour/Shared/Valour.Shared.csproj +++ b/Valour/Shared/Valour.Shared.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/Valour/Tests/Models/ChannelTests.cs b/Valour/Tests/Models/ChannelTests.cs new file mode 100644 index 000000000..6416c14be --- /dev/null +++ b/Valour/Tests/Models/ChannelTests.cs @@ -0,0 +1,41 @@ +namespace Valour.Tests.Models; + +public class ChannelTests +{ + [Fact] + public void TestChannelDepth() + { + var channel = new Server.Models.Channel(); + + // Depth checks + + // Depth 1 (top level) + channel.Position = 0x01_00_00_00; + Assert.Equal(1, channel.Depth); + + // Depth 2 + channel.Position = 0x01_01_00_00; + Assert.Equal(2, channel.Depth); + + // Depth 3 + channel.Position = 0x01_01_01_00; + Assert.Equal(3, channel.Depth); + + // Depth 4 + channel.Position = 0x01_01_01_01; + Assert.Equal(4, channel.Depth); + + // Local position checks + channel.Position = 0x01_00_00_00; + Assert.Equal(1, channel.LocalPosition); + + channel.Position = 0x01_01_00_00; + Assert.Equal(1, channel.LocalPosition); + + channel.Position = 0x01_02_00_00; + Assert.Equal(2, channel.LocalPosition); + + channel.Position = 0x01_01_01_05; + Assert.Equal(5, channel.LocalPosition); + } +} \ No newline at end of file diff --git a/Valour/Tests/Valour.Tests.csproj b/Valour/Tests/Valour.Tests.csproj new file mode 100644 index 000000000..cd39b5978 --- /dev/null +++ b/Valour/Tests/Valour.Tests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + True + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + +