From b9c1c47474ec5f5374cdd3cf53a79512ccfe34d2 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 27 Apr 2024 04:17:46 +0300 Subject: [PATCH] Migrate to Avalonia (#1220) --- .github/workflows/main.yml | 1 + .../DiscordChatExporter.Cli.Tests.csproj | 13 +- .../Infra/ExportWrapper.cs | 26 +- .../Specs/DateRangeSpecs.cs | 18 +- .../Specs/HtmlEmbedSpecs.cs | 55 +- .../DiscordChatExporter.Cli.csproj | 4 +- .../Discord/Data/ChannelNode.cs | 21 + .../Discord/Data/Message.cs | 19 +- .../Discord/DiscordClient.cs | 21 +- .../DiscordChatExporter.Core.csproj | 12 +- .../Exporting/ExportAssetDownloader.cs | 19 +- .../Exporting/ExportContext.cs | 3 +- .../Filtering/ContainsMessageFilter.cs | 13 +- .../Filtering/MentionsMessageFilter.cs | 11 +- .../Filtering/Parsing/FilterGrammar.cs | 4 +- .../Filtering/ReactionMessageFilter.cs | 9 +- .../Exporting/HtmlMarkdownVisitor.cs | 30 +- DiscordChatExporter.Core/Utils/Http.cs | 9 +- DiscordChatExporter.Gui/App.axaml | 135 +++++ DiscordChatExporter.Gui/App.axaml.cs | 110 ++++ DiscordChatExporter.Gui/App.xaml | 543 ------------------ DiscordChatExporter.Gui/App.xaml.cs | 52 -- .../ChannelMultiSelectionListBoxBehavior.cs | 5 - .../MultiSelectionListBoxBehavior.cs | 104 ---- DiscordChatExporter.Gui/Bootstrapper.cs | 45 -- .../Converters/ChannelToGroupKeyConverter.cs | 35 -- ...hannelToHierarchicalNameStringConverter.cs | 25 + .../DateTimeOffsetToDateTimeConverter.cs | 31 - .../ExportFormatToStringConverter.cs | 5 +- .../Converters/InverseBoolConverter.cs | 21 - ... => LocaleToDisplayNameStringConverter.cs} | 7 +- ...=> SnowflakeToTimestampStringConverter.cs} | 9 +- .../Converters/TimeSpanToDateTimeConverter.cs | 25 - .../DiscordChatExporter.Gui.csproj | 26 +- DiscordChatExporter.Gui/FodyWeavers.xml | 4 - DiscordChatExporter.Gui/FodyWeavers.xsd | 74 --- .../Framework/DialogManager.cs | 88 +++ .../Framework/DialogVIewModelBase.cs | 25 + .../Framework/SnackbarManager.cs | 34 ++ .../Framework/UserControl.cs | 18 + .../Framework/ViewManager.cs | 37 ++ .../Framework/ViewModelBase.cs | 19 + .../Framework/ViewModelManager.cs | 53 ++ DiscordChatExporter.Gui/Framework/Window.cs | 18 + DiscordChatExporter.Gui/Program.cs | 51 ++ .../Services/SettingsService.cs | 87 +-- .../Services/UpdateService.cs | 4 + DiscordChatExporter.Gui/Utils/Disposable.cs | 10 + .../Utils/DisposableCollector.cs | 28 + .../Utils/Extensions/AvaloniaExtensions.cs | 34 ++ .../Utils/Extensions/DisposableExtensions.cs | 28 + .../NotifyPropertyChangedExtensions.cs | 60 ++ .../Utils/Internationalization.cs | 2 + DiscordChatExporter.Gui/Utils/MediaColor.cs | 8 - .../Utils/NativeMethods.cs | 12 + DiscordChatExporter.Gui/Utils/ProcessEx.cs | 6 +- .../Components/DashboardViewModel.cs | 187 +++--- .../Dialogs/ExportSetupViewModel.cs | 170 +++--- .../ViewModels/Dialogs/MessageBoxViewModel.cs | 56 +- .../ViewModels/Dialogs/SettingsViewModel.cs | 65 ++- .../ViewModels/Framework/DialogManager.cs | 67 --- .../ViewModels/Framework/DialogScreen.cs | 19 - .../ViewModels/Framework/IViewModelFactory.cs | 16 - .../ViewModels/MainViewModel.cs | 127 ++++ .../Messages/NotificationMessage.cs | 3 - .../ViewModels/RootViewModel.cs | 147 ----- .../Views/Components/DashboardView.axaml | 363 ++++++++++++ .../Views/Components/DashboardView.axaml.cs | 37 ++ .../Views/Components/DashboardView.xaml | 440 -------------- .../Views/Components/DashboardView.xaml.cs | 9 - .../Views/Controls/HyperLink.axaml | 19 + .../Views/Controls/HyperLink.axaml.cs | 49 ++ .../Views/Controls/RevealablePasswordBox.xaml | 24 - .../Controls/RevealablePasswordBox.xaml.cs | 40 -- .../Views/Dialogs/ExportSetupView.axaml | 343 +++++++++++ .../Views/Dialogs/ExportSetupView.axaml.cs | 13 + .../Views/Dialogs/ExportSetupView.xaml | 365 ------------ .../Views/Dialogs/ExportSetupView.xaml.cs | 9 - ...ssageBoxView.xaml => MessageBoxView.axaml} | 40 +- .../Views/Dialogs/MessageBoxView.axaml.cs | 9 + .../Views/Dialogs/MessageBoxView.xaml.cs | 9 - .../Views/Dialogs/SettingsView.axaml | 133 +++++ .../Views/Dialogs/SettingsView.axaml.cs | 27 + .../Views/Dialogs/SettingsView.xaml | 192 ------- .../Views/Dialogs/SettingsView.xaml.cs | 17 - DiscordChatExporter.Gui/Views/MainView.axaml | 28 + .../Views/MainView.axaml.cs | 13 + DiscordChatExporter.Gui/Views/RootView.xaml | 34 -- .../Views/RootView.xaml.cs | 9 - 89 files changed, 2451 insertions(+), 2794 deletions(-) create mode 100644 DiscordChatExporter.Core/Discord/Data/ChannelNode.cs create mode 100644 DiscordChatExporter.Gui/App.axaml create mode 100644 DiscordChatExporter.Gui/App.axaml.cs delete mode 100644 DiscordChatExporter.Gui/App.xaml delete mode 100644 DiscordChatExporter.Gui/App.xaml.cs delete mode 100644 DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs delete mode 100644 DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs delete mode 100644 DiscordChatExporter.Gui/Bootstrapper.cs delete mode 100644 DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs create mode 100644 DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs delete mode 100644 DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs delete mode 100644 DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs rename DiscordChatExporter.Gui/Converters/{LocaleToDisplayNameConverter.cs => LocaleToDisplayNameStringConverter.cs} (71%) rename DiscordChatExporter.Gui/Converters/{SnowflakeToDateTimeOffsetConverter.cs => SnowflakeToTimestampStringConverter.cs} (59%) delete mode 100644 DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs delete mode 100644 DiscordChatExporter.Gui/FodyWeavers.xml delete mode 100644 DiscordChatExporter.Gui/FodyWeavers.xsd create mode 100644 DiscordChatExporter.Gui/Framework/DialogManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs create mode 100644 DiscordChatExporter.Gui/Framework/SnackbarManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/UserControl.cs create mode 100644 DiscordChatExporter.Gui/Framework/ViewManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/ViewModelBase.cs create mode 100644 DiscordChatExporter.Gui/Framework/ViewModelManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/Window.cs create mode 100644 DiscordChatExporter.Gui/Program.cs create mode 100644 DiscordChatExporter.Gui/Utils/Disposable.cs create mode 100644 DiscordChatExporter.Gui/Utils/DisposableCollector.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs delete mode 100644 DiscordChatExporter.Gui/Utils/MediaColor.cs create mode 100644 DiscordChatExporter.Gui/Utils/NativeMethods.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs create mode 100644 DiscordChatExporter.Gui/ViewModels/MainViewModel.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/RootViewModel.cs create mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml create mode 100644 DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml.cs rename DiscordChatExporter.Gui/Views/Dialogs/{MessageBoxView.xaml => MessageBoxView.axaml} (54%) create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/MainView.axaml create mode 100644 DiscordChatExporter.Gui/Views/MainView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/RootView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/RootView.xaml.cs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3367db8..5d57fce 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -106,6 +106,7 @@ jobs: -p:CSharpier_Bypass=true --output ${{ matrix.app }}/bin/publish/ --configuration Release + --use-current-runtime - name: Upload artifacts uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 diff --git a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj index dff6242..98b6490 100644 --- a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj +++ b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj @@ -11,19 +11,18 @@ - - - + + + - - - - + + + diff --git a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs index d0a389f..aa3c37a 100644 --- a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs +++ b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs @@ -93,13 +93,12 @@ public static class ExportWrapper Snowflake messageId ) { - var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault( - e => - string.Equals( - e.GetAttribute("data-message-id"), - messageId.ToString(), - StringComparison.OrdinalIgnoreCase - ) + var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e => + string.Equals( + e.GetAttribute("data-message-id"), + messageId.ToString(), + StringComparison.OrdinalIgnoreCase + ) ); if (message is null) @@ -117,13 +116,12 @@ public static class ExportWrapper Snowflake messageId ) { - var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault( - j => - string.Equals( - j.GetProperty("id").GetString(), - messageId.ToString(), - StringComparison.OrdinalIgnoreCase - ) + var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j => + string.Equals( + j.GetProperty("id").GetString(), + messageId.ToString(), + StringComparison.OrdinalIgnoreCase + ) ); if (message.ValueKind == JsonValueKind.Undefined) diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs index 7257711..41e8cc9 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs @@ -53,10 +53,8 @@ public class DateRangeSpecs new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) ], o => - o.Using( - ctx => - ctx.Subject.Should() - .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) + o.Using(ctx => + ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) ) .WhenTypeIs() ); @@ -97,10 +95,8 @@ public class DateRangeSpecs new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) ], o => - o.Using( - ctx => - ctx.Subject.Should() - .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) + o.Using(ctx => + ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) ) .WhenTypeIs() ); @@ -144,10 +140,8 @@ public class DateRangeSpecs new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero) ], o => - o.Using( - ctx => - ctx.Subject.Should() - .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) + o.Using(ctx => + ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) ) .WhenTypeIs() ); diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs index 9de8c0b..207e804 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs @@ -90,12 +90,11 @@ public class HtmlEmbedSpecs .QuerySelectorAll("source") .Select(e => e.GetAttribute("src")) .WhereNotNull() - .Where( - s => - s.Contains( - "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4", - StringComparison.Ordinal - ) + .Where(s => + s.Contains( + "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4", + StringComparison.Ordinal + ) ) .Should() .ContainSingle(); @@ -205,42 +204,38 @@ public class HtmlEmbedSpecs imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png", + StringComparison.Ordinal + ) ); imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png", + StringComparison.Ordinal + ) ); imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png", + StringComparison.Ordinal + ) ); imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png", + StringComparison.Ordinal + ) ); message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle(); diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj index 24e48c4..95ebf18 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj @@ -11,9 +11,9 @@ - + - + diff --git a/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs b/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs new file mode 100644 index 0000000..a465aec --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Core.Discord.Data; + +public record ChannelNode(Channel Channel, IReadOnlyList Children) +{ + public static IReadOnlyList BuildTree(IReadOnlyList channels) + { + IReadOnlyList GetChildren(Channel parent) => + channels + .Where(c => c.Parent?.Id == parent.Id) + .Select(c => new ChannelNode(c, GetChildren(c))) + .ToArray(); + + return channels + .Where(c => c.Parent is null) + .Select(c => new ChannelNode(c, GetChildren(c))) + .ToArray(); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index a7b19ed..6fa13c1 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -83,16 +83,15 @@ public partial record Message // Find embeds with the same URL that only contain a single image and nothing else var trailingEmbeds = embeds .Skip(i + 1) - .TakeWhile( - e => - e.Url == embed.Url - && e.Timestamp is null - && e.Author is null - && e.Color is null - && string.IsNullOrWhiteSpace(e.Description) - && !e.Fields.Any() - && e.Images.Count == 1 - && e.Footer is null + .TakeWhile(e => + e.Url == embed.Url + && e.Timestamp is null + && e.Author is null + && e.Color is null + && string.IsNullOrWhiteSpace(e.Description) + && !e.Fields.Any() + && e.Images.Count == 1 + && e.Footer is null ) .ToArray(); diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index a3f14cb..4b85fec 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -66,12 +66,12 @@ public class DiscordClient(string token) if (remainingRequestCount <= 0 && resetAfterDelay is not null) { var delay = - // Adding a small buffer to the reset time reduces the chance of getting - // rate limited again, because it allows for more requests to be released. - (resetAfterDelay.Value + TimeSpan.FromSeconds(1)) - // Sometimes Discord returns an absurdly high value for the reset time, which - // is not actually enforced by the server. So we cap it at a reasonable value. - .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60)); + // Adding a small buffer to the reset time reduces the chance of getting + // rate limited again, because it allows for more requests to be released. + (resetAfterDelay.Value + TimeSpan.FromSeconds(1)) + // Sometimes Discord returns an absurdly high value for the reset time, which + // is not actually enforced by the server. So we cap it at a reasonable value. + .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60)); await Task.Delay(delay, innerCancellationToken); } @@ -152,8 +152,13 @@ public class DiscordClient(string token) _ => throw new DiscordChatExporterException( $""" - Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}. - Response content: {await response.Content.ReadAsStringAsync(cancellationToken)} + Request to '{url}' failed: {response + .StatusCode.ToString() + .ToSpaceSeparatedWords() + .ToLowerInvariant()}. + Response content: {await response.Content.ReadAsStringAsync( + cancellationToken + )} """, true ) diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index 8c2b575..2813f89 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -2,14 +2,14 @@ - + - - + + - - + + - + \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs index fe957ed..35620d4 100644 --- a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs +++ b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs @@ -58,16 +58,15 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) { var lastModified = response .Content.Headers.TryGetValue("Last-Modified") - ?.Pipe( - s => - DateTimeOffset.TryParse( - s, - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var instant - ) - ? instant - : (DateTimeOffset?)null + ?.Pipe(s => + DateTimeOffset.TryParse( + s, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var instant + ) + ? instant + : (DateTimeOffset?)null ); if (lastModified is not null) diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index c4dca3f..c3d43f6 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -93,8 +93,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request) public IReadOnlyList GetUserRoles(Snowflake id) => TryGetMember(id) - ?.RoleIds - .Select(TryGetRole) + ?.RoleIds.Select(TryGetRole) .WhereNotNull() .OrderByDescending(r => r.Position) .ToArray() ?? []; diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs index 2c3c8ef..7877194 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs @@ -22,12 +22,11 @@ internal class ContainsMessageFilter(string text) : MessageFilter public override bool IsMatch(Message message) => IsMatch(message.Content) - || message.Embeds.Any( - e => - IsMatch(e.Title) - || IsMatch(e.Author?.Name) - || IsMatch(e.Description) - || IsMatch(e.Footer?.Text) - || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value)) + || message.Embeds.Any(e => + IsMatch(e.Title) + || IsMatch(e.Author?.Name) + || IsMatch(e.Description) + || IsMatch(e.Footer?.Text) + || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value)) ); } diff --git a/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs index ae98b14..5ec9d0a 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs @@ -7,11 +7,10 @@ namespace DiscordChatExporter.Core.Exporting.Filtering; internal class MentionsMessageFilter(string value) : MessageFilter { public override bool IsMatch(Message message) => - message.MentionedUsers.Any( - user => - string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) + message.MentionedUsers.Any(user => + string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) ); } diff --git a/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs b/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs index 8aaac42..2015694 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs @@ -30,8 +30,8 @@ internal static class FilterGrammar .OneOf(QuotedString, UnquotedString) .Named("text string"); - private static readonly TextParser ContainsFilter = String.Select( - v => (MessageFilter)new ContainsMessageFilter(v) + private static readonly TextParser ContainsFilter = String.Select(v => + (MessageFilter)new ContainsMessageFilter(v) ); private static readonly TextParser FromFilter = Span.EqualToIgnoreCase("from:") diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs index eb7bb57..d88faaa 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs @@ -7,10 +7,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering; internal class ReactionMessageFilter(string value) : MessageFilter { public override bool IsMatch(Message message) => - message.Reactions.Any( - r => - string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) - || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase) + message.Reactions.Any(r => + string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) + || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase) ); } diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs index 29435db..735aade 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs @@ -155,7 +155,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - {HtmlEncode(inlineCodeBlock.Code)} + {HtmlEncode( + inlineCodeBlock.Code + )} """ ); @@ -174,7 +176,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - {HtmlEncode(multiLineCodeBlock.Code)} + {HtmlEncode( + multiLineCodeBlock.Code + )} """ ); @@ -267,7 +271,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - @{HtmlEncode(displayName)} + @{HtmlEncode( + displayName + )} """ ); } @@ -292,8 +298,12 @@ internal partial class HtmlMarkdownVisitor( var style = color is not null ? $""" - color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1); - """ + color: rgb({color.Value.R}, {color.Value.G}, {color + .Value + .B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color + .Value + .B}, 0.1); + """ : null; buffer.Append( @@ -321,7 +331,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - {HtmlEncode(formatted)} + {HtmlEncode(formatted)} """ ); @@ -344,10 +356,8 @@ internal partial class HtmlMarkdownVisitor var isJumbo = isJumboAllowed - && nodes.All( - n => - n is EmojiNode - || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text) + && nodes.All(n => + n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text) ); var buffer = new StringBuilder(); diff --git a/DiscordChatExporter.Core/Utils/Http.cs b/DiscordChatExporter.Core/Utils/Http.cs index f08984f..b70852c 100644 --- a/DiscordChatExporter.Core/Utils/Http.cs +++ b/DiscordChatExporter.Core/Utils/Http.cs @@ -25,11 +25,10 @@ public static class Http private static bool IsRetryableException(Exception exception) => exception .GetSelfAndChildren() - .Any( - ex => - ex is TimeoutException or SocketException or AuthenticationException - || ex is HttpRequestException hrex - && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK) + .Any(ex => + ex is TimeoutException or SocketException or AuthenticationException + || ex is HttpRequestException hrex + && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK) ); public static ResiliencePipeline ResiliencePipeline { get; } = diff --git a/DiscordChatExporter.Gui/App.axaml b/DiscordChatExporter.Gui/App.axaml new file mode 100644 index 0000000..52f0d27 --- /dev/null +++ b/DiscordChatExporter.Gui/App.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DiscordChatExporter.Gui/App.axaml.cs b/DiscordChatExporter.Gui/App.axaml.cs new file mode 100644 index 0000000..5032d8d --- /dev/null +++ b/DiscordChatExporter.Gui/App.axaml.cs @@ -0,0 +1,110 @@ +using System; +using System.Net; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Platform; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.Services; +using DiscordChatExporter.Gui.ViewModels; +using DiscordChatExporter.Gui.ViewModels.Components; +using DiscordChatExporter.Gui.ViewModels.Dialogs; +using DiscordChatExporter.Gui.Views; +using Material.Styles.Themes; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordChatExporter.Gui; + +public partial class App : Application, IDisposable +{ + private readonly ServiceProvider _services; + private readonly MainViewModel _mainViewModel; + + public App() + { + var services = new ServiceCollection(); + + // Framework + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Services + services.AddSingleton(); + services.AddSingleton(); + + // View models + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + _services = services.BuildServiceProvider(true); + _mainViewModel = _services.GetRequiredService().CreateMainViewModel(); + } + + public override void Initialize() + { + // Increase maximum concurrent connections + ServicePointManager.DefaultConnectionLimit = 20; + + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainView { DataContext = _mainViewModel }; + + base.OnFrameworkInitializationCompleted(); + + // Set custom theme colors + SetDefaultTheme(); + } + + public void Dispose() => _services.Dispose(); +} + +public partial class App +{ + public static void SetLightTheme() + { + if (Current is null) + return; + + Current.LocateMaterialTheme().CurrentTheme = Theme.Create( + Theme.Light, + Color.Parse("#343838"), + Color.Parse("#F9A825") + ); + } + + public static void SetDarkTheme() + { + if (Current is null) + return; + + Current.LocateMaterialTheme().CurrentTheme = Theme.Create( + Theme.Dark, + Color.Parse("#E8E8E8"), + Color.Parse("#F9A825") + ); + } + + public static void SetDefaultTheme() + { + if (Current is null) + return; + + var isDarkModeEnabledByDefault = + Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark; + + if (isDarkModeEnabledByDefault) + SetDarkTheme(); + else + SetLightTheme(); + } +} diff --git a/DiscordChatExporter.Gui/App.xaml b/DiscordChatExporter.Gui/App.xaml deleted file mode 100644 index e90a123..0000000 --- a/DiscordChatExporter.Gui/App.xaml +++ /dev/null @@ -1,543 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/App.xaml.cs b/DiscordChatExporter.Gui/App.xaml.cs deleted file mode 100644 index 35c890c..0000000 --- a/DiscordChatExporter.Gui/App.xaml.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Reflection; -using DiscordChatExporter.Gui.Utils; -using MaterialDesignThemes.Wpf; - -namespace DiscordChatExporter.Gui; - -public partial class App -{ - private static Assembly Assembly { get; } = typeof(App).Assembly; - - public static string Name { get; } = Assembly.GetName().Name!; - - public static Version Version { get; } = Assembly.GetName().Version!; - - public static string VersionString { get; } = Version.ToString(3); - - public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter"; - - public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest"; - - public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs"; -} - -public partial class App -{ - private static Theme LightTheme { get; } = - Theme.Create( - new MaterialDesignLightTheme(), - MediaColor.FromHex("#343838"), - MediaColor.FromHex("#F9A825") - ); - - private static Theme DarkTheme { get; } = - Theme.Create( - new MaterialDesignDarkTheme(), - MediaColor.FromHex("#E8E8E8"), - MediaColor.FromHex("#F9A825") - ); - - public static void SetLightTheme() - { - var paletteHelper = new PaletteHelper(); - paletteHelper.SetTheme(LightTheme); - } - - public static void SetDarkTheme() - { - var paletteHelper = new PaletteHelper(); - paletteHelper.SetTheme(DarkTheme); - } -} diff --git a/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs b/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs deleted file mode 100644 index 66071f5..0000000 --- a/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs +++ /dev/null @@ -1,5 +0,0 @@ -using DiscordChatExporter.Core.Discord.Data; - -namespace DiscordChatExporter.Gui.Behaviors; - -public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior; diff --git a/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs b/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs deleted file mode 100644 index 8e8242e..0000000 --- a/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections; -using System.Collections.Specialized; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using Microsoft.Xaml.Behaviors; - -namespace DiscordChatExporter.Gui.Behaviors; - -public class MultiSelectionListBoxBehavior : Behavior -{ - public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register( - nameof(SelectedItems), - typeof(IList), - typeof(MultiSelectionListBoxBehavior), - new FrameworkPropertyMetadata( - null, - FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, - OnSelectedItemsChanged - ) - ); - - private static void OnSelectedItemsChanged( - DependencyObject sender, - DependencyPropertyChangedEventArgs args - ) - { - var behavior = (MultiSelectionListBoxBehavior)sender; - if (behavior._modelHandled) - return; - - if (behavior.AssociatedObject is null) - return; - - behavior._modelHandled = true; - behavior.SelectItems(); - behavior._modelHandled = false; - } - - private bool _viewHandled; - private bool _modelHandled; - - public IList? SelectedItems - { - get => (IList?)GetValue(SelectedItemsProperty); - set => SetValue(SelectedItemsProperty, value); - } - - // Propagate selected items from the model to the view - private void SelectItems() - { - _viewHandled = true; - - AssociatedObject.SelectedItems.Clear(); - if (SelectedItems is not null) - { - foreach (var item in SelectedItems) - AssociatedObject.SelectedItems.Add(item); - } - - _viewHandled = false; - } - - // Propagate selected items from the view to the model - private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args) - { - if (_viewHandled) - return; - if (AssociatedObject.Items.SourceCollection is null) - return; - - SelectedItems = AssociatedObject.SelectedItems.Cast().ToArray(); - } - - private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args) - { - if (_viewHandled) - return; - if (AssociatedObject.Items.SourceCollection is null) - return; - SelectItems(); - } - - protected override void OnAttached() - { - base.OnAttached(); - - AssociatedObject.SelectionChanged += OnListBoxSelectionChanged; - ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged += - OnListBoxItemsChanged; - } - - protected override void OnDetaching() - { - base.OnDetaching(); - - if (AssociatedObject is not null) - { - AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged; - ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -= - OnListBoxItemsChanged; - } - } -} diff --git a/DiscordChatExporter.Gui/Bootstrapper.cs b/DiscordChatExporter.Gui/Bootstrapper.cs deleted file mode 100644 index 1be1e8b..0000000 --- a/DiscordChatExporter.Gui/Bootstrapper.cs +++ /dev/null @@ -1,45 +0,0 @@ -using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.ViewModels; -using DiscordChatExporter.Gui.ViewModels.Framework; -using Stylet; -using StyletIoC; -#if !DEBUG -using System.Windows; -using System.Windows.Threading; -#endif - -namespace DiscordChatExporter.Gui; - -public class Bootstrapper : Bootstrapper -{ - protected override void OnStart() - { - base.OnStart(); - - // Set the default theme. - // Preferred theme will be set later, once the settings are loaded. - App.SetLightTheme(); - } - - protected override void ConfigureIoC(IStyletIoCBuilder builder) - { - base.ConfigureIoC(builder); - - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToAbstractFactory(); - } - -#if !DEBUG - protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args) - { - base.OnUnhandledException(args); - - MessageBox.Show( - args.Exception.ToString(), - "Error occured", - MessageBoxButton.OK, - MessageBoxImage.Error - ); - } -#endif -} diff --git a/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs b/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs deleted file mode 100644 index 37c91ba..0000000 --- a/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using DiscordChatExporter.Core.Discord.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(Channel), typeof(string))] -public class ChannelToGroupKeyConverter : IValueConverter -{ - public static ChannelToGroupKeyConverter Instance { get; } = new(); - - public object? Convert( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => - value switch - { - Channel { IsThread: true, Parent: not null } channel - => $"Threads in #{channel.Parent.Name}", - - Channel channel => channel.Parent?.Name ?? "???", - - _ => null - }; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => throw new NotSupportedException(); -} diff --git a/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs b/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs new file mode 100644 index 0000000..d092288 --- /dev/null +++ b/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using DiscordChatExporter.Core.Discord.Data; + +namespace DiscordChatExporter.Gui.Converters; + +public class ChannelToHierarchicalNameStringConverter : IValueConverter +{ + public static ChannelToHierarchicalNameStringConverter Instance { get; } = new(); + + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => value is Channel channel ? channel.GetHierarchicalName() : null; + + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs b/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs deleted file mode 100644 index 4511336..0000000 --- a/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(DateTimeOffset?), typeof(DateTime?))] -public class DateTimeOffsetToDateTimeConverter : IValueConverter -{ - public static DateTimeOffsetToDateTimeConverter Instance { get; } = new(); - - public object? Convert( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => - value is DateTimeOffset dateTimeOffsetValue - ? dateTimeOffsetValue.DateTime - : default(DateTime?); - - public object? ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => - value is DateTime dateTimeValue - ? new DateTimeOffset(dateTimeValue) - : default(DateTimeOffset?); -} diff --git a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs index fe8a132..1782c89 100644 --- a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs +++ b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs @@ -1,11 +1,10 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; using DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Gui.Converters; -[ValueConversion(typeof(ExportFormat), typeof(string))] public class ExportFormatToStringConverter : IValueConverter { public static ExportFormatToStringConverter Instance { get; } = new(); @@ -15,7 +14,7 @@ public class ExportFormatToStringConverter : IValueConverter Type targetType, object? parameter, CultureInfo culture - ) => value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default; + ) => value is ExportFormat format ? format.GetDisplayName() : default; public object ConvertBack( object? value, diff --git a/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs b/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs deleted file mode 100644 index d6a8ad0..0000000 --- a/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(bool), typeof(bool))] -public class InverseBoolConverter : IValueConverter -{ - public static InverseBoolConverter Instance { get; } = new(); - - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value is false; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is false; -} diff --git a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs similarity index 71% rename from DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs rename to DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs index f7cff19..5ec65d9 100644 --- a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs +++ b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs @@ -1,13 +1,12 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace DiscordChatExporter.Gui.Converters; -[ValueConversion(typeof(string), typeof(string))] -public class LocaleToDisplayNameConverter : IValueConverter +public class LocaleToDisplayNameStringConverter : IValueConverter { - public static LocaleToDisplayNameConverter Instance { get; } = new(); + public static LocaleToDisplayNameStringConverter Instance { get; } = new(); public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value is string locale && !string.IsNullOrWhiteSpace(locale) diff --git a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs b/DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs similarity index 59% rename from DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs rename to DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs index b4b0f01..1147933 100644 --- a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs +++ b/DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs @@ -1,21 +1,20 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; using DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Gui.Converters; -[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))] -public class SnowflakeToDateTimeOffsetConverter : IValueConverter +public class SnowflakeToTimestampStringConverter : IValueConverter { - public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new(); + public static SnowflakeToTimestampStringConverter Instance { get; } = new(); public object? Convert( object? value, Type targetType, object? parameter, CultureInfo culture - ) => value is Snowflake snowflake ? snowflake.ToDate() : null; + ) => value is Snowflake snowflake ? snowflake.ToDate().ToString("g", culture) : null; public object ConvertBack( object? value, diff --git a/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs b/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs deleted file mode 100644 index 658d7a5..0000000 --- a/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))] -public class TimeSpanToDateTimeConverter : IValueConverter -{ - public static TimeSpanToDateTimeConverter Instance { get; } = new(); - - public object? Convert( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is TimeSpan timeSpanValue ? DateTime.Today.Add(timeSpanValue) : default(DateTime?); - - public object? ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is DateTime dateTimeValue ? dateTimeValue.TimeOfDay : default(TimeSpan?); -} diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj index fad90a5..8d32b29 100644 --- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj +++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj @@ -2,28 +2,30 @@ WinExe - $(TargetFramework)-windows DiscordChatExporter - true - ../favicon.ico + ..\favicon.ico - + + + + + - + + - + + - - - - - - + + + + diff --git a/DiscordChatExporter.Gui/FodyWeavers.xml b/DiscordChatExporter.Gui/FodyWeavers.xml deleted file mode 100644 index 4e68ed1..0000000 --- a/DiscordChatExporter.Gui/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/FodyWeavers.xsd b/DiscordChatExporter.Gui/FodyWeavers.xsd deleted file mode 100644 index 69dbe48..0000000 --- a/DiscordChatExporter.Gui/FodyWeavers.xsd +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - Used to control if the On_PropertyName_Changed feature is enabled. - - - - - Used to control if the Dependent properties feature is enabled. - - - - - Used to control if the IsChanged property feature is enabled. - - - - - Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form. - - - - - Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project. - - - - - Used to control if equality checks should use the Equals method resolved from the base class. - - - - - Used to control if equality checks should use the static Equals method resolved from the base class. - - - - - Used to turn off build warnings from this weaver. - - - - - Used to turn off build warnings about mismatched On_PropertyName_Changed methods. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Framework/DialogManager.cs b/DiscordChatExporter.Gui/Framework/DialogManager.cs new file mode 100644 index 0000000..6ec2f32 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/DialogManager.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AsyncKeyedLock; +using Avalonia; +using Avalonia.Platform.Storage; +using DialogHostAvalonia; +using DiscordChatExporter.Gui.Utils.Extensions; + +namespace DiscordChatExporter.Gui.Framework; + +public class DialogManager : IDisposable +{ + private readonly AsyncNonKeyedLocker _dialogLock = new(); + + public async Task ShowDialogAsync(DialogViewModelBase dialog) + { + using (await _dialogLock.LockAsync()) + { + await DialogHost.Show( + dialog, + // It's fine to await in a void method here because it's an event handler + // ReSharper disable once AsyncVoidLambda + async (object _, DialogOpenedEventArgs args) => + { + await dialog.WaitForCloseAsync(); + + try + { + args.Session.Close(); + } + catch (InvalidOperationException) + { + // Dialog host is already processing a close operation + } + } + ); + + return dialog.DialogResult; + } + } + + public async Task PromptSaveFilePathAsync( + IReadOnlyList? fileTypes = null, + string defaultFilePath = "" + ) + { + var topLevel = + Application.Current?.ApplicationLifetime?.TryGetTopLevel() + ?? throw new ApplicationException("Could not find the top-level visual element."); + + var file = await topLevel.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + FileTypeChoices = fileTypes, + SuggestedFileName = defaultFilePath, + DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.') + } + ); + + return file?.Path.LocalPath; + } + + public async Task PromptDirectoryPathAsync(string defaultDirPath = "") + { + var topLevel = + Application.Current?.ApplicationLifetime?.TryGetTopLevel() + ?? throw new ApplicationException("Could not find the top-level visual element."); + + var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync( + defaultDirPath + ); + + var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + AllowMultiple = false, + SuggestedStartLocation = startLocation + } + ); + + return folderPickResult.FirstOrDefault()?.Path.LocalPath; + } + + public void Dispose() => _dialogLock.Dispose(); +} diff --git a/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs b/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs new file mode 100644 index 0000000..94f28ba --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace DiscordChatExporter.Gui.Framework; + +public abstract partial class DialogViewModelBase : ViewModelBase +{ + private readonly TaskCompletionSource _closeTcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + [ObservableProperty] + private T? _dialogResult; + + [RelayCommand] + protected void Close(T dialogResult) + { + DialogResult = dialogResult; + _closeTcs.TrySetResult(dialogResult); + } + + public async Task WaitForCloseAsync() => await _closeTcs.Task; +} + +public abstract class DialogViewModelBase : DialogViewModelBase; diff --git a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs new file mode 100644 index 0000000..a4af9f5 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs @@ -0,0 +1,34 @@ +using System; +using Avalonia.Threading; +using Material.Styles.Controls; +using Material.Styles.Models; + +namespace DiscordChatExporter.Gui.Framework; + +public class SnackbarManager +{ + private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5); + + public void Notify(string message, TimeSpan? duration = null) => + SnackbarHost.Post( + new SnackbarModel(message, duration ?? _defaultDuration), + null, + DispatcherPriority.Normal + ); + + public void Notify( + string message, + string actionText, + Action actionHandler, + TimeSpan? duration = null + ) => + SnackbarHost.Post( + new SnackbarModel( + message, + duration ?? _defaultDuration, + new SnackbarButtonModel { Text = actionText, Action = actionHandler } + ), + null, + DispatcherPriority.Normal + ); +} diff --git a/DiscordChatExporter.Gui/Framework/UserControl.cs b/DiscordChatExporter.Gui/Framework/UserControl.cs new file mode 100644 index 0000000..fda1b3e --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/UserControl.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; + +namespace DiscordChatExporter.Gui.Framework; + +public class UserControl : UserControl +{ + public new TDataContext DataContext + { + get => + base.DataContext is TDataContext dataContext + ? dataContext + : throw new InvalidCastException( + $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." + ); + set => base.DataContext = value; + } +} diff --git a/DiscordChatExporter.Gui/Framework/ViewManager.cs b/DiscordChatExporter.Gui/Framework/ViewManager.cs new file mode 100644 index 0000000..f335e44 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/ViewManager.cs @@ -0,0 +1,37 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; + +namespace DiscordChatExporter.Gui.Framework; + +public partial class ViewManager +{ + public Control? TryBindView(ViewModelBase viewModel) + { + var name = viewModel + .GetType() + .FullName?.Replace("ViewModel", "View", StringComparison.Ordinal); + + if (string.IsNullOrWhiteSpace(name)) + return null; + + var type = Type.GetType(name); + if (type is null) + return null; + + if (Activator.CreateInstance(type) is not Control view) + return null; + + view.DataContext ??= viewModel; + + return view; + } +} + +public partial class ViewManager : IDataTemplate +{ + bool IDataTemplate.Match(object? data) => data is ViewModelBase; + + Control? ITemplate.Build(object? data) => + data is ViewModelBase viewModel ? TryBindView(viewModel) : null; +} diff --git a/DiscordChatExporter.Gui/Framework/ViewModelBase.cs b/DiscordChatExporter.Gui/Framework/ViewModelBase.cs new file mode 100644 index 0000000..2f8a007 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/ViewModelBase.cs @@ -0,0 +1,19 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DiscordChatExporter.Gui.Framework; + +public abstract class ViewModelBase : ObservableObject, IDisposable +{ + ~ViewModelBase() => Dispose(false); + + protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty); + + protected virtual void Dispose(bool disposing) { } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs new file mode 100644 index 0000000..4102280 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Gui.ViewModels; +using DiscordChatExporter.Gui.ViewModels.Components; +using DiscordChatExporter.Gui.ViewModels.Dialogs; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordChatExporter.Gui.Framework; + +public class ViewModelManager(IServiceProvider services) +{ + public MainViewModel CreateMainViewModel() => services.GetRequiredService(); + + public DashboardViewModel CreateDashboardViewModel() => + services.GetRequiredService(); + + public ExportSetupViewModel CreateExportSetupViewModel( + Guild guild, + IReadOnlyList channels + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Guild = guild; + viewModel.Channels = channels; + + return viewModel; + } + + public MessageBoxViewModel CreateMessageBoxViewModel( + string title, + string message, + string? okButtonText, + string? cancelButtonText + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Title = title; + viewModel.Message = message; + viewModel.DefaultButtonText = okButtonText; + viewModel.CancelButtonText = cancelButtonText; + + return viewModel; + } + + public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) => + CreateMessageBoxViewModel(title, message, "CLOSE", null); + + public SettingsViewModel CreateSettingsViewModel() => + services.GetRequiredService(); +} diff --git a/DiscordChatExporter.Gui/Framework/Window.cs b/DiscordChatExporter.Gui/Framework/Window.cs new file mode 100644 index 0000000..a5ff98e --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/Window.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; + +namespace DiscordChatExporter.Gui.Framework; + +public class Window : Window +{ + public new TDataContext DataContext + { + get => + base.DataContext is TDataContext dataContext + ? dataContext + : throw new InvalidCastException( + $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." + ); + set => base.DataContext = value; + } +} diff --git a/DiscordChatExporter.Gui/Program.cs b/DiscordChatExporter.Gui/Program.cs new file mode 100644 index 0000000..a8a494a --- /dev/null +++ b/DiscordChatExporter.Gui/Program.cs @@ -0,0 +1,51 @@ +using System; +using System.Reflection; +using Avalonia; +using DiscordChatExporter.Gui.Utils; + +namespace DiscordChatExporter.Gui; + +public static class Program +{ + private static Assembly Assembly { get; } = typeof(App).Assembly; + + public static string Name { get; } = Assembly.GetName().Name!; + + public static Version Version { get; } = Assembly.GetName().Version!; + + public static string VersionString { get; } = Version.ToString(3); + + public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter"; + + public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest"; + + public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs"; + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UsePlatformDetect().LogToTrace(); + + [STAThread] + public static int Main(string[] args) + { + // Build and run the app + var builder = BuildAvaloniaApp(); + + try + { + return builder.StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + if (OperatingSystem.IsWindows()) + _ = NativeMethods.Windows.MessageBox(0, ex.ToString(), "Fatal Error", 0x10); + + throw; + } + finally + { + // Clean up after application shutdown + if (builder.Instance is IDisposable disposableApp) + disposableApp.Dispose(); + } + } +} diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index 58310ef..4294c4b 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -1,48 +1,80 @@ using System; using System.IO; +using Avalonia; +using Avalonia.Platform; using Cogwheel; +using CommunityToolkit.Mvvm.ComponentModel; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Gui.Models; using Microsoft.Win32; namespace DiscordChatExporter.Gui.Services; +[INotifyPropertyChanged] public partial class SettingsService() : SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat")) { - public bool IsUkraineSupportMessageEnabled { get; set; } = true; + [ObservableProperty] + private bool _isUkraineSupportMessageEnabled = true; - public bool IsAutoUpdateEnabled { get; set; } = true; + [ObservableProperty] + private bool _isAutoUpdateEnabled = true; - public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault(); + [ObservableProperty] + private bool _isDarkModeEnabled; - public bool IsTokenPersisted { get; set; } = true; + [ObservableProperty] + private bool _isTokenPersisted = true; - public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None; + [ObservableProperty] + private ThreadInclusionMode _threadInclusionMode; - public string? Locale { get; set; } + [ObservableProperty] + private string? _locale; - public bool IsUtcNormalizationEnabled { get; set; } + [ObservableProperty] + private bool _isUtcNormalizationEnabled; - public int ParallelLimit { get; set; } = 1; + [ObservableProperty] + private int _parallelLimit = 1; - public Version? LastAppVersion { get; set; } + [ObservableProperty] + private Version? _lastAppVersion; - public string? LastToken { get; set; } + [ObservableProperty] + private string? _lastToken; - public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; + [ObservableProperty] + private ExportFormat _lastExportFormat = ExportFormat.HtmlDark; - public string? LastPartitionLimitValue { get; set; } + [ObservableProperty] + private string? _lastPartitionLimitValue; - public string? LastMessageFilterValue { get; set; } + [ObservableProperty] + private string? _lastMessageFilterValue; - public bool LastShouldFormatMarkdown { get; set; } = true; + [ObservableProperty] + private bool _lastShouldFormatMarkdown = true; - public bool LastShouldDownloadAssets { get; set; } + [ObservableProperty] + private bool _lastShouldDownloadAssets; - public bool LastShouldReuseAssets { get; set; } + [ObservableProperty] + private bool _lastShouldReuseAssets; - public string? LastAssetsDirPath { get; set; } + [ObservableProperty] + private string? _lastAssetsDirPath; + + public override void Reset() + { + base.Reset(); + + // Reset the dark mode setting separately because its default value is evaluated dynamically + // and cannot be set by the field initializer. + IsDarkModeEnabled = + Application.Current?.PlatformSettings?.GetColorValues().ThemeVariant + == PlatformThemeVariant.Dark; + } public override void Save() { @@ -56,24 +88,3 @@ public partial class SettingsService() LastToken = lastToken; } } - -public partial class SettingsService -{ - private static bool IsDarkModeEnabledByDefault() - { - try - { - return Registry - .CurrentUser.OpenSubKey( - "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", - false - ) - ?.GetValue("AppsUseLightTheme") - is 0; - } - catch - { - return false; - } - } -} diff --git a/DiscordChatExporter.Gui/Services/UpdateService.cs b/DiscordChatExporter.Gui/Services/UpdateService.cs index 738ba21..cefb053 100644 --- a/DiscordChatExporter.Gui/Services/UpdateService.cs +++ b/DiscordChatExporter.Gui/Services/UpdateService.cs @@ -51,6 +51,10 @@ public class UpdateService(SettingsService settingsService) : IDisposable if (!settingsService.IsAutoUpdateEnabled) return; + // Onova only works on Windows currently + if (!OperatingSystem.IsWindows()) + return; + if (_updateVersion is null || !_updatePrepared || _updaterLaunched) return; diff --git a/DiscordChatExporter.Gui/Utils/Disposable.cs b/DiscordChatExporter.Gui/Utils/Disposable.cs new file mode 100644 index 0000000..e7bdfb6 --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Disposable.cs @@ -0,0 +1,10 @@ +using System; + +namespace DiscordChatExporter.Gui.Utils; + +internal class Disposable(Action dispose) : IDisposable +{ + public static IDisposable Create(Action dispose) => new Disposable(dispose); + + public void Dispose() => dispose(); +} diff --git a/DiscordChatExporter.Gui/Utils/DisposableCollector.cs b/DiscordChatExporter.Gui/Utils/DisposableCollector.cs new file mode 100644 index 0000000..e96761b --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/DisposableCollector.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using DiscordChatExporter.Gui.Utils.Extensions; + +namespace DiscordChatExporter.Gui.Utils; + +internal class DisposableCollector : IDisposable +{ + private readonly object _lock = new(); + private readonly List _items = []; + + public void Add(IDisposable item) + { + lock (_lock) + { + _items.Add(item); + } + } + + public void Dispose() + { + lock (_lock) + { + _items.DisposeAll(); + _items.Clear(); + } + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs new file mode 100644 index 0000000..15df033 --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.VisualTree; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class AvaloniaExtensions +{ + public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) => + lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime + ? desktopLifetime.MainWindow + : null; + + public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) => + lifetime.TryGetMainWindow() + ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel; + + public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0) + { + if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + return desktopLifetime.TryShutdown(exitCode); + } + + if (lifetime is IControlledApplicationLifetime controlledLifetime) + { + controlledLifetime.Shutdown(exitCode); + return true; + } + + return false; + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs new file mode 100644 index 0000000..de8651d --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class DisposableExtensions +{ + public static void DisposeAll(this IEnumerable disposables) + { + var exceptions = default(List); + + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + (exceptions ??= []).Add(ex); + } + } + + if (exceptions?.Any() == true) + throw new AggregateException(exceptions); + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs new file mode 100644 index 0000000..2f9744f --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class NotifyPropertyChangedExtensions +{ + public static IDisposable WatchProperty( + this TOwner owner, + Expression> propertyExpression, + Action callback, + bool watchInitialValue = true + ) + where TOwner : INotifyPropertyChanged + { + var memberExpression = + propertyExpression.Body as MemberExpression + // Property value might be boxed inside a conversion expression, if the types don't match + ?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression; + + if (memberExpression?.Member is not PropertyInfo property) + throw new ArgumentException("Provided expression must reference a property."); + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if ( + string.IsNullOrWhiteSpace(args.PropertyName) + || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) + ) + { + callback(); + } + } + + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValue) + callback(); + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + + public static IDisposable WatchAllProperties( + this TOwner owner, + Action callback, + bool watchInitialValues = true + ) + where TOwner : INotifyPropertyChanged + { + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback(); + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValues) + callback(); + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } +} diff --git a/DiscordChatExporter.Gui/Utils/Internationalization.cs b/DiscordChatExporter.Gui/Utils/Internationalization.cs index 7567c3d..bcc58ae 100644 --- a/DiscordChatExporter.Gui/Utils/Internationalization.cs +++ b/DiscordChatExporter.Gui/Utils/Internationalization.cs @@ -7,4 +7,6 @@ internal static class Internationalization public static bool Is24Hours => string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator) && string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator); + + public static string AvaloniaClockIdentifier => Is24Hours ? "24HourClock" : "12HourClock"; } diff --git a/DiscordChatExporter.Gui/Utils/MediaColor.cs b/DiscordChatExporter.Gui/Utils/MediaColor.cs deleted file mode 100644 index 07fb8ac..0000000 --- a/DiscordChatExporter.Gui/Utils/MediaColor.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Windows.Media; - -namespace DiscordChatExporter.Gui.Utils; - -internal static class MediaColor -{ - public static Color FromHex(string hex) => (Color)ColorConverter.ConvertFromString(hex); -} diff --git a/DiscordChatExporter.Gui/Utils/NativeMethods.cs b/DiscordChatExporter.Gui/Utils/NativeMethods.cs new file mode 100644 index 0000000..a52aa6e --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/NativeMethods.cs @@ -0,0 +1,12 @@ +using System.Runtime.InteropServices; + +namespace DiscordChatExporter.Gui.Utils; + +internal static class NativeMethods +{ + public static class Windows + { + [DllImport("user32.dll", SetLastError = true)] + public static extern int MessageBox(nint hWnd, string text, string caption, uint type); + } +} diff --git a/DiscordChatExporter.Gui/Utils/ProcessEx.cs b/DiscordChatExporter.Gui/Utils/ProcessEx.cs index 100550b..82ef9eb 100644 --- a/DiscordChatExporter.Gui/Utils/ProcessEx.cs +++ b/DiscordChatExporter.Gui/Utils/ProcessEx.cs @@ -6,10 +6,8 @@ internal static class ProcessEx { public static void StartShellExecute(string path) { - using var process = new Process - { - StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true } - }; + using var process = new Process(); + process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true }; process.Start(); } diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index cf075d3..5ee722f 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -1,104 +1,113 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Utils; -using DiscordChatExporter.Gui.ViewModels.Dialogs; -using DiscordChatExporter.Gui.ViewModels.Framework; -using DiscordChatExporter.Gui.ViewModels.Messages; +using DiscordChatExporter.Gui.Utils.Extensions; using Gress; using Gress.Completable; -using Stylet; namespace DiscordChatExporter.Gui.ViewModels.Components; -public class DashboardViewModel : PropertyChangedBase +public partial class DashboardViewModel : ViewModelBase { - private readonly IViewModelFactory _viewModelFactory; - private readonly IEventAggregator _eventAggregator; + private readonly ViewModelManager _viewModelManager; + private readonly SnackbarManager _snackbarManager; private readonly DialogManager _dialogManager; private readonly SettingsService _settingsService; + private readonly DisposableCollector _eventRoot = new(); private readonly AutoResetProgressMuxer _progressMuxer; private DiscordClient? _discord; - public bool IsBusy { get; private set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))] + [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] + [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] + [NotifyCanExecuteChangedFor(nameof(ExportCommand))] + private bool _isBusy; - public ProgressContainer Progress { get; } = new(); - - public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; - - public string? Token { get; set; } + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] + private string? _token; - public IReadOnlyList? AvailableGuilds { get; private set; } + [ObservableProperty] + private IReadOnlyList? _availableGuilds; - public Guild? SelectedGuild { get; set; } + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] + [NotifyCanExecuteChangedFor(nameof(ExportCommand))] + private Guild? _selectedGuild; - public IReadOnlyList? AvailableChannels { get; private set; } - - public IReadOnlyList? SelectedChannels { get; set; } + [ObservableProperty] + private IReadOnlyList? _availableChannels; public DashboardViewModel( - IViewModelFactory viewModelFactory, - IEventAggregator eventAggregator, + ViewModelManager viewModelManager, DialogManager dialogManager, + SnackbarManager snackbarManager, SettingsService settingsService ) { - _viewModelFactory = viewModelFactory; - _eventAggregator = eventAggregator; + _viewModelManager = viewModelManager; _dialogManager = dialogManager; + _snackbarManager = snackbarManager; _settingsService = settingsService; _progressMuxer = Progress.CreateMuxer().WithAutoReset(); - this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); - - Progress.Bind( - o => o.Current, - (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate) + _eventRoot.Add( + Progress.WatchProperty( + o => o.Current, + () => OnPropertyChanged(nameof(IsProgressIndeterminate)) + ) ); - this.Bind( - o => o.SelectedGuild, - (_, _) => - { - // Reset channels when the selected guild changes, to avoid jitter - // due to the channels being asynchronously loaded. - AvailableChannels = null; - SelectedChannels = null; - - // Pull channels for the selected guild - // (ideally this should be called inside `PullGuilds()`, - // but Stylet doesn't support async commands) - PullChannels(); - } + _eventRoot.Add( + SelectedChannels.WatchProperty( + o => o.Count, + () => ExportCommand.NotifyCanExecuteChanged() + ) ); } - public void OnViewLoaded() + public ProgressContainer Progress { get; } = new(); + + public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; + + public ObservableCollection SelectedChannels { get; } = []; + + [RelayCommand] + private void Initialize() { if (!string.IsNullOrWhiteSpace(_settingsService.LastToken)) Token = _settingsService.LastToken; } - public async void ShowSettings() => - await _dialogManager.ShowDialogAsync(_viewModelFactory.CreateSettingsViewModel()); + [RelayCommand] + private async Task ShowSettingsAsync() => + await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel()); - public void ShowHelp() => ProcessEx.StartShellExecute(App.DocumentationUrl); + [RelayCommand] + private void ShowHelp() => ProcessEx.StartShellExecute(Program.DocumentationUrl); - public bool CanPullGuilds => !IsBusy && !string.IsNullOrWhiteSpace(Token); + private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token); - public async void PullGuilds() + [RelayCommand(CanExecute = nameof(CanPullGuilds))] + private async Task PullGuildsAsync() { IsBusy = true; var progress = _progressMuxer.CreateInput(); @@ -112,7 +121,7 @@ public class DashboardViewModel : PropertyChangedBase AvailableGuilds = null; SelectedGuild = null; AvailableChannels = null; - SelectedChannels = null; + SelectedChannels.Clear(); _discord = new DiscordClient(token); _settingsService.LastToken = token; @@ -121,14 +130,16 @@ public class DashboardViewModel : PropertyChangedBase AvailableGuilds = guilds; SelectedGuild = guilds.FirstOrDefault(); + + await PullChannelsAsync(); } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + _snackbarManager.Notify(ex.Message.TrimEnd('.')); } catch (Exception ex) { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( + var dialog = _viewModelManager.CreateMessageBoxViewModel( "Error pulling guilds", ex.ToString() ); @@ -142,9 +153,10 @@ public class DashboardViewModel : PropertyChangedBase } } - public bool CanPullChannels => !IsBusy && _discord is not null && SelectedGuild is not null; + private bool CanPullChannels() => !IsBusy && _discord is not null && SelectedGuild is not null; - public async void PullChannels() + [RelayCommand(CanExecute = nameof(CanPullChannels))] + private async Task PullChannelsAsync() { IsBusy = true; var progress = _progressMuxer.CreateInput(); @@ -155,18 +167,13 @@ public class DashboardViewModel : PropertyChangedBase return; AvailableChannels = null; - SelectedChannels = null; + SelectedChannels.Clear(); var channels = new List(); // Regular channels await foreach (var channel in _discord.GetGuildChannelsAsync(SelectedGuild.Id)) - { - if (channel.IsCategory) - continue; - channels.Add(channel); - } // Threads if (_settingsService.ThreadInclusionMode != ThreadInclusionMode.None) @@ -182,16 +189,24 @@ public class DashboardViewModel : PropertyChangedBase } } - AvailableChannels = channels; - SelectedChannels = null; + // Build a hierarchy of channels + var channelTree = ChannelNode.BuildTree( + channels + .OrderByDescending(c => c.IsDirect ? c.LastMessageId : null) + .ThenBy(c => c.Position) + .ToArray() + ); + + AvailableChannels = channelTree; + SelectedChannels.Clear(); } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + _snackbarManager.Notify(ex.Message.TrimEnd('.')); } catch (Exception ex) { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( + var dialog = _viewModelManager.CreateMessageBoxViewModel( "Error pulling channels", ex.ToString() ); @@ -205,30 +220,24 @@ public class DashboardViewModel : PropertyChangedBase } } - public bool CanExport => - !IsBusy - && _discord is not null - && SelectedGuild is not null - && SelectedChannels?.Any() is true; + private bool CanExport() => + !IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any(); - public async void Export() + [RelayCommand(CanExecute = nameof(CanExport))] + private async Task ExportAsync() { IsBusy = true; try { - if ( - _discord is null - || SelectedGuild is null - || SelectedChannels is null - || !SelectedChannels.Any() - ) + if (_discord is null || SelectedGuild is null || !SelectedChannels.Any()) return; - var dialog = _viewModelFactory.CreateExportSetupViewModel( + var dialog = _viewModelManager.CreateExportSetupViewModel( SelectedGuild, - SelectedChannels + SelectedChannels.Select(c => c.Channel).ToArray() ); + if (await _dialogManager.ShowDialogAsync(dialog) != true) return; @@ -276,7 +285,7 @@ public class DashboardViewModel : PropertyChangedBase } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + _snackbarManager.Notify(ex.Message.TrimEnd('.')); } finally { @@ -288,16 +297,14 @@ public class DashboardViewModel : PropertyChangedBase // Notify of the overall completion if (successfulExportCount > 0) { - _eventAggregator.Publish( - new NotificationMessage( - $"Successfully exported {successfulExportCount} channel(s)" - ) + _snackbarManager.Notify( + $"Successfully exported {successfulExportCount} channel(s)" ); } } catch (Exception ex) { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( + var dialog = _viewModelManager.CreateMessageBoxViewModel( "Error exporting channel(s)", ex.ToString() ); @@ -310,8 +317,20 @@ public class DashboardViewModel : PropertyChangedBase } } - public void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app"); + [RelayCommand] + private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app"); - public void OpenDiscordDeveloperPortal() => + [RelayCommand] + private void OpenDiscordDeveloperPortal() => ProcessEx.StartShellExecute("https://discord.com/developers/applications"); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); + } } diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs index 0522e8b..5ea15b3 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs @@ -1,89 +1,111 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.ViewModels.Framework; namespace DiscordChatExporter.Gui.ViewModels.Dialogs; -public class ExportSetupViewModel : DialogScreen +public partial class ExportSetupViewModel( + DialogManager dialogManager, + SettingsService settingsService +) : DialogViewModelBase { - private readonly DialogManager _dialogManager; - private readonly SettingsService _settingsService; + [ObservableProperty] + private Guild? _guild; - public Guild? Guild { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSingleChannel))] + private IReadOnlyList? _channels; - public IReadOnlyList? Channels { get; set; } + [ObservableProperty] + private string? _outputPath; - public bool IsSingleChannel => Channels?.Count == 1; + [ObservableProperty] + private ExportFormat _selectedFormat; - public string? OutputPath { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAfterDateSet))] + [NotifyPropertyChangedFor(nameof(After))] + private DateTimeOffset? _afterDate; - public IReadOnlyList AvailableFormats { get; } = Enum.GetValues(); + [ObservableProperty] + private TimeSpan? _afterTime; - public ExportFormat SelectedFormat { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBeforeDateSet))] + [NotifyPropertyChangedFor(nameof(Before))] + private DateTimeOffset? _beforeDate; - // This date/time abomination is required because we use separate controls to set these + [ObservableProperty] + private TimeSpan? _beforeTime; - public DateTimeOffset? AfterDate { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PartitionLimit))] + private string? _partitionLimitValue; - public bool IsAfterDateSet => AfterDate is not null; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MessageFilter))] + private string? _messageFilterValue; - public TimeSpan? AfterTime { get; set; } + [ObservableProperty] + private bool _shouldFormatMarkdown; - public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero); + [ObservableProperty] + private bool _shouldDownloadAssets; - public DateTimeOffset? BeforeDate { get; set; } + [ObservableProperty] + private bool _shouldReuseAssets; - public bool IsBeforeDateSet => BeforeDate is not null; + [ObservableProperty] + private string? _assetsDirPath; - public TimeSpan? BeforeTime { get; set; } + [ObservableProperty] + private bool _isAdvancedSectionDisplayed; - public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero); + public bool IsSingleChannel => Channels?.Count == 1; - public string? PartitionLimitValue { get; set; } + public IReadOnlyList AvailableFormats { get; } = Enum.GetValues(); + + public bool IsAfterDateSet => AfterDate is not null; + + public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero); + + public bool IsBeforeDateSet => BeforeDate is not null; + + public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero); public PartitionLimit PartitionLimit => !string.IsNullOrWhiteSpace(PartitionLimitValue) ? PartitionLimit.Parse(PartitionLimitValue) : PartitionLimit.Null; - public string? MessageFilterValue { get; set; } - public MessageFilter MessageFilter => !string.IsNullOrWhiteSpace(MessageFilterValue) ? MessageFilter.Parse(MessageFilterValue) : MessageFilter.Null; - public bool ShouldFormatMarkdown { get; set; } - - public bool ShouldDownloadAssets { get; set; } - - public bool ShouldReuseAssets { get; set; } - - public string? AssetsDirPath { get; set; } - - public bool IsAdvancedSectionDisplayed { get; set; } - - public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService) + [RelayCommand] + private void Initialize() { - _dialogManager = dialogManager; - _settingsService = settingsService; - // Persist preferences - SelectedFormat = _settingsService.LastExportFormat; - PartitionLimitValue = _settingsService.LastPartitionLimitValue; - MessageFilterValue = _settingsService.LastMessageFilterValue; - ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown; - ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets; - ShouldReuseAssets = _settingsService.LastShouldReuseAssets; - AssetsDirPath = _settingsService.LastAssetsDirPath; + SelectedFormat = settingsService.LastExportFormat; + PartitionLimitValue = settingsService.LastPartitionLimitValue; + MessageFilterValue = settingsService.LastMessageFilterValue; + ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown; + ShouldDownloadAssets = settingsService.LastShouldDownloadAssets; + ShouldReuseAssets = settingsService.LastShouldReuseAssets; + AssetsDirPath = settingsService.LastAssetsDirPath; // Show the "advanced options" section by default if any // of the advanced options are set to non-default values. @@ -97,9 +119,8 @@ public class ExportSetupViewModel : DialogScreen || !string.IsNullOrWhiteSpace(AssetsDirPath); } - public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed; - - public void ShowOutputPathPrompt() + [RelayCommand] + private async Task ShowOutputPathPromptAsync() { if (IsSingleChannel) { @@ -112,33 +133,43 @@ public class ExportSetupViewModel : DialogScreen ); var extension = SelectedFormat.GetFileExtension(); - var filter = $"{extension.ToUpperInvariant()} files|*.{extension}"; - var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName); + var path = await dialogManager.PromptSaveFilePathAsync( + [ + new FilePickerFileType($"{extension.ToUpperInvariant()} file") + { + Patterns = [$"*.{extension}"] + } + ], + defaultFileName + ); + if (!string.IsNullOrWhiteSpace(path)) OutputPath = path; } else { - var path = _dialogManager.PromptDirectoryPath(); + var path = await dialogManager.PromptDirectoryPathAsync(); if (!string.IsNullOrWhiteSpace(path)) OutputPath = path; } } - public void ShowAssetsDirPathPrompt() + [RelayCommand] + private async Task ShowAssetsDirPathPromptAsync() { - var path = _dialogManager.PromptDirectoryPath(); + var path = await dialogManager.PromptDirectoryPathAsync(); if (!string.IsNullOrWhiteSpace(path)) AssetsDirPath = path; } - public void Confirm() + [RelayCommand] + private async Task ConfirmAsync() { - // Prompt the output path if it's not set yet + // Prompt the output path if it hasn't been set yet if (string.IsNullOrWhiteSpace(OutputPath)) { - ShowOutputPathPrompt(); + await ShowOutputPathPromptAsync(); // If the output path is still not set, cancel the export if (string.IsNullOrWhiteSpace(OutputPath)) @@ -146,31 +177,14 @@ public class ExportSetupViewModel : DialogScreen } // Persist preferences - _settingsService.LastExportFormat = SelectedFormat; - _settingsService.LastPartitionLimitValue = PartitionLimitValue; - _settingsService.LastMessageFilterValue = MessageFilterValue; - _settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; - _settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; - _settingsService.LastShouldReuseAssets = ShouldReuseAssets; - _settingsService.LastAssetsDirPath = AssetsDirPath; + settingsService.LastExportFormat = SelectedFormat; + settingsService.LastPartitionLimitValue = PartitionLimitValue; + settingsService.LastMessageFilterValue = MessageFilterValue; + settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; + settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; + settingsService.LastShouldReuseAssets = ShouldReuseAssets; + settingsService.LastAssetsDirPath = AssetsDirPath; Close(true); } } - -public static class ExportSetupViewModelExtensions -{ - public static ExportSetupViewModel CreateExportSetupViewModel( - this IViewModelFactory factory, - Guild guild, - IReadOnlyList channels - ) - { - var viewModel = factory.CreateExportSetupViewModel(); - - viewModel.Guild = guild; - viewModel.Channels = channels; - - return viewModel; - } -} diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs index 270ed21..d871c3f 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs @@ -1,49 +1,29 @@ -using DiscordChatExporter.Gui.ViewModels.Framework; +using CommunityToolkit.Mvvm.ComponentModel; +using DiscordChatExporter.Gui.Framework; namespace DiscordChatExporter.Gui.ViewModels.Dialogs; -public class MessageBoxViewModel : DialogScreen +public partial class MessageBoxViewModel : DialogViewModelBase { - public string? Title { get; set; } + [ObservableProperty] + private string? _title = "Title"; - public string? Message { get; set; } + [ObservableProperty] + private string? _message = "Message"; - public bool IsOkButtonVisible { get; set; } = true; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))] + [NotifyPropertyChangedFor(nameof(ButtonsCount))] + private string? _defaultButtonText = "OK"; - public string? OkButtonText { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))] + [NotifyPropertyChangedFor(nameof(ButtonsCount))] + private string? _cancelButtonText = "Cancel"; - public bool IsCancelButtonVisible { get; set; } + public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText); - public string? CancelButtonText { get; set; } + public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText); - public int ButtonsCount => (IsOkButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); -} - -public static class MessageBoxViewModelExtensions -{ - public static MessageBoxViewModel CreateMessageBoxViewModel( - this IViewModelFactory factory, - string title, - string message, - string? okButtonText, - string? cancelButtonText - ) - { - var viewModel = factory.CreateMessageBoxViewModel(); - - viewModel.Title = title; - viewModel.Message = message; - viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText); - viewModel.OkButtonText = okButtonText; - viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText); - viewModel.CancelButtonText = cancelButtonText; - - return viewModel; - } - - public static MessageBoxViewModel CreateMessageBoxViewModel( - this IViewModelFactory factory, - string title, - string message - ) => factory.CreateMessageBoxViewModel(title, message, "CLOSE", null); + public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); } diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs index d15e217..9f98d56 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs @@ -2,30 +2,43 @@ using System.Collections.Generic; using System.Linq; using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.ViewModels.Framework; +using DiscordChatExporter.Gui.Utils; +using DiscordChatExporter.Gui.Utils.Extensions; namespace DiscordChatExporter.Gui.ViewModels.Dialogs; -public class SettingsViewModel(SettingsService settingsService) : DialogScreen +public class SettingsViewModel : DialogViewModelBase { + private readonly SettingsService _settingsService; + + private readonly DisposableCollector _eventRoot = new(); + + public SettingsViewModel(SettingsService settingsService) + { + _settingsService = settingsService; + + _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged)); + } + public bool IsAutoUpdateEnabled { - get => settingsService.IsAutoUpdateEnabled; - set => settingsService.IsAutoUpdateEnabled = value; + get => _settingsService.IsAutoUpdateEnabled; + set => _settingsService.IsAutoUpdateEnabled = value; } public bool IsDarkModeEnabled { - get => settingsService.IsDarkModeEnabled; - set => settingsService.IsDarkModeEnabled = value; + get => _settingsService.IsDarkModeEnabled; + set => _settingsService.IsDarkModeEnabled = value; } public bool IsTokenPersisted { - get => settingsService.IsTokenPersisted; - set => settingsService.IsTokenPersisted = value; + get => _settingsService.IsTokenPersisted; + set => _settingsService.IsTokenPersisted = value; } public IReadOnlyList AvailableThreadInclusions { get; } = @@ -33,13 +46,13 @@ public class SettingsViewModel(SettingsService settingsService) : DialogScreen public ThreadInclusionMode ThreadInclusionMode { - get => settingsService.ThreadInclusionMode; - set => settingsService.ThreadInclusionMode = value; + get => _settingsService.ThreadInclusionMode; + set => _settingsService.ThreadInclusionMode = value; } - // These items have to be non-nullable because WPF ComboBox doesn't allow a null value to be selected - public IReadOnlyList AvailableLocales { get; } = new[] - { + // These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected + public IReadOnlyList AvailableLocales { get; } = + [ // Current locale (maps to null downstream) "", // Locales supported by the Discord app @@ -72,25 +85,35 @@ public class SettingsViewModel(SettingsService settingsService) : DialogScreen "ja-JP", "zh-TW", "ko-KR" - }.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + ]; - // This has to be non-nullable because WPF ComboBox doesn't allow a null value to be selected + // This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected public string Locale { - get => settingsService.Locale ?? ""; + get => _settingsService.Locale ?? ""; // Important to reduce empty strings to nulls, because empty strings don't correspond to valid cultures - set => settingsService.Locale = value.NullIfWhiteSpace(); + set => _settingsService.Locale = value.NullIfWhiteSpace(); } public bool IsUtcNormalizationEnabled { - get => settingsService.IsUtcNormalizationEnabled; - set => settingsService.IsUtcNormalizationEnabled = value; + get => _settingsService.IsUtcNormalizationEnabled; + set => _settingsService.IsUtcNormalizationEnabled = value; } public int ParallelLimit { - get => settingsService.ParallelLimit; - set => settingsService.ParallelLimit = Math.Clamp(value, 1, 10); + get => _settingsService.ParallelLimit; + set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); } } diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs b/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs deleted file mode 100644 index 1c9fb07..0000000 --- a/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using AsyncKeyedLock; -using MaterialDesignThemes.Wpf; -using Microsoft.Win32; -using Stylet; - -namespace DiscordChatExporter.Gui.ViewModels.Framework; - -public class DialogManager(IViewManager viewManager) : IDisposable -{ - private readonly AsyncNonKeyedLocker _dialogLock = new(); - - public async ValueTask ShowDialogAsync(DialogScreen dialogScreen) - { - var view = viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); - - void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs) - { - void OnScreenClosed(object? closeSender, EventArgs closeArgs) - { - try - { - openArgs.Session.Close(); - } - catch (InvalidOperationException) - { - // Race condition: dialog is already being closed - } - - dialogScreen.Closed -= OnScreenClosed; - } - dialogScreen.Closed += OnScreenClosed; - } - - using (await _dialogLock.LockAsync()) - { - await DialogHost.Show(view, OnDialogOpened); - return dialogScreen.DialogResult; - } - } - - public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "") - { - var dialog = new SaveFileDialog - { - Filter = filter, - AddExtension = true, - FileName = defaultFilePath, - DefaultExt = Path.GetExtension(defaultFilePath) - }; - - return dialog.ShowDialog() == true ? dialog.FileName : null; - } - - public string? PromptDirectoryPath(string defaultDirPath = "") - { - var dialog = new OpenFolderDialog { InitialDirectory = defaultDirPath }; - return dialog.ShowDialog() == true ? dialog.FolderName : null; - } - - public void Dispose() - { - _dialogLock.Dispose(); - } -} diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs b/DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs deleted file mode 100644 index e6a7436..0000000 --- a/DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Stylet; - -namespace DiscordChatExporter.Gui.ViewModels.Framework; - -public abstract class DialogScreen : PropertyChangedBase -{ - public T? DialogResult { get; private set; } - - public event EventHandler? Closed; - - public void Close(T dialogResult) - { - DialogResult = dialogResult; - Closed?.Invoke(this, EventArgs.Empty); - } -} - -public abstract class DialogScreen : DialogScreen; diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs b/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs deleted file mode 100644 index f1e5e58..0000000 --- a/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs +++ /dev/null @@ -1,16 +0,0 @@ -using DiscordChatExporter.Gui.ViewModels.Components; -using DiscordChatExporter.Gui.ViewModels.Dialogs; - -namespace DiscordChatExporter.Gui.ViewModels.Framework; - -// Used to instantiate new view models while making use of dependency injection -public interface IViewModelFactory -{ - DashboardViewModel CreateDashboardViewModel(); - - ExportSetupViewModel CreateExportSetupViewModel(); - - MessageBoxViewModel CreateMessageBoxViewModel(); - - SettingsViewModel CreateSettingsViewModel(); -} diff --git a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..a517272 --- /dev/null +++ b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.Input; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.Services; +using DiscordChatExporter.Gui.Utils; +using DiscordChatExporter.Gui.Utils.Extensions; +using DiscordChatExporter.Gui.ViewModels.Components; + +namespace DiscordChatExporter.Gui.ViewModels; + +public partial class MainViewModel( + ViewModelManager viewModelManager, + DialogManager dialogManager, + SnackbarManager snackbarManager, + SettingsService settingsService, + UpdateService updateService +) : ViewModelBase +{ + public string Title { get; } = $"{Program.Name} v{Program.VersionString}"; + + public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel(); + + private async Task ShowUkraineSupportMessageAsync() + { + if (!settingsService.IsUkraineSupportMessageEnabled) + return; + + var dialog = viewModelManager.CreateMessageBoxViewModel( + "Thank you for supporting Ukraine!", + """ + As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. + + Click LEARN MORE to find ways that you can help. + """, + "LEARN MORE", + "CLOSE" + ); + + // Disable this message in the future + settingsService.IsUkraineSupportMessageEnabled = false; + settingsService.Save(); + + if (await dialogManager.ShowDialogAsync(dialog) == true) + ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter"); + } + + private async Task CheckForUpdatesAsync() + { + try + { + var updateVersion = await updateService.CheckForUpdatesAsync(); + if (updateVersion is null) + return; + + snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}..."); + await updateService.PrepareUpdateAsync(updateVersion); + + snackbarManager.Notify( + "Update has been downloaded and will be installed when you exit", + "INSTALL NOW", + () => + { + updateService.FinalizeUpdate(true); + + if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true) + Environment.Exit(2); + } + ); + } + catch + { + // Failure to update shouldn't crash the application + snackbarManager.Notify("Failed to perform application update"); + } + } + + [RelayCommand] + private async Task InitializeAsync() + { + // Reset settings (needed to resolve the default dark mode setting) + settingsService.Reset(); + + // Load settings + settingsService.Load(); + + // Set the correct theme + if (settingsService.IsDarkModeEnabled) + App.SetDarkTheme(); + else + App.SetLightTheme(); + + await ShowUkraineSupportMessageAsync(); + await CheckForUpdatesAsync(); + + // App has just been updated, display the changelog + if ( + settingsService.LastAppVersion is not null + && settingsService.LastAppVersion != Program.Version + ) + { + snackbarManager.Notify( + $"Successfully updated to {Program.Name} v{Program.VersionString}", + "WHAT'S NEW", + () => ProcessEx.StartShellExecute(Program.LatestReleaseUrl) + ); + + settingsService.LastAppVersion = Program.Version; + settingsService.Save(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Save settings + settingsService.Save(); + + // Finalize pending updates + updateService.FinalizeUpdate(false); + } + + base.Dispose(disposing); + } +} diff --git a/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs b/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs deleted file mode 100644 index 54c2ae8..0000000 --- a/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace DiscordChatExporter.Gui.ViewModels.Messages; - -public record NotificationMessage(string Text); diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs deleted file mode 100644 index 4e100cd..0000000 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Threading.Tasks; -using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.Utils; -using DiscordChatExporter.Gui.ViewModels.Components; -using DiscordChatExporter.Gui.ViewModels.Dialogs; -using DiscordChatExporter.Gui.ViewModels.Framework; -using DiscordChatExporter.Gui.ViewModels.Messages; -using MaterialDesignThemes.Wpf; -using Stylet; - -namespace DiscordChatExporter.Gui.ViewModels; - -public class RootViewModel : Screen, IHandle, IDisposable -{ - private readonly IViewModelFactory _viewModelFactory; - private readonly DialogManager _dialogManager; - private readonly SettingsService _settingsService; - private readonly UpdateService _updateService; - - public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5)); - - public DashboardViewModel Dashboard { get; } - - public RootViewModel( - IViewModelFactory viewModelFactory, - IEventAggregator eventAggregator, - DialogManager dialogManager, - SettingsService settingsService, - UpdateService updateService - ) - { - _viewModelFactory = viewModelFactory; - _dialogManager = dialogManager; - _settingsService = settingsService; - _updateService = updateService; - - eventAggregator.Subscribe(this); - - Dashboard = _viewModelFactory.CreateDashboardViewModel(); - - DisplayName = $"{App.Name} v{App.VersionString}"; - } - - private async Task ShowUkraineSupportMessageAsync() - { - if (!_settingsService.IsUkraineSupportMessageEnabled) - return; - - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Thank you for supporting Ukraine!", - """ - As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. - - Click LEARN MORE to find ways that you can help. - """, - "LEARN MORE", - "CLOSE" - ); - - // Disable this message in the future - _settingsService.IsUkraineSupportMessageEnabled = false; - _settingsService.Save(); - - if (await _dialogManager.ShowDialogAsync(dialog) == true) - ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter"); - } - - private async ValueTask CheckForUpdatesAsync() - { - try - { - var updateVersion = await _updateService.CheckForUpdatesAsync(); - if (updateVersion is null) - return; - - Notifications.Enqueue($"Downloading update to {App.Name} v{updateVersion}..."); - await _updateService.PrepareUpdateAsync(updateVersion); - - Notifications.Enqueue( - "Update has been downloaded and will be installed when you exit", - "INSTALL NOW", - () => - { - _updateService.FinalizeUpdate(true); - RequestClose(); - } - ); - } - catch - { - // Failure to update shouldn't crash the application - Notifications.Enqueue("Failed to perform application update"); - } - } - - public async void OnViewFullyLoaded() - { - await ShowUkraineSupportMessageAsync(); - await CheckForUpdatesAsync(); - } - - protected override void OnViewLoaded() - { - base.OnViewLoaded(); - - _settingsService.Load(); - - // Sync the theme with settings - if (_settingsService.IsDarkModeEnabled) - { - App.SetDarkTheme(); - } - else - { - App.SetLightTheme(); - } - - // App has just been updated, display the changelog - if ( - _settingsService.LastAppVersion is not null - && _settingsService.LastAppVersion != App.Version - ) - { - Notifications.Enqueue( - $"Successfully updated to {App.Name} v{App.VersionString}", - "WHAT'S NEW", - () => ProcessEx.StartShellExecute(App.LatestReleaseUrl) - ); - - _settingsService.LastAppVersion = App.Version; - _settingsService.Save(); - } - } - - protected override void OnClose() - { - base.OnClose(); - - _settingsService.Save(); - _updateService.FinalizeUpdate(false); - } - - public void Handle(NotificationMessage message) => Notifications.Enqueue(message.Text); - - public void Dispose() => Notifications.Dispose(); -} diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml new file mode 100644 index 0000000..69c3b9e --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs new file mode 100644 index 0000000..c31982e --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs @@ -0,0 +1,37 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Interactivity; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.ViewModels.Components; + +namespace DiscordChatExporter.Gui.Views.Components; + +public partial class DashboardView : UserControl +{ + public DashboardView() => InitializeComponent(); + + private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) + { + DataContext.InitializeCommand.Execute(null); + TokenValueTextBox.Focus(); + } + + private void AvailableGuildsListBox_OnSelectionChanged( + object? sender, + SelectionChangedEventArgs args + ) => DataContext.PullChannelsCommand.Execute(null); + + private void AvailableChannelsTreeView_OnSelectionChanged( + object? sender, + SelectionChangedEventArgs args + ) + { + // Hack: unselect categories because they cannot be exported + foreach (var item in args.AddedItems.OfType().Where(x => x.Channel.IsCategory)) + { + if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container) + container.IsSelected = false; + } + } +} diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml deleted file mode 100644 index 4d7d031..0000000 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml +++ /dev/null @@ -1,440 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs deleted file mode 100644 index 281bc6f..0000000 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DiscordChatExporter.Gui.Views.Components; - -public partial class DashboardView -{ - public DashboardView() - { - InitializeComponent(); - } -} diff --git a/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml new file mode 100644 index 0000000..084e815 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs new file mode 100644 index 0000000..08452c1 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs @@ -0,0 +1,49 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace DiscordChatExporter.Gui.Views.Controls; + +public partial class HyperLink : UserControl +{ + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(); + + public static readonly StyledProperty CommandProperty = + Button.CommandProperty.AddOwner(); + + public static readonly StyledProperty CommandParameterProperty = + Button.CommandParameterProperty.AddOwner(); + + public HyperLink() => InitializeComponent(); + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args) + { + if (Command is null) + return; + + if (!Command.CanExecute(CommandParameter)) + return; + + Command.Execute(CommandParameter); + } +} diff --git a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml b/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml deleted file mode 100644 index f9ca6ad..0000000 --- a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs b/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs deleted file mode 100644 index 23239e1..0000000 --- a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Windows; - -namespace DiscordChatExporter.Gui.Views.Controls; - -public partial class RevealablePasswordBox -{ - public static readonly DependencyProperty PasswordProperty = DependencyProperty.Register( - nameof(Password), - typeof(string), - typeof(RevealablePasswordBox), - new FrameworkPropertyMetadata( - string.Empty, - FrameworkPropertyMetadataOptions.BindsTwoWayByDefault - ) - ); - - public static readonly DependencyProperty IsRevealedProperty = DependencyProperty.Register( - nameof(IsRevealed), - typeof(bool), - typeof(RevealablePasswordBox), - new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.None) - ); - - public string Password - { - get => (string)GetValue(PasswordProperty); - set => SetValue(PasswordProperty, value); - } - - public bool IsRevealed - { - get => (bool)GetValue(IsRevealedProperty); - set => SetValue(IsRevealedProperty, value); - } - - public RevealablePasswordBox() - { - InitializeComponent(); - } -} diff --git a/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml new file mode 100644 index 0000000..67067f7 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -