Migrate to Avalonia (#1220)

This commit is contained in:
Oleksii Holub
2024-04-27 04:17:46 +03:00
committed by GitHub
parent 74f99b4e59
commit b9c1c47474
89 changed files with 2467 additions and 2810 deletions

View File

@@ -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;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
private string? _token;
[ObservableProperty]
private IReadOnlyList<Guild>? _availableGuilds;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
private Guild? _selectedGuild;
[ObservableProperty]
private IReadOnlyList<ChannelNode>? _availableChannels;
public DashboardViewModel(
ViewModelManager viewModelManager,
DialogManager dialogManager,
SnackbarManager snackbarManager,
SettingsService settingsService
)
{
_viewModelManager = viewModelManager;
_dialogManager = dialogManager;
_snackbarManager = snackbarManager;
_settingsService = settingsService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
_eventRoot.Add(
Progress.WatchProperty(
o => o.Current,
() => OnPropertyChanged(nameof(IsProgressIndeterminate))
)
);
_eventRoot.Add(
SelectedChannels.WatchProperty(
o => o.Count,
() => ExportCommand.NotifyCanExecuteChanged()
)
);
}
public ProgressContainer<Percentage> Progress { get; } = new();
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
public string? Token { get; set; }
public ObservableCollection<ChannelNode> SelectedChannels { get; } = [];
public IReadOnlyList<Guild>? AvailableGuilds { get; private set; }
public Guild? SelectedGuild { get; set; }
public IReadOnlyList<Channel>? AvailableChannels { get; private set; }
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
public DashboardViewModel(
IViewModelFactory viewModelFactory,
IEventAggregator eventAggregator,
DialogManager dialogManager,
SettingsService settingsService
)
{
_viewModelFactory = viewModelFactory;
_eventAggregator = eventAggregator;
_dialogManager = dialogManager;
_settingsService = settingsService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => 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();
}
);
}
public void OnViewLoaded()
[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<Channel>();
// 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);
}
}

View File

@@ -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<Channel>? _channels;
public IReadOnlyList<Channel>? Channels { get; set; }
[ObservableProperty]
private string? _outputPath;
[ObservableProperty]
private ExportFormat _selectedFormat;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
[NotifyPropertyChangedFor(nameof(After))]
private DateTimeOffset? _afterDate;
[ObservableProperty]
private TimeSpan? _afterTime;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
[NotifyPropertyChangedFor(nameof(Before))]
private DateTimeOffset? _beforeDate;
[ObservableProperty]
private TimeSpan? _beforeTime;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
private string? _partitionLimitValue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MessageFilter))]
private string? _messageFilterValue;
[ObservableProperty]
private bool _shouldFormatMarkdown;
[ObservableProperty]
private bool _shouldDownloadAssets;
[ObservableProperty]
private bool _shouldReuseAssets;
[ObservableProperty]
private string? _assetsDirPath;
[ObservableProperty]
private bool _isAdvancedSectionDisplayed;
public bool IsSingleChannel => Channels?.Count == 1;
public string? OutputPath { get; set; }
public IReadOnlyList<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
public ExportFormat SelectedFormat { get; set; }
// This date/time abomination is required because we use separate controls to set these
public DateTimeOffset? AfterDate { get; set; }
public bool IsAfterDateSet => AfterDate is not null;
public TimeSpan? AfterTime { get; set; }
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
public DateTimeOffset? BeforeDate { get; set; }
public bool IsBeforeDateSet => BeforeDate is not null;
public TimeSpan? BeforeTime { get; set; }
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
public string? PartitionLimitValue { get; set; }
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<Channel> channels
)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}

View File

@@ -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);
}

View File

@@ -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<ThreadInclusionMode> 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<string> AvailableLocales { get; } = new[]
{
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
public IReadOnlyList<string> 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);
}
}

View File

@@ -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<T?> ShowDialogAsync<T>(DialogScreen<T> 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();
}
}

View File

@@ -1,19 +0,0 @@
using System;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework;
public abstract class DialogScreen<T> : 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<bool?>;

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -1,3 +0,0 @@
namespace DiscordChatExporter.Gui.ViewModels.Messages;
public record NotificationMessage(string Text);

View File

@@ -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<NotificationMessage>, 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();
}