1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-08-17 19:31:42 +02:00

65 Commits

Author SHA1 Message Date
Bertogim
5866d953f8 Merge branch 'chylex:master' into master 2025-07-28 23:45:27 +02:00
16509be56c Group channels by type and server when filtering in the app 2025-06-22 12:34:35 +02:00
791171a79b Add custom TreeView styles 2025-06-22 12:25:18 +02:00
08dec7deb3 Release v47.1 2025-06-17 08:03:49 +02:00
b148a5634f Update SQLite provider to 9.0.6 (SQLite version 3.46.1) 2025-06-17 07:54:40 +02:00
57e2f9ed80 Add SQLite library version to the Debug tab 2025-06-17 07:54:40 +02:00
895d69279f Show error dialogs instead of crashing if the database is read-only
Closes #292
2025-06-17 07:19:36 +02:00
0176fa5d53 Improve error logging 2025-06-17 07:15:35 +02:00
b5a5826cf9 Update tracking script for latest Discord update
Closes #293
2025-06-17 04:36:56 +02:00
Bertogim
6c0e0ff697 Merge branch 'chylex:master' into master 2025-06-07 19:59:48 +02:00
45593cb140 Release v47.0 2025-05-18 17:12:28 +02:00
c770a066c4 Mention BetterDiscord in the About dialog 2025-05-18 02:55:20 +02:00
43bf1fcafe Update Avalonia to 11.3.0 2025-05-18 02:34:03 +02:00
8857f6c3b1 Remove redundant code from webpack hooks 2025-05-18 02:06:36 +02:00
87de0b98b5 Update webpack hooks for latest Discord update
Closes #290
2025-05-18 02:02:18 +02:00
295370a3d4 Show progress dialog if closing database takes too long 2025-04-25 21:20:36 +02:00
312be6609d Store server icons 2025-04-25 14:17:54 +02:00
44b42657ef Refactor downloads 2025-04-25 13:07:35 +02:00
Bertogim
7e29c7f837 Add server scroll 2025-04-15 20:48:03 +02:00
0d3600492e Add option to install a userscript instead of manually copying the tracking script 2025-03-29 08:32:17 +01:00
3b569ad5d6 Refactor endpoints and authorization 2025-03-29 08:23:47 +01:00
Bertogim
7ba012ef5c Code formatting and small fixes 2025-03-21 19:07:50 +01:00
Bertogim
c52572b387 removed svg, fixed font 2025-03-18 23:18:41 +01:00
Bertogim
5ba80dc12f Delete font.css 2025-03-18 23:16:03 +01:00
Bertogim
37a0feddcc Delete fonts directory 2025-03-18 23:15:45 +01:00
Bertogim
47f448dcde localstorage is not needed 2025-03-18 23:10:12 +01:00
Bertogim
0281b49815 fix Regular font 2025-03-18 22:56:46 +01:00
Bertogim
648b221bb8 Delete /fonts directory 2025-03-18 22:54:30 +01:00
Bertogim
7d8558ae04 Add files via upload 2025-03-18 22:54:09 +01:00
Bertogim
41053549ab Add files via upload 2025-03-18 22:45:43 +01:00
Bertogim
f279bb4d16 Add files via upload 2025-03-18 22:00:36 +01:00
b2276600c7 Release v46.0 2025-03-18 21:03:45 +01:00
40269f591b Fix the Export All button text 2025-03-18 21:03:44 +01:00
dea3b272c0 Enable full trimming in .NET 2025-03-18 20:23:20 +01:00
e66206b4a8 Update About page 2025-03-18 17:35:39 +01:00
780d5ae421 Optimize viewer message export 2025-03-18 17:35:38 +01:00
38f79dee7d Replace CommunityToolkit.Mvvm with PropertyChanged.SourceGenerator 2025-03-18 13:55:32 +01:00
e30b305eb5 Add option to export downloaded files
Closes #277
2025-03-17 17:27:12 +01:00
fa17d0e224 Fix exception handling in progress dialogs 2025-03-17 17:27:12 +01:00
5741fad528 Fix random exceptions when disposing DelayedThrottledTask 2025-03-16 22:25:55 +01:00
e7c52fd1d5 Fix storing wrong download size in metadata when actual size is known and differs 2025-03-16 21:17:50 +01:00
fb9b31175c Add option to use a separate database file for downloads when creating a new database 2025-03-16 17:16:51 +01:00
f8e56ae367 Unify style definitions for bottom button panels 2025-03-16 17:16:41 +01:00
7ed31fbc8b Move the Vacuum Database button to the Database tab 2025-03-16 17:13:07 +01:00
e92aeb5a1b Refactor click event handler for Delete Orphaned Downloads 2025-03-16 17:13:06 +01:00
50d5bdba97 Release v45.0 2025-02-07 16:44:16 +01:00
295c32c7b4 Remove some runtimes from the portable build 2025-02-06 14:12:37 +01:00
5e4bb5b44d Remove osx-x64 from dedicated runtime builds 2025-02-06 14:11:05 +01:00
96c19afa66 Improve reproducibility of builds 2025-02-06 13:36:13 +01:00
a45f60b528 Update to .NET 9 2025-02-06 13:15:11 +01:00
62b97807b8 Update FUNDING.yml 2025-02-06 06:25:39 +01:00
0fff3e8eaf Add option to delete orphaned downloads
Closes #273
2025-02-03 12:29:09 +01:00
02b773db8f Handle uncaught exceptions in progress dialogs 2025-02-03 12:28:07 +01:00
261a583b50 Remove orphaned servers, channels, users, and attachments after removing messages 2025-02-03 12:28:07 +01:00
2ecb658e42 Reformat code 2025-02-03 12:28:07 +01:00
7a6dd23912 Add foreign keys to tables linked to messages 2025-02-03 12:28:07 +01:00
60761d80ab Fix showing wrong keyboard shortcut for opening dev tools on macOS 2025-01-15 03:47:57 +01:00
e11db62015 Update Avalonia to 11.2.3 2024-12-31 10:30:18 +01:00
bfac9b91d9 Release v44.0 2024-12-31 09:23:17 +01:00
4215dc76f4 Add main menu button to check for updates
Closes #165
2024-12-31 08:58:56 +01:00
86adda610b Fix wrong reaction handling in messages with polls
Closes #259
2024-12-31 04:59:33 +01:00
f1c4cd040e Tweak tracking script for easier debugging 2024-12-31 04:55:53 +01:00
737ab43ea4 Add option to hide previews while tracking and autoscrolling 2024-11-05 18:38:49 +01:00
0229c00259 Rewrite tracker to use Discord client functions instead of React properties 2024-10-23 02:01:23 +02:00
95361d2e01 Write downloads to temporary files to reduce blocking concurrent database accesses
References #168
2024-10-22 11:55:27 +02:00
198 changed files with 5183 additions and 3002 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,3 +1 @@
github: chylex
patreon: chylex
ko_fi: chylex ko_fi: chylex

View File

@@ -18,7 +18,7 @@ Folder organization:
* `app/` contains a Visual Studio solution for the desktop app * `app/` contains a Visual Studio solution for the desktop app
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website * `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/). To start editing source code for the desktop app, install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building ### Building
@@ -28,18 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
#### Release Windows (64-bit) #### Release Windows (64-bit)
1. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough) 1. Install Debian in WSL and open a terminal in the project folder.
2. Run the `app/build.wsl.sh` script.
3. Read the [Distribution](#distribution) section below.
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below. Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
#### Release Other Operating Systems #### Release Other Operating Systems
1. Install the `zip` package from your repository 1. Install the `zip` package from your repository.
2. Run the `app/build.sh` script.
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below. 3. Read the [Distribution](#distribution) section below.
#### Distribution #### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed. The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires the ASP.NET Core Runtime to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.

2
app/.editorconfig Normal file
View File

@@ -0,0 +1,2 @@
[*.cs]
propertychanged.auto_notify = false

View File

@@ -13,6 +13,7 @@
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
@@ -25,4 +26,4 @@
</map> </map>
</option> </option>
</component> </component>
</project> </project>

View File

@@ -51,9 +51,56 @@
<Style Selector="Expander"> <Style Selector="Expander">
<Setter Property="MinHeight" Value="40" /> <Setter Property="MinHeight" Value="40" />
<Setter Property="Padding" Value="12" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
</Style> </Style>
<Style Selector="TreeViewItem:not(:empty) /template/ Panel#PART_ExpandCollapseChevronContainer">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Margin" Value="0 0 5 0" />
</Style>
<Style Selector="TreeViewItem:empty /template/ Panel#PART_ExpandCollapseChevronContainer">
<Setter Property="Margin" Value="10 0" />
</Style>
<Style Selector="TreeViewItem[Level=0]:empty /template/ Panel#PART_ExpandCollapseChevronContainer">
<Setter Property="Margin" Value="0" />
<Setter Property="Width" Value="0" />
</Style>
<Style Selector="TreeViewItem /template/ ToggleButton#PART_ExpandCollapseChevron">
<Setter Property="Width" Value="18" />
<Setter Property="Height" Value="32" />
</Style>
<Style Selector="TreeViewItem /template/ ToggleButton#PART_ExpandCollapseChevron > Border">
<Setter Property="Padding" Value="2 10 3 10" />
</Style>
<Style Selector="TreeView.noSelection">
<Style Selector="^ TreeViewItem /template/ Border#PART_LayoutRoot">
<Setter Property="Background" Value="{DynamicResource TreeViewItemBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource TreeViewItemBorderBrush}" />
</Style>
<Style Selector="^ TreeViewItem /template/ Border#PART_LayoutRoot > ContentPresenter#PART_HeaderPresenter">
<Setter Property="Foreground" Value="{DynamicResource TreeViewItemForeground}" />
</Style>
<Style Selector="^ TreeViewItem:disabled /template/ Border#PART_LayoutRoot">
<Setter Property="Background" Value="{DynamicResource TreeViewItemBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource TreeViewItemBorderBrushDisabled}" />
</Style>
<Style Selector="^ TreeViewItem:disabled /template/ Border#PART_LayoutRoot > ContentPresenter#PART_HeaderPresenter">
<Setter Property="Foreground" Value="{DynamicResource TreeViewItemForegroundDisabled}" />
</Style>
</Style>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel">
<Setter Property="ItemSpacing" Value="8" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
</Style>
</Application.Styles> </Application.Styles>
<Application.Resources> <Application.Resources>
@@ -107,7 +154,7 @@
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" /> <SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" /> <SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" /> <SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness> <Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">12</Thickness> <Thickness x:Key="ExpanderContentPadding">12</Thickness>
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" /> <SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />

View File

@@ -9,12 +9,12 @@ sealed class App : Application {
public override void Initialize() { public override void Initialize() {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
public override void OnFrameworkInitializationCompleted() { public override void OnFrameworkInitializationCompleted() {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(Program.Arguments); desktop.MainWindow = new MainWindow(Program.Arguments);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
} }

View File

@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using DHT.Utils.Logging; using DHT.Utils.Logging;
@@ -6,33 +5,33 @@ namespace DHT.Desktop;
sealed class Arguments { sealed class Arguments {
private static readonly Log Log = Log.ForType<Arguments>(); private static readonly Log Log = Log.ForType<Arguments>();
private const int FirstArgument = 1; private const int FirstArgument = 1;
public static Arguments Empty => new (Array.Empty<string>()); public static Arguments Empty => new ([]);
public bool Console { get; } public bool Console { get; }
public string? DatabaseFile { get; } public string? DatabaseFile { get; }
public ushort? ServerPort { get; } public ushort? ServerPort { get; }
public string? ServerToken { get; } public string? ServerToken { get; }
public byte? ConcurrentDownloads { get; } public byte? ConcurrentDownloads { get; }
public Arguments(IReadOnlyList<string> args) { public Arguments(IReadOnlyList<string> args) {
for (int i = FirstArgument; i < args.Count; i++) { for (int i = FirstArgument; i < args.Count; i++) {
string key = args[i]; string key = args[i];
switch (key) { switch (key) {
case "-debug": case "-debug":
Log.IsDebugEnabled = true; Log.IsDebugEnabled = true;
continue; continue;
case "-console": case "-console":
Console = true; Console = true;
continue; continue;
} }
string value; string value;
if (i == FirstArgument && !key.StartsWith('-')) { if (i == FirstArgument && !key.StartsWith('-')) {
value = key; value = key;
key = "-db"; key = "-db";
@@ -44,33 +43,33 @@ sealed class Arguments {
else { else {
value = args[++i]; value = args[++i];
} }
switch (key) { switch (key) {
case "-db": case "-db":
DatabaseFile = value; DatabaseFile = value;
continue; continue;
case "-port": { case "-port": {
if (!ushort.TryParse(value, out var port)) { if (!ushort.TryParse(value, out ushort port)) {
Log.Warn("Invalid port number: " + value); Log.Warn("Invalid port number: " + value);
} }
else { else {
ServerPort = port; ServerPort = port;
} }
continue; continue;
} }
case "-token": case "-token":
ServerToken = value; ServerToken = value;
continue; continue;
case "-concurrentdownloads": case "-concurrentdownloads":
if (!ulong.TryParse(value, out var concurrentDownloads) || concurrentDownloads == 0) { if (!ulong.TryParse(value, out ulong concurrentDownloads) || concurrentDownloads == 0) {
Log.Warn("Invalid concurrent downloads count: " + value); Log.Warn("Invalid concurrent downloads count: " + value);
} }
else if (concurrentDownloads > 10) { else if (concurrentDownloads > 10) {
Log.Warn("Limiting concurrent downloads to 10"); Log.Warn("Limiting concurrent downloads to 10.");
ConcurrentDownloads = 10; ConcurrentDownloads = 10;
} }
else { else {
@@ -78,7 +77,7 @@ sealed class Arguments {
} }
continue; continue;
default: default:
Log.Warn("Unknown command line argument: " + key); Log.Warn("Unknown command line argument: " + key);
break; break;

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections;
using System.Reflection;
using Avalonia.Interactivity;
namespace DHT.Desktop.Common;
static class AvaloniaReflection {
private static FieldInfo InteractiveEventHandlersField { get; } = typeof(Interactive).GetField("_eventHandlers", BindingFlags.Instance | BindingFlags.NonPublic)!;
public static void Check() {
if (InteractiveEventHandlersField == null) {
throw new InvalidOperationException("Missing field: " + nameof(InteractiveEventHandlersField));
}
if (InteractiveEventHandlersField.FieldType.ToString() != "System.Collections.Generic.Dictionary`2[Avalonia.Interactivity.RoutedEvent,System.Collections.Generic.List`1[Avalonia.Interactivity.Interactive+EventSubscription]]") {
throw new InvalidOperationException("Invalid field type: " + nameof(InteractiveEventHandlersField) + " = " + InteractiveEventHandlersField.FieldType);
}
}
public static IList? GetEventHandler(Interactive target, RoutedEvent routedEvent) {
IDictionary? eventHandlers = (IDictionary?) InteractiveEventHandlersField.GetValue(target);
return (IList?) eventHandlers?[routedEvent];
}
}

View File

@@ -8,17 +8,17 @@ sealed class BytesValueConverter : IValueConverter {
private sealed class Unit { private sealed class Unit {
private readonly string label; private readonly string label;
private readonly string numberFormat; private readonly string numberFormat;
public Unit(string label, int decimalPlaces) { public Unit(string label, int decimalPlaces) {
this.label = label; this.label = label;
this.numberFormat = "{0:n" + decimalPlaces + "}"; this.numberFormat = "{0:n" + decimalPlaces + "}";
} }
public string Format(double size) { public string Format(double size) {
return string.Format(Program.Culture, numberFormat, size) + " " + label; return string.Format(Program.Culture, numberFormat, size) + " " + label;
} }
} }
private static readonly Unit[] Units = [ private static readonly Unit[] Units = [
new Unit("B", decimalPlaces: 0), new Unit("B", decimalPlaces: 0),
new Unit("kB", decimalPlaces: 0), new Unit("kB", decimalPlaces: 0),
@@ -26,15 +26,15 @@ sealed class BytesValueConverter : IValueConverter {
new Unit("GB", decimalPlaces: 1), new Unit("GB", decimalPlaces: 1),
new Unit("TB", decimalPlaces: 1) new Unit("TB", decimalPlaces: 1)
]; ];
private const int Scale = 1000; private const int Scale = 1000;
public static string Convert(ulong size) { public static string Convert(ulong size) {
int power = size == 0L ? 0 : (int) Math.Log(size, Scale); int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
int unit = power >= Units.Length ? Units.Length - 1 : power; int unit = power >= Units.Length ? Units.Length - 1 : power;
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit)); return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
} }
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
if (value is long size and >= 0L) { if (value is long size and >= 0L) {
return Convert((ulong) size); return Convert((ulong) size);
@@ -46,7 +46,7 @@ sealed class BytesValueConverter : IValueConverter {
return "-"; return "-";
} }
} }
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
throw new NotSupportedException(); throw new NotSupportedException();
} }

View File

@@ -16,35 +16,35 @@ namespace DHT.Desktop.Common;
static class DatabaseGui { static class DatabaseGui {
private static readonly Log Log = Log.ForType(typeof(DatabaseGui)); private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
private const string DatabaseFileInitialName = "archive.dht"; private const string DatabaseFileInitialName = "archive.dht";
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [ private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]) FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]),
]; ];
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) { public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Database File", Title = "Open Database File",
FileTypeFilter = DatabaseFileDialogFilter, FileTypeFilter = DatabaseFileDialogFilter,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory), SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
AllowMultiple = true AllowMultiple = true,
}); });
} }
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) { public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions { return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Open or Create Database File", Title = "Open or Create Database File",
FileTypeChoices = DatabaseFileDialogFilter, FileTypeChoices = DatabaseFileDialogFilter,
SuggestedFileName = DatabaseFileInitialName, SuggestedFileName = DatabaseFileInitialName,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory), SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
ShowOverwritePrompt = false ShowOverwritePrompt = false,
}); });
} }
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) { public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
IDatabaseFile? file = null; IDatabaseFile? file = null;
try { try {
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks); file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
} catch (InvalidDatabaseVersionException ex) { } catch (InvalidDatabaseVersionException ex) {
@@ -52,17 +52,17 @@ static class DatabaseGui {
} catch (DatabaseTooNewException ex) { } catch (DatabaseTooNewException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ")."); await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
} catch (Exception ex) { } catch (Exception ex) {
Log.Error(ex); Log.Error("Could not open database file: " + path, ex);
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message); await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
} }
return file; return file;
} }
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) { public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?"); return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
} }
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) { public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?"); return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
} }

View File

@@ -8,7 +8,7 @@ sealed class NumberValueConverter : IValueConverter {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value); return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
} }
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
throw new NotSupportedException(); throw new NotSupportedException();
} }

View File

@@ -0,0 +1,9 @@
using System.Diagnostics;
namespace DHT.Desktop.Common;
static class SystemUtils {
public static void OpenUrl(string url) {
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}

View File

@@ -4,15 +4,15 @@ static class TextFormat {
public static string Format(this int number) { public static string Format(this int number) {
return number.ToString("N0", Program.Culture); return number.ToString("N0", Program.Culture);
} }
public static string Format(this long number) { public static string Format(this long number) {
return number.ToString("N0", Program.Culture); return number.ToString("N0", Program.Culture);
} }
public static string Pluralize(this int number, string singular) { public static string Pluralize(this int number, string singular) {
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s"); return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
} }
public static string Pluralize(this long number, string singular) { public static string Pluralize(this long number, string singular) {
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s"); return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
} }

View File

@@ -15,15 +15,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.11" /> <PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" /> <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.11" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " /> <PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" /> <PackageReference Include="PropertyChanged.SourceGenerator" Version="1.1.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -40,5 +43,5 @@
<LogicalName>tracker-loader.js</LogicalName> <LogicalName>tracker-loader.js</LogicalName>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -8,7 +8,8 @@
x:DataType="namespace:CheckBoxDialogModel" x:DataType="namespace:CheckBoxDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" MinWidth="425" MinHeight="200"
Width="500" Height="395" CanResize="True"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<Window.DataContext> <Window.DataContext>
@@ -16,47 +17,35 @@
</Window.DataContext> </Window.DataContext>
<Window.Styles> <Window.Styles>
<Style Selector="Panel.buttons"> <Style Selector="TreeViewItem">
<Setter Property="Margin" Value="0 20 0 0" /> <Setter Property="IsExpanded" Value="True" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.left Button">
<Setter Property="Margin" Value="0 0 8 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right Button">
<Setter Property="Margin" Value="8 0 0 0" />
</Style> </Style>
</Window.Styles> </Window.Styles>
<StackPanel Margin="20"> <DockPanel Margin="20 17 20 20">
<ScrollViewer MaxHeight="400"> <Panel Classes="buttons" DockPanel.Dock="Bottom">
<ItemsRepeater ItemsSource="{Binding Items}"> <WrapPanel>
<ItemsRepeater.ItemTemplate> <Button Command="{Binding SelectAll}" IsEnabled="{Binding !AreAllSelected}">Select All</Button>
<DataTemplate> <Button Command="{Binding SelectNone}" IsEnabled="{Binding !AreNoneSelected}">Select None</Button>
</WrapPanel>
<WrapPanel HorizontalAlignment="Right">
<Button Click="ClickOk">OK</Button>
<Button Click="ClickCancel">Cancel</Button>
</WrapPanel>
</Panel>
<ScrollViewer DockPanel.Dock="Top">
<TreeView Name="TreeView" Classes="noSelection" ItemsSource="{Binding RootItems}" ContainerPrepared="TreeViewOnContainerPrepared">
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}">
<CheckBox IsChecked="{Binding IsChecked}"> <CheckBox IsChecked="{Binding IsChecked}">
<Label> <Label>
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" /> <TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</Label> </Label>
</CheckBox> </CheckBox>
</DataTemplate> </TreeDataTemplate>
</ItemsRepeater.ItemTemplate> </TreeView.ItemTemplate>
</ItemsRepeater> </TreeView>
</ScrollViewer> </ScrollViewer>
<Panel Classes="buttons"> </DockPanel>
<WrapPanel Classes="left">
<Button Command="{Binding SelectAll}" IsEnabled="{Binding !AreAllSelected}">Select All</Button>
<Button Command="{Binding SelectNone}" IsEnabled="{Binding !AreNoneSelected}">Select None</Button>
</WrapPanel>
<WrapPanel Classes="right">
<Button Click="ClickOk">OK</Button>
<Button Click="ClickCancel">Cancel</Button>
</WrapPanel>
</Panel>
</StackPanel>
</Window> </Window>

View File

@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox;
@@ -10,11 +13,41 @@ public sealed partial class CheckBoxDialog : Window {
public CheckBoxDialog() { public CheckBoxDialog() {
InitializeComponent(); InitializeComponent();
} }
private void TreeViewOnContainerPrepared(object? sender, ContainerPreparedEventArgs e) {
foreach (object? item in TreeView.Items) {
if (item != null && TreeView.ContainerFromItem(item) is TreeViewItem treeViewItem) {
treeViewItem.TemplateApplied += TreeViewItemOnTemplateApplied;
treeViewItem.GotFocus += TreeViewItemOnGotFocus;
treeViewItem.KeyDown += TreeViewItemOnKeyDown;
}
}
}
private void TreeViewItemOnTemplateApplied(object? sender, TemplateAppliedEventArgs e) {
if (sender is TreeViewItem { HeaderPresenter: Interactive headerPresenter } ) {
// Removes support for double-clicking to expand.
AvaloniaReflection.GetEventHandler(headerPresenter, DoubleTappedEvent)?.Clear();
}
}
private void TreeViewItemOnGotFocus(object? sender, GotFocusEventArgs e) {
if (e.NavigationMethod == NavigationMethod.Tab && sender is TreeViewItem treeViewItem && TreeView.SelectedItem == null) {
TreeView.SelectedItem = TreeView.ItemFromContainer(treeViewItem);
}
}
private void TreeViewItemOnKeyDown(object? sender, KeyEventArgs e) {
if (e.Key == Key.Space && TreeView.SelectedItem is ICheckBoxItem item) {
item.IsChecked = item.IsChecked == false;
e.Handled = true;
}
}
public void ClickOk(object? sender, RoutedEventArgs e) { public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok); Close(DialogResult.OkCancel.Ok);
} }
public void ClickCancel(object? sender, RoutedEventArgs e) { public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel); Close(DialogResult.OkCancel.Cancel);
} }

View File

@@ -1,70 +1,73 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel; using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : ObservableObject { partial class CheckBoxDialogModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>(); private ImmutableArray<ICheckBoxItem> rootItems = [];
public IReadOnlyList<CheckBoxItem> Items { public ImmutableArray<ICheckBoxItem> RootItems {
get => items; get => rootItems;
protected set { protected set {
foreach (var item in items) { foreach (ICheckBoxItem item in ICheckBoxItem.GetAllRecursively(rootItems)) {
item.PropertyChanged -= OnItemPropertyChanged; item.PropertyChanged -= OnItemPropertyChanged;
} }
items = value; rootItems = value;
foreach (var item in items) { foreach (ICheckBoxItem item in ICheckBoxItem.GetAllRecursively(rootItems)) {
item.PropertyChanged += OnItemPropertyChanged; item.PropertyChanged += OnItemPropertyChanged;
} }
} }
} }
private bool pauseCheckEvents = false; protected IEnumerable<ICheckBoxItem> AllItems => ICheckBoxItem.GetAllRecursively(RootItems);
public bool AreAllSelected => Items.All(static item => item.IsChecked); [DependsOn(nameof(RootItems))]
public bool AreNoneSelected => Items.All(static item => !item.IsChecked); public bool AreAllSelected => RootItems.All(static item => item.IsChecked == true);
[DependsOn(nameof(RootItems))]
public bool AreNoneSelected => RootItems.All(static item => item.IsChecked == false);
private bool pauseUpdatingBulkButtons = false;
public void SelectAll() => SetAllChecked(true); public void SelectAll() => SetAllChecked(true);
public void SelectNone() => SetAllChecked(false); public void SelectNone() => SetAllChecked(false);
private void SetAllChecked(bool isChecked) { private void SetAllChecked(bool isChecked) {
pauseCheckEvents = true; pauseUpdatingBulkButtons = true;
foreach (var item in Items) { foreach (ICheckBoxItem item in RootItems) {
item.IsChecked = isChecked; item.IsChecked = isChecked;
} }
pauseCheckEvents = false; pauseUpdatingBulkButtons = false;
UpdateBulkButtons(); UpdateBulkButtons();
} }
private void UpdateBulkButtons() {
OnPropertyChanged(nameof(AreAllSelected));
OnPropertyChanged(nameof(AreNoneSelected));
}
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) { if (e.PropertyName == nameof(ICheckBoxItem.IsChecked) && !pauseUpdatingBulkButtons) {
UpdateBulkButtons(); UpdateBulkButtons();
} }
} }
private void UpdateBulkButtons() {
OnPropertyChanged(new PropertyChangedEventArgs(nameof(RootItems)));
}
} }
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel { sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
private new IReadOnlyList<CheckBoxItem<T>> Items { get; } public IEnumerable<T> SelectedValues => AllItems.OfType<ICheckBoxItem.Leaf<T>>()
.Where(static item => item.IsChecked == true)
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked); .Select(static item => item.Value);
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) { public CheckBoxDialogModel(ImmutableArray<ICheckBoxItem> items) {
this.Items = new List<CheckBoxItem<T>>(items); this.RootItems = items;
base.Items = this.Items;
} }
} }

View File

@@ -1,20 +1,110 @@
using CommunityToolkit.Mvvm.ComponentModel; using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel;
using System.Linq;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox;
partial class CheckBoxItem : ObservableObject { partial interface ICheckBoxItem : INotifyPropertyChanged {
public string Title { get; init; } = ""; public string Title { get; }
public object? Item { get; init; } = null; public bool? IsChecked { get; set; }
[ObservableProperty] public ImmutableArray<ICheckBoxItem> Children { get; }
private bool isChecked = false;
} void NotifyIsCheckedChanged();
sealed class CheckBoxItem<T> : CheckBoxItem { public static IEnumerable<ICheckBoxItem> GetAllRecursively(IEnumerable<ICheckBoxItem> items) {
public new T Item { get; } Stack<ICheckBoxItem> stack = new Stack<ICheckBoxItem>(items);
public CheckBoxItem(T item) { while (stack.TryPop(out var item)) {
this.Item = item; yield return item;
base.Item = item;
foreach (ICheckBoxItem child in item.Children) {
stack.Push(child);
}
}
}
sealed class NonLeaf : ICheckBoxItem {
public string Title { get; }
public bool? IsChecked {
get {
if (Children.Count(static child => child.IsChecked == true) == Children.Length) {
return true;
}
else if (Children.Count(static child => child.IsChecked == false) == Children.Length) {
return false;
}
else {
return null;
}
}
set {
foreach (ICheckBoxItem child in Children) {
if (child is Leaf leaf) {
leaf.SetCheckedFromParent(value);
}
else {
child.IsChecked = value;
}
}
NotifyIsCheckedChanged();
parent?.NotifyIsCheckedChanged();
}
}
public ImmutableArray<ICheckBoxItem> Children { get; }
public event PropertyChangedEventHandler? PropertyChanged;
private readonly ICheckBoxItem? parent;
public NonLeaf(string title, ICheckBoxItem? parent, Func<ICheckBoxItem, ImmutableArray<ICheckBoxItem>> children) {
this.parent = parent;
this.Title = title;
this.Children = children(this);
}
public void NotifyIsCheckedChanged() {
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked)));
}
}
partial class Leaf(string title, ICheckBoxItem? parent, bool isChecked) : ICheckBoxItem {
public string Title { get; } = title;
public ImmutableArray<ICheckBoxItem> Children => ImmutableArray<ICheckBoxItem>.Empty;
public readonly ICheckBoxItem? parent = parent;
[Notify]
private bool? isChecked = isChecked;
private bool notifyParent = true;
public void SetCheckedFromParent(bool? isChecked) {
notifyParent = false;
IsChecked = isChecked;
notifyParent = true;
}
private void OnIsCheckedChanged() {
if (notifyParent) {
parent?.NotifyIsCheckedChanged();
}
}
void ICheckBoxItem.NotifyIsCheckedChanged() {
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsChecked)));
}
}
sealed class Leaf<T>(string title, ICheckBoxItem? parent, T value, bool isChecked) : Leaf(title, parent, isChecked) {
public T Value => value;
} }
} }

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace DHT.Desktop.Dialogs.CheckBox;
sealed class CheckBoxItemList<TKey, TValue> where TKey : notnull {
private readonly List<INode> rootNodes = [];
private readonly Dictionary<TKey, List<INode>> parentNodes = [];
public void AddParent(TKey key, string title) {
if (!parentNodes.ContainsKey(key)) {
List<INode> children = [];
rootNodes.Add(new INode.NonLeaf(title, children));
parentNodes[key] = children;
}
}
public void Add(TValue value, string title, bool isChecked = false) {
rootNodes.Add(new INode.Leaf(title, value, isChecked));
}
public void Add(TKey key, TValue value, string title, bool isChecked = false) {
parentNodes.GetValueOrDefault(key, rootNodes).Add(new INode.Leaf(title, value, isChecked));
}
public ImmutableArray<ICheckBoxItem> ToCheckBoxItems() {
return [..rootNodes.Select(static node => node.ToCheckBoxItem(null))];
}
private interface INode {
ICheckBoxItem ToCheckBoxItem(ICheckBoxItem? parent);
sealed record NonLeaf(string Title, List<INode> Children) : INode {
public ICheckBoxItem ToCheckBoxItem(ICheckBoxItem? parent) {
return new ICheckBoxItem.NonLeaf(Title, parent, self => [..Children.Select(child => child.ToCheckBoxItem(self))]);
}
}
sealed record Leaf(string Title, TValue Value, bool IsChecked) : INode {
public ICheckBoxItem ToCheckBoxItem(ICheckBoxItem? parent) {
return new ICheckBoxItem.Leaf<TValue>(Title, parent, Value, IsChecked);
}
}
}
}

View File

@@ -8,29 +8,33 @@ using Avalonia.Platform.Storage;
namespace DHT.Desktop.Dialogs.File; namespace DHT.Desktop.Dialogs.File;
static class FileDialogs { static class FileDialogs {
public static async Task<string[]> OpenFolders(this IStorageProvider storageProvider, FolderPickerOpenOptions options) {
return (await storageProvider.OpenFolderPickerAsync(options)).ToLocalPaths();
}
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) { public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths(); return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
} }
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) { public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath(); return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
} }
public static FilePickerFileType CreateFilter(string name, string[] extensions) { public static FilePickerFileType CreateFilter(string name, string[] extensions) {
return new FilePickerFileType(name) { return new FilePickerFileType(name) {
Patterns = extensions.Select(static ext => "*." + ext).ToArray() Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
}; };
} }
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) { public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory); return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
} }
private static string ToLocalPath(this IStorageFile file) { private static string ToLocalPath(this IStorageItem itme) {
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported."); return itme.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
} }
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) { private static string[] ToLocalPaths(this IReadOnlyList<IStorageItem> items) {
return files.Select(ToLocalPath).ToArray(); return items.Select(ToLocalPath).ToArray();
} }
} }

View File

@@ -10,65 +10,65 @@ static class Dialog {
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message)); await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
return; return;
} }
await new MessageDialog { await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,
Message = message, Message = message,
IsOkVisible = true IsOkVisible = true,
} },
}.ShowDialog<DialogResult.All>(owner); }.ShowDialog<DialogResult.All>(owner);
} }
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) { public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) { if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message)); return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
} }
var result = await new MessageDialog { var result = await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,
Message = message, Message = message,
IsOkVisible = true, IsOkVisible = true,
IsCancelVisible = true IsCancelVisible = true,
} },
}.ShowDialog<DialogResult.All?>(owner); }.ShowDialog<DialogResult.All?>(owner);
return result.ToOkCancel(); return result.ToOkCancel();
} }
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) { public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) { if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message)); return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
} }
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
}
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
}
var result = await new MessageDialog { var result = await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,
Message = message, Message = message,
IsYesVisible = true, IsYesVisible = true,
IsNoVisible = true, IsNoVisible = true,
IsCancelVisible = true },
}
}.ShowDialog<DialogResult.All?>(owner); }.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
}
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
}
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true,
IsCancelVisible = true,
},
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNoCancel(); return result.ToYesNoCancel();
} }
} }

View File

@@ -7,53 +7,53 @@ static class DialogResult {
Ok, Ok,
Yes, Yes,
No, No,
Cancel Cancel,
} }
public enum OkCancel { public enum OkCancel {
Closed, Closed,
Ok, Ok,
Cancel Cancel,
} }
public enum YesNo { public enum YesNo {
Closed, Closed,
Yes, Yes,
No No,
} }
public enum YesNoCancel { public enum YesNoCancel {
Closed, Closed,
Yes, Yes,
No, No,
Cancel Cancel,
} }
public static OkCancel ToOkCancel(this All? result) { public static OkCancel ToOkCancel(this All? result) {
return result switch { return result switch {
null => OkCancel.Closed, null => OkCancel.Closed,
All.Ok => OkCancel.Ok, All.Ok => OkCancel.Ok,
All.Cancel => OkCancel.Cancel, All.Cancel => OkCancel.Cancel,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.") _ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel."),
}; };
} }
public static YesNo ToYesNo(this All? result) { public static YesNo ToYesNo(this All? result) {
return result switch { return result switch {
null => YesNo.Closed, null => YesNo.Closed,
All.Yes => YesNo.Yes, All.Yes => YesNo.Yes,
All.No => YesNo.No, All.No => YesNo.No,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.") _ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no."),
}; };
} }
public static YesNoCancel ToYesNoCancel(this All? result) { public static YesNoCancel ToYesNoCancel(this All? result) {
return result switch { return result switch {
null => YesNoCancel.Closed, null => YesNoCancel.Closed,
All.Yes => YesNoCancel.Yes, All.Yes => YesNoCancel.Yes,
All.No => YesNoCancel.No, All.No => YesNoCancel.No,
All.Cancel => YesNoCancel.Cancel, All.Cancel => YesNoCancel.Cancel,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.") _ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel."),
}; };
} }
} }

View File

@@ -20,24 +20,18 @@
<Setter Property="FontSize" Value="15" /> <Setter Property="FontSize" Value="15" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
</Style> </Style>
<Style Selector="WrapPanel">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Margin" Value="0 8 0 0" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="8 0 0 0" />
<Setter Property="MinWidth" Value="80" />
</Style>
</Window.Styles> </Window.Styles>
<StackPanel Margin="20"> <StackPanel Margin="20">
<TextBlock Text="{Binding Message}" /> <TextBlock Text="{Binding Message}" />
<WrapPanel> <Panel Classes="buttons">
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button> <WrapPanel HorizontalAlignment="Right">
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button> <Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button> <Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button> <Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
</WrapPanel> <Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
</WrapPanel>
</Panel>
</StackPanel> </StackPanel>
</Window> </Window>

View File

@@ -9,19 +9,19 @@ public sealed partial class MessageDialog : Window {
public MessageDialog() { public MessageDialog() {
InitializeComponent(); InitializeComponent();
} }
public void ClickOk(object? sender, RoutedEventArgs e) { public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Ok); Close(DialogResult.All.Ok);
} }
public void ClickYes(object? sender, RoutedEventArgs e) { public void ClickYes(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Yes); Close(DialogResult.All.Yes);
} }
public void ClickNo(object? sender, RoutedEventArgs e) { public void ClickNo(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.No); Close(DialogResult.All.No);
} }
public void ClickCancel(object? sender, RoutedEventArgs e) { public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Cancel); Close(DialogResult.All.Cancel);
} }

View File

@@ -3,7 +3,7 @@ namespace DHT.Desktop.Dialogs.Message;
sealed class MessageDialogModel { sealed class MessageDialogModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public string Message { get; init; } = ""; public string Message { get; init; } = "";
public bool IsOkVisible { get; init; } = false; public bool IsOkVisible { get; init; } = false;
public bool IsYesVisible { get; init; } = false; public bool IsYesVisible { get; init; } = false;
public bool IsNoVisible { get; init; } = false; public bool IsNoVisible { get; init; } = false;

View File

@@ -0,0 +1,24 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
namespace DHT.Desktop.Dialogs.Progress;
static class DelayedProgressDialog {
public static async ValueTask Await(Func<Task> taskProvider, TimeSpan delay, Window window, string progressDialogTitle, string progressDialogDescription) {
Dispatcher.UIThread.VerifyAccess();
Task task = Task.Run(taskProvider);
if (task.IsCompleted) {
return;
}
// Freeze the UI thread for a short while in case the task finishes quickly.
_ = Task.WhenAny(Task.Delay(delay), task).GetAwaiter().GetResult();
if (!task.IsCompleted) {
await ProgressDialog.ShowIndeterminate(window, progressDialogTitle, progressDialogDescription, _ => task);
}
}
}

View File

@@ -3,7 +3,7 @@ using System.Threading.Tasks;
namespace DHT.Desktop.Dialogs.Progress; namespace DHT.Desktop.Dialogs.Progress;
interface IProgressCallback { interface IProgressCallback {
Task Update(string message, int finishedItems, int totalItems); Task Update(string message, long finishedItems, long totalItems);
Task UpdateIndeterminate(string message); Task UpdateIndeterminate(string message);
Task Hide(); Task Hide();
} }

View File

@@ -7,81 +7,72 @@ namespace DHT.Desktop.Dialogs.Progress;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class ProgressDialog : Window { public sealed partial class ProgressDialog : Window {
private static readonly TimeSpan MinimumShowDuration = TimeSpan.FromMilliseconds(500);
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) { internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog(); var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => await action(dialog, callbacks[0]));
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
try {
await action(dialog, callbacks[0]);
taskCompletionSource.SetResult();
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner); await dialog.ShowProgressDialog(owner);
await taskCompletionSource.Task;
} }
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) { internal static async Task<T> Show<T>(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task<T>> action) {
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message);
try {
await action(dialog);
taskCompletionSource.SetResult();
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
await taskCompletionSource.Task;
}
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
var taskCompletionSource = new TaskCompletionSource<T>(); var taskCompletionSource = new TaskCompletionSource<T>();
var dialog = new ProgressDialog(); var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => { dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message); taskCompletionSource.SetResult(await action(dialog, callbacks[0]));
try {
taskCompletionSource.SetResult(await action(dialog));
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
}); });
await dialog.ShowProgressDialog(owner); await dialog.ShowProgressDialog(owner);
return await taskCompletionSource.Task; return await taskCompletionSource.Task;
} }
internal static Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
return Show(owner, title, async (dialog, callback) => {
await callback.UpdateIndeterminate(message);
await action(dialog);
});
}
internal static Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
return Show<T>(owner, title, async (dialog, callback) => {
await callback.UpdateIndeterminate(message);
return await action(dialog);
});
}
private bool isFinished = false; private bool isFinished = false;
private DateTime startTime = DateTime.Now;
private Task progressTask = Task.CompletedTask; private Task progressTask = Task.CompletedTask;
public ProgressDialog() { public ProgressDialog() {
InitializeComponent(); InitializeComponent();
} }
public void OnOpened(object? sender, EventArgs e) { public void OnOpened(object? sender, EventArgs e) {
startTime = DateTime.Now;
if (DataContext is ProgressDialogModel model) { if (DataContext is ProgressDialogModel model) {
progressTask = Task.Run(model.StartTask); progressTask = Task.Run(model.StartTask);
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext()); progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
} }
} }
public void OnClosing(object? sender, WindowClosingEventArgs e) { public void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = !isFinished; e.Cancel = !isFinished;
} }
private void OnFinished(Task task) { private async Task OnFinished(Task task) {
isFinished = true; isFinished = true;
TimeSpan elapsedTime = DateTime.Now - startTime;
if (elapsedTime < MinimumShowDuration) {
await Task.Delay(MinimumShowDuration - elapsedTime);
}
Close(); Close();
} }
public async Task ShowProgressDialog(Window owner) { public async Task ShowProgressDialog(Window owner) {
await ShowDialog(owner); await ShowDialog(owner);
await progressTask; await progressTask;

View File

@@ -9,36 +9,36 @@ namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel { sealed class ProgressDialogModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>(); public IReadOnlyList<ProgressItem> Items { get; } = [];
private readonly TaskRunner? task; private readonly TaskRunner? task;
[Obsolete("Designer")] [Obsolete("Designer")]
public ProgressDialogModel() {} public ProgressDialogModel() {}
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) { public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
this.Title = title; this.Title = title;
this.task = task; this.task = task;
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray(); this.Items = Enumerable.Range(start: 0, progressItems).Select(static _ => new ProgressItem()).ToArray();
} }
internal async Task StartTask() { internal async Task StartTask() {
if (task != null) { if (task != null) {
await task(Items.Select(static item => new Callback(item)).ToArray()); await task(Items.Select(static item => new Callback(item)).ToArray());
} }
} }
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks); public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
private sealed class Callback : IProgressCallback { private sealed class Callback : IProgressCallback {
private readonly ProgressItem item; private readonly ProgressItem item;
public Callback(ProgressItem item) { public Callback(ProgressItem item) {
this.item = item; this.item = item;
} }
public async Task Update(string message, int finishedItems, int totalItems) { public async Task Update(string message, long finishedItems, long totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => { await Dispatcher.UIThread.InvokeAsync(() => {
item.Message = message; item.Message = message;
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format(); item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
@@ -46,7 +46,7 @@ sealed class ProgressDialogModel {
item.IsIndeterminate = false; item.IsIndeterminate = false;
}); });
} }
public async Task UpdateIndeterminate(string message) { public async Task UpdateIndeterminate(string message) {
await Dispatcher.UIThread.InvokeAsync(() => { await Dispatcher.UIThread.InvokeAsync(() => {
item.Message = message; item.Message = message;
@@ -55,9 +55,9 @@ sealed class ProgressDialogModel {
item.IsIndeterminate = true; item.IsIndeterminate = true;
}); });
} }
public Task Hide() { public Task Hide() {
return Update(string.Empty, 0, 0); return Update(string.Empty, finishedItems: 0, totalItems: 0);
} }
} }
} }

View File

@@ -1,30 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel; using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Dialogs.Progress; namespace DHT.Desktop.Dialogs.Progress;
sealed partial class ProgressItem : ObservableObject { sealed partial class ProgressItem {
[ObservableProperty(Setter = Access.Private)] [Notify]
[NotifyPropertyChangedFor(nameof(Opacity))]
private bool isVisible = false;
public double Opacity => IsVisible ? 1.0 : 0.0;
private string message = ""; private string message = "";
public string Message { [Notify]
get => message;
set {
SetProperty(ref message, value);
IsVisible = !string.IsNullOrEmpty(value);
}
}
[ObservableProperty]
private string items = ""; private string items = "";
[ObservableProperty] [Notify]
private int progress = 0; private long progress = 0L;
[ObservableProperty] [Notify]
private bool isIndeterminate; private bool isIndeterminate;
[DependsOn(nameof(Message))]
public bool IsVisible => !string.IsNullOrEmpty(Message);
[DependsOn(nameof(IsVisible))]
public double Opacity => IsVisible ? 1.0 : 0.0;
} }

View File

@@ -15,19 +15,6 @@
<namespace:TextBoxDialogModel /> <namespace:TextBoxDialogModel />
</Window.DataContext> </Window.DataContext>
<Window.Styles>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20"> <StackPanel Margin="20">
<ScrollViewer MaxHeight="400"> <ScrollViewer MaxHeight="400">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
@@ -47,7 +34,7 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<Panel Classes="buttons"> <Panel Classes="buttons">
<WrapPanel Classes="right"> <WrapPanel HorizontalAlignment="Right">
<Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button> <Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button>
<Button Click="ClickCancel">Cancel</Button> <Button Click="ClickCancel">Cancel</Button>
</WrapPanel> </WrapPanel>

View File

@@ -10,11 +10,11 @@ public sealed partial class TextBoxDialog : Window {
public TextBoxDialog() { public TextBoxDialog() {
InitializeComponent(); InitializeComponent();
} }
public void ClickOk(object? sender, RoutedEventArgs e) { public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok); Close(DialogResult.OkCancel.Ok);
} }
public void ClickCancel(object? sender, RoutedEventArgs e) { public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel); Close(DialogResult.OkCancel.Cancel);
} }

View File

@@ -1,45 +1,45 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel; using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : ObservableObject { partial class TextBoxDialogModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public string Description { get; init; } = ""; public string Description { get; init; } = "";
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>(); private IReadOnlyList<TextBoxItem> items = [];
public IReadOnlyList<TextBoxItem> Items { public IReadOnlyList<TextBoxItem> Items {
get => items; get => items;
protected set { protected set {
foreach (var item in items) { foreach (TextBoxItem item in items) {
item.ErrorsChanged -= OnItemErrorsChanged; item.ErrorsChanged -= OnItemErrorsChanged;
} }
items = value; items = value;
foreach (var item in items) { foreach (TextBoxItem item in items) {
item.ErrorsChanged += OnItemErrorsChanged; item.ErrorsChanged += OnItemErrorsChanged;
} }
} }
} }
[DependsOn(nameof(Items))]
public bool HasErrors => Items.Any(static item => !item.IsValid); public bool HasErrors => Items.Any(static item => !item.IsValid);
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) { private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
OnPropertyChanged(nameof(HasErrors)); OnPropertyChanged(new PropertyChangedEventArgs(nameof(Items)));
} }
} }
sealed class TextBoxDialogModel<T> : TextBoxDialogModel { sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
private new IReadOnlyList<TextBoxItem<T>> Items { get; } private new IReadOnlyList<TextBoxItem<T>> Items { get; }
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid); public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) { public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
this.Items = new List<TextBoxItem<T>>(items); this.Items = new List<TextBoxItem<T>>(items);
base.Items = this.Items; base.Items = this.Items;

View File

@@ -1,40 +1,37 @@
using System; using System;
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel; using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxItem : ObservableObject, INotifyDataErrorInfo { partial class TextBoxItem : INotifyDataErrorInfo {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public object? Item { get; init; } = null; public object? Item { get; init; } = null;
public Func<string, bool> ValidityCheck { get; init; } = static _ => true; public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
public bool IsValid => ValidityCheck(Value); public bool IsValid => ValidityCheck(Value);
[Notify]
private string value = string.Empty; private string value = string.Empty;
public string Value { private void OnValueChanged() {
get => this.value; ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
set {
SetProperty(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
} }
public IEnumerable GetErrors(string? propertyName) { public IEnumerable GetErrors(string? propertyName) {
if (propertyName == nameof(Value) && !IsValid) { if (propertyName == nameof(Value) && !IsValid) {
yield return string.Empty; yield return string.Empty;
} }
} }
public bool HasErrors => !IsValid; public bool HasErrors => !IsValid;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged; public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
} }
sealed class TextBoxItem<T> : TextBoxItem { sealed class TextBoxItem<T> : TextBoxItem {
public new T Item { get; } public new T Item { get; }
public TextBoxItem(T item) { public TextBoxItem(T item) {
this.Item = item; this.Item = item;
base.Item = item; base.Item = item;

View File

@@ -13,16 +13,16 @@ namespace DHT.Desktop.Discord;
static class DiscordAppSettings { static class DiscordAppSettings {
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings)); private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING"; private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
public static string JsonFilePath { get; } public static string JsonFilePath { get; }
private static string JsonBackupFilePath { get; } private static string JsonBackupFilePath { get; }
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")] [SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
static DiscordAppSettings() { static DiscordAppSettings() {
string rootFolder; string rootFolder;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord"); rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
} }
@@ -32,29 +32,28 @@ static class DiscordAppSettings {
else { else {
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord"); rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
} }
JsonFilePath = Path.Combine(rootFolder, "settings.json"); JsonFilePath = Path.Combine(rootFolder, "settings.json");
JsonBackupFilePath = JsonFilePath + ".bak"; JsonBackupFilePath = JsonFilePath + ".bak";
} }
public static async Task<bool?> AreDevToolsEnabled() { public static async Task<bool?> AreDevToolsEnabled() {
try { try {
var settingsJson = await ReadSettingsJson(); JsonObject settingsJson = await ReadSettingsJson();
return AreDevToolsEnabled(settingsJson); return AreDevToolsEnabled(settingsJson);
} catch (Exception e) { } catch (Exception e) {
Log.Error("Cannot read settings file."); Log.Error("Could not read settings file.", e);
Log.Error(e);
return null; return null;
} }
} }
private static bool AreDevToolsEnabled(JsonObject json) { private static bool AreDevToolsEnabled(JsonObject json) {
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True; return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
} }
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) { public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
JsonObject json; JsonObject json;
try { try {
json = await ReadSettingsJson(); json = await ReadSettingsJson();
} catch (FileNotFoundException) { } catch (FileNotFoundException) {
@@ -62,59 +61,56 @@ static class DiscordAppSettings {
} catch (JsonException) { } catch (JsonException) {
return SettingsJsonResult.InvalidJson; return SettingsJsonResult.InvalidJson;
} catch (Exception e) { } catch (Exception e) {
Log.Error(e); Log.Error("Could not read settings file.", e);
return SettingsJsonResult.ReadError; return SettingsJsonResult.ReadError;
} }
if (enable == AreDevToolsEnabled(json)) { if (enable == AreDevToolsEnabled(json)) {
return SettingsJsonResult.AlreadySet; return SettingsJsonResult.AlreadySet;
} }
if (enable) { if (enable) {
json[JsonKeyDevTools] = true; json[JsonKeyDevTools] = true;
} }
else { else {
json.Remove(JsonKeyDevTools); json.Remove(JsonKeyDevTools);
} }
try { try {
if (!File.Exists(JsonBackupFilePath)) { if (!File.Exists(JsonBackupFilePath)) {
File.Copy(JsonFilePath, JsonBackupFilePath); File.Copy(JsonFilePath, JsonBackupFilePath);
} }
await WriteSettingsJson(json); await WriteSettingsJson(json);
} catch (Exception e) { } catch (Exception e) {
Log.Error("An error occurred when writing settings file."); Log.Error("Could not write settings file.", e);
Log.Error(e);
if (File.Exists(JsonBackupFilePath)) { if (File.Exists(JsonBackupFilePath)) {
try { try {
File.Move(JsonBackupFilePath, JsonFilePath, true); File.Move(JsonBackupFilePath, JsonFilePath, overwrite: true);
Log.Info("Restored settings file from backup."); Log.Info("Restored settings file from backup.");
} catch (Exception e2) { } catch (Exception e2) {
Log.Error("Cannot restore settings file from backup."); Log.Error("Could not restore settings file from backup.", e2);
Log.Error(e2);
} }
} }
return SettingsJsonResult.WriteError; return SettingsJsonResult.WriteError;
} }
try { try {
File.Delete(JsonBackupFilePath); File.Delete(JsonBackupFilePath);
} catch (Exception e) { } catch (Exception e) {
Log.Error("Cannot delete backup file."); Log.Error("Could not delete backup file.", e);
Log.Error(e);
} }
return SettingsJsonResult.Success; return SettingsJsonResult.Success;
} }
private static async Task<JsonObject> ReadSettingsJson() { private static async Task<JsonObject> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException(); return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
} }
private static async Task WriteSettingsJson(JsonObject json) { private static async Task WriteSettingsJson(JsonObject json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None); await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject); await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);

View File

@@ -6,5 +6,5 @@ enum SettingsJsonResult {
FileNotFound, FileNotFound,
ReadError, ReadError,
InvalidJson, InvalidJson,
WriteError WriteError,
} }

View File

@@ -3,12 +3,12 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="360" mc:Ignorable="d" d:DesignWidth="510" d:DesignHeight="425"
x:Class="DHT.Desktop.Main.AboutWindow" x:Class="DHT.Desktop.Main.AboutWindow"
x:DataType="main:AboutWindowModel" x:DataType="main:AboutWindowModel"
Title="About Discord History Tracker" Title="About Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="480" Height="360" CanResize="False" Width="510" Height="425" CanResize="False"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<Design.DataContext> <Design.DataContext>
@@ -16,16 +16,12 @@
</Design.DataContext> </Design.DataContext>
<Window.Styles> <Window.Styles>
<Style Selector="StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Spacing" Value="5" />
</Style>
<Style Selector="TextBlock"> <Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Grid > Button"> <Style Selector="Grid > Button">
<Setter Property="Margin" Value="0 4" /> <Setter Property="Margin" Value="0 3" />
<Setter Property="Padding" Value="8 4" /> <Setter Property="Padding" Value="8 4" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
</Style> </Style>
@@ -33,44 +29,55 @@
<StackPanel Orientation="Vertical" Margin="20" Spacing="12"> <StackPanel Orientation="Vertical" Margin="20" Spacing="12">
<TextBlock VerticalAlignment="Center"> <StackPanel Orientation="Vertical" Spacing="3">
Discord History Tracker was created by chylex and released under the MIT license. <TextBlock TextWrapping="Wrap">Discord History Tracker was created by chylex.</TextBlock>
</TextBlock> <TextBlock>It is available under the MIT license.</TextBlock>
<StackPanel>
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
<Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button>
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
</StackPanel> </StackPanel>
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding OpenOfficialWebsite}">Official Website</Button>
<Button Command="{Binding OpenIssueTracker}">Issue Tracker</Button>
<Button Command="{Binding OpenSourceCode}">Source Code</Button>
</StackPanel>
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,115,90" Margin="0 10 0 0">
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock> <TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock> <TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock> <TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock> <TextBlock Grid.Row="2" Grid.Column="0">.NET</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock> <TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button> <Button Grid.Row="2" Grid.Column="2" Command="{Binding OpenThirdPartyNetCore}">GitHub</Button>
<TextBlock Grid.Row="3" Grid.Column="0">Avalonia</TextBlock> <TextBlock Grid.Row="3" Grid.Column="0">Avalonia</TextBlock>
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock> <TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button> <Button Grid.Row="3" Grid.Column="2" Command="{Binding OpenThirdPartyAvalonia}">GitHub</Button>
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock> <TextBlock Grid.Row="4" Grid.Column="0">Rx.NET</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock> <TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button> <Button Grid.Row="4" Grid.Column="2" Command="{Binding OpenThirdPartyRxNet}">GitHub</Button>
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock> <TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock> <TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button> <Button Grid.Row="5" Grid.Column="2" Command="{Binding OpenThirdPartySqlite}">Website</Button>
<TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock> <TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
<TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock> <TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock>
<Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button> <Button Grid.Row="6" Grid.Column="2" Command="{Binding OpenThirdPartyMicrosoftDataSqlite}">NuGet</Button>
<TextBlock Grid.Row="7" Grid.Column="0">Rx.NET</TextBlock> <TextBlock Grid.Row="7" Grid.Column="0">PropertyChanged.SourceGenerator</TextBlock>
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock> <TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button> <Button Grid.Row="7" Grid.Column="2" Command="{Binding OpenThirdPartyPropertyChangedSourceGenerator}">GitHub</Button>
</Grid>
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,115,90" Margin="0 5 0 0">
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Additional Thanks</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">BetterDiscord, for Webpack hooks</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1">Apache-2.0</TextBlock>
<Button Grid.Row="2" Grid.Column="2" Command="{Binding OpenThirdPartyBetterDiscord}">GitHub</Button>
</Grid> </Grid>
</StackPanel> </StackPanel>

View File

@@ -1,45 +1,45 @@
using System.Diagnostics; using DHT.Desktop.Common;
namespace DHT.Desktop.Main; namespace DHT.Desktop.Main;
sealed class AboutWindowModel { sealed class AboutWindowModel {
public void ShowOfficialWebsite() { public void OpenOfficialWebsite() {
OpenUrl("https://dht.chylex.com"); SystemUtils.OpenUrl(Program.Website);
}
public void ShowIssueTracker() {
OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
} }
public void ShowSourceCode() { public void OpenIssueTracker() {
OpenUrl("https://github.com/chylex/Discord-History-Tracker"); SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
} }
public void ShowLibraryNetCore() { public void OpenSourceCode() {
OpenUrl("https://github.com/dotnet/core"); SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
} }
public void ShowLibraryAvalonia() { public void OpenThirdPartyNetCore() {
OpenUrl("https://www.nuget.org/packages/Avalonia"); SystemUtils.OpenUrl("https://github.com/dotnet/core");
} }
public void ShowLibraryCommunityToolkit() { public void OpenThirdPartyAvalonia() {
OpenUrl("https://github.com/CommunityToolkit/dotnet"); SystemUtils.OpenUrl("https://github.com/AvaloniaUI/Avalonia");
} }
public void ShowLibrarySqlite() { public void OpenThirdPartyPropertyChangedSourceGenerator() {
OpenUrl("https://www.sqlite.org"); SystemUtils.OpenUrl("https://github.com/canton7/PropertyChanged.SourceGenerator");
} }
public void ShowLibrarySqliteAdoNet() { public void OpenThirdPartySqlite() {
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite"); SystemUtils.OpenUrl("https://www.sqlite.org");
} }
public void ShowLibraryRxNet() { public void OpenThirdPartyMicrosoftDataSqlite() {
OpenUrl("https://github.com/dotnet/reactive"); SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
} }
private static void OpenUrl(string url) { public void OpenThirdPartyRxNet() {
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
}
public void OpenThirdPartyBetterDiscord() {
SystemUtils.OpenUrl("https://github.com/BetterDiscord/BetterDiscord");
} }
} }

View File

@@ -13,12 +13,8 @@
<UserControl.Styles> <UserControl.Styles>
<Style Selector="WrapPanel > StackPanel"> <Style Selector="WrapPanel > StackPanel">
<Setter Property="Margin" Value="0 20 40 0" />
<Setter Property="Spacing" Value="4" /> <Setter Property="Spacing" Value="4" />
</Style> </Style>
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Grid > Label"> <Style Selector="Grid > Label">
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
@@ -30,9 +26,9 @@
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel> <StackPanel Spacing="20">
<TextBlock Text="{Binding FilterStatisticsText}" /> <TextBlock Text="{Binding FilterStatisticsText}" />
<WrapPanel> <WrapPanel ItemSpacing="40" LineSpacing="20">
<StackPanel> <StackPanel>
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox> <CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">

View File

@@ -5,88 +5,89 @@ using System.Linq;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Server; using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Data.Settings; using DHT.Server.Data.Settings;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisposable { sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>(); private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>();
public sealed record Unit(string Name, uint Scale); public sealed record Unit(string Name, uint Scale);
private static readonly Unit[] AllUnits = [ private static readonly Unit[] AllUnits = [
new Unit("B", 1), new Unit("B", Scale: 1),
new Unit("kB", 1024), new Unit("kB", Scale: 1024),
new Unit("MB", 1024 * 1024) new Unit("MB", Scale: 1024 * 1024)
]; ];
private static readonly HashSet<string> FilterProperties = [ private static readonly HashSet<string> FilterProperties = [
nameof(LimitSize), nameof(LimitSize),
nameof(MaximumSize), nameof(MaximumSize),
nameof(MaximumSizeUnit) nameof(MaximumSizeUnit),
]; ];
public string FilterStatisticsText { get; private set; } = ""; [Notify(Setter.Private)]
private string filterStatisticsText = "";
[ObservableProperty]
[Notify]
private bool limitSize = false; private bool limitSize = false;
[ObservableProperty] [Notify]
private ulong maximumSize = 0UL; private ulong maximumSize = 0UL;
[ObservableProperty] [Notify]
private Unit maximumSizeUnit = AllUnits[0]; private Unit maximumSizeUnit = AllUnits[0];
public IEnumerable<Unit> Units => AllUnits; public IEnumerable<Unit> Units => AllUnits;
private readonly State state; private readonly State state;
private readonly string verb; private readonly string verb;
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
private bool isLoadingFilterSettings; private bool isLoadingFilterSettings;
private readonly RestartableTask<long> downloadItemCountTask; private readonly RestartableTask<long> downloadItemCountTask;
private long? matchingItemCount; private long? matchingItemCount;
private readonly IDisposable downloadItemCountSubscription; private readonly IDisposable downloadItemCountSubscription;
private long? totalItemCount; private long? totalItemCount;
[Obsolete("Designer")] [Obsolete("Designer")]
public DownloadItemFilterPanelModel() : this(State.Dummy) {} public DownloadItemFilterPanelModel() : this(State.Dummy) {}
public DownloadItemFilterPanelModel(State state, string verb = "Matches") { public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
this.state = state; this.state = state;
this.verb = verb; this.verb = verb;
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings); this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged); this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
UpdateFilterStatistics(); UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
} }
public async Task Initialize() { public async Task Initialize() {
isLoadingFilterSettings = true; isLoadingFilterSettings = true;
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize); LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize); MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) { if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, defaultValue: null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
MaximumSizeUnit = unitValue; MaximumSizeUnit = unitValue;
} }
isLoadingFilterSettings = false; isLoadingFilterSettings = false;
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
saveFilterSettingsTask.Dispose(); saveFilterSettingsTask.Dispose();
@@ -108,11 +109,10 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name); await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name);
}); });
} catch (Exception e) { } catch (Exception e) {
Log.Error("Could not save download filter settings"); Log.Error("Could not save download filter settings.", e);
Log.Error(e);
} }
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) { if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
if (!isLoadingFilterSettings) { if (!isLoadingFilterSettings) {
@@ -128,9 +128,9 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
UpdateFilterStatistics(); UpdateFilterStatistics();
} }
private void UpdateFilterStatistics() { private void UpdateFilterStatistics() {
var filter = CreateFilter(); DownloadItemFilter filter = CreateFilter();
if (filter.IsEmpty) { if (filter.IsEmpty) {
downloadItemCountTask.Cancel(); downloadItemCountTask.Cancel();
matchingItemCount = totalItemCount; matchingItemCount = totalItemCount;
@@ -142,23 +142,21 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken)); downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
} }
} }
private void SetMatchingCount(long matchingAttachmentCount) { private void SetMatchingCount(long matchingAttachmentCount) {
this.matchingItemCount = matchingAttachmentCount; this.matchingItemCount = matchingAttachmentCount;
UpdateFilterStatisticsText(); UpdateFilterStatisticsText();
} }
private void UpdateFilterStatisticsText() { private void UpdateFilterStatisticsText() {
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)"; string matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
var totalItemCountStr = totalItemCount?.Format() ?? "(...)"; string totalItemCountStr = totalItemCount?.Format() ?? "(...)";
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s."); FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
OnPropertyChanged(nameof(FilterStatisticsText));
} }
public DownloadItemFilter CreateFilter() { public DownloadItemFilter CreateFilter() {
DownloadItemFilter filter = new (); DownloadItemFilter filter = new ();
if (LimitSize) { if (LimitSize) {
try { try {
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale; filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
@@ -166,7 +164,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
// set no size limit, because the overflown size is larger than any file could possibly be // set no size limit, because the overflown size is larger than any file could possibly be
} }
} }
return filter; return filter;
} }
} }

View File

@@ -13,12 +13,8 @@
<UserControl.Styles> <UserControl.Styles>
<Style Selector="WrapPanel > StackPanel"> <Style Selector="WrapPanel > StackPanel">
<Setter Property="Margin" Value="0 20 40 0" />
<Setter Property="Spacing" Value="4" /> <Setter Property="Spacing" Value="4" />
</Style> </Style>
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Grid > Label"> <Style Selector="Grid > Label">
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
@@ -36,9 +32,9 @@
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel> <StackPanel Spacing="20">
<TextBlock Text="{Binding FilterStatisticsText}" /> <TextBlock Text="{Binding FilterStatisticsText}" />
<WrapPanel> <WrapPanel ItemSpacing="40" LineSpacing="20">
<StackPanel> <StackPanel>
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox> <CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
<Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0"> <Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0">

View File

@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Controls; using Avalonia.Controls;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
@@ -7,16 +8,16 @@ namespace DHT.Desktop.Main.Controls;
public sealed partial class MessageFilterPanel : UserControl { public sealed partial class MessageFilterPanel : UserControl {
public MessageFilterPanel() { public MessageFilterPanel() {
InitializeComponent(); InitializeComponent();
var culture = Program.Culture; CultureInfo culture = Program.Culture;
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) { foreach (CalendarDatePicker picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek; picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom; picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern; picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
picker.Watermark = culture.DateTimeFormat.ShortDatePattern; picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
} }
} }
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) { public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (DataContext is MessageFilterPanelModel model) { if (DataContext is MessageFilterPanelModel model) {
model.StartDate = StartDatePicker.SelectedDate; model.StartDate = StartDatePicker.SelectedDate;

View File

@@ -1,13 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox; using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
@@ -16,10 +16,11 @@ using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable { sealed partial class MessageFilterPanelModel : IDisposable {
private static readonly HashSet<string> FilterProperties = [ private static readonly HashSet<string> FilterProperties = [
nameof(FilterByDate), nameof(FilterByDate),
nameof(StartDate), nameof(StartDate),
@@ -27,46 +28,47 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
nameof(FilterByChannel), nameof(FilterByChannel),
nameof(IncludedChannels), nameof(IncludedChannels),
nameof(FilterByUser), nameof(FilterByUser),
nameof(IncludedUsers) nameof(IncludedUsers),
]; ];
public string FilterStatisticsText { get; private set; } = "";
public event PropertyChangedEventHandler? FilterPropertyChanged; public event PropertyChangedEventHandler? FilterPropertyChanged;
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser; public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
[ObservableProperty] [Notify]
private string filterStatisticsText = "";
[Notify]
private bool filterByDate = false; private bool filterByDate = false;
[ObservableProperty] [Notify]
private DateTime? startDate = null; private DateTime? startDate = null;
[ObservableProperty] [Notify]
private DateTime? endDate = null; private DateTime? endDate = null;
[ObservableProperty] [Notify]
private bool filterByChannel = false; private bool filterByChannel = false;
[ObservableProperty] [Notify]
private HashSet<ulong>? includedChannels = null; private HashSet<ulong>? includedChannels = null;
[ObservableProperty] [Notify]
private bool filterByUser = false; private bool filterByUser = false;
[ObservableProperty] [Notify]
private HashSet<ulong>? includedUsers = null; private HashSet<ulong>? includedUsers = null;
[ObservableProperty] [Notify]
private string channelFilterLabel = ""; private string channelFilterLabel = "";
[ObservableProperty] [Notify]
private string userFilterLabel = ""; private string userFilterLabel = "";
private readonly Window window; private readonly Window window;
private readonly State state; private readonly State state;
private readonly string verb; private readonly string verb;
private readonly RestartableTask<long> exportedMessageCountTask; private readonly RestartableTask<long> exportedMessageCountTask;
private long? exportedMessageCount; private long? exportedMessageCount;
@@ -78,28 +80,28 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
private readonly IDisposable userCountSubscription; private readonly IDisposable userCountSubscription;
private long? totalUserCount; private long? totalUserCount;
[Obsolete("Designer")] [Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, State.Dummy) {} public MessageFilterPanelModel() : this(null!, State.Dummy) {}
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") { public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
this.window = window; this.window = window;
this.state = state; this.state = state;
this.verb = verb; this.verb = verb;
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext()); this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged); this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged); this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged); this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
UpdateFilterStatistics(); UpdateFilterStatistics();
UpdateChannelFilterLabel(); UpdateChannelFilterLabel();
UpdateUserFilterLabel(); UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
} }
public void Dispose() { public void Dispose() {
exportedMessageCountTask.Cancel(); exportedMessageCountTask.Cancel();
@@ -107,13 +109,13 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
channelCountSubscription.Dispose(); channelCountSubscription.Dispose();
userCountSubscription.Dispose(); userCountSubscription.Dispose();
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) { if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
UpdateFilterStatistics(); UpdateFilterStatistics();
FilterPropertyChanged?.Invoke(sender, e); FilterPropertyChanged?.Invoke(sender, e);
} }
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) { if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
UpdateChannelFilterLabel(); UpdateChannelFilterLabel();
} }
@@ -121,12 +123,12 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
UpdateUserFilterLabel(); UpdateUserFilterLabel();
} }
} }
private void OnMessageCountChanged(long newMessageCount) { private void OnMessageCountChanged(long newMessageCount) {
totalMessageCount = newMessageCount; totalMessageCount = newMessageCount;
UpdateFilterStatistics(); UpdateFilterStatistics();
} }
private void OnChannelCountChanged(long newChannelCount) { private void OnChannelCountChanged(long newChannelCount) {
totalChannelCount = newChannelCount; totalChannelCount = newChannelCount;
UpdateChannelFilterLabel(); UpdateChannelFilterLabel();
@@ -136,7 +138,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
totalUserCount = newUserCount; totalUserCount = newUserCount;
UpdateUserFilterLabel(); UpdateUserFilterLabel();
} }
private void UpdateChannelFilterLabel() { private void UpdateChannelFilterLabel() {
if (totalChannelCount.HasValue) { if (totalChannelCount.HasValue) {
long total = totalChannelCount.Value; long total = totalChannelCount.Value;
@@ -147,7 +149,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
ChannelFilterLabel = "Loading..."; ChannelFilterLabel = "Loading...";
} }
} }
private void UpdateUserFilterLabel() { private void UpdateUserFilterLabel() {
if (totalUserCount.HasValue) { if (totalUserCount.HasValue) {
long total = totalUserCount.Value; long total = totalUserCount.Value;
@@ -158,9 +160,9 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
UserFilterLabel = "Loading..."; UserFilterLabel = "Loading...";
} }
} }
private void UpdateFilterStatistics() { private void UpdateFilterStatistics() {
var filter = CreateFilter(); MessageFilter filter = CreateFilter();
if (filter.IsEmpty) { if (filter.IsEmpty) {
exportedMessageCountTask.Cancel(); exportedMessageCountTask.Cancel();
exportedMessageCount = totalMessageCount; exportedMessageCount = totalMessageCount;
@@ -172,137 +174,151 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken)); exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
} }
} }
private void SetExportedMessageCount(long exportedMessageCount) { private void SetExportedMessageCount(long exportedMessageCount) {
this.exportedMessageCount = exportedMessageCount; this.exportedMessageCount = exportedMessageCount;
UpdateFilterStatisticsText(); UpdateFilterStatisticsText();
} }
private void UpdateFilterStatisticsText() { private void UpdateFilterStatisticsText() {
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)"; string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)"; string totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s."); FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
OnPropertyChanged(nameof(FilterStatisticsText));
} }
public async Task OpenChannelFilterDialog() { [SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local")]
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) { [SuppressMessage("ReSharper", "MemberCanBePrivate.Local")]
var items = new List<CheckBoxItem<ulong>>(); private readonly record struct ChannelFilterKey(byte Type, ulong? ServerId, string Title) : IComparable<ChannelFilterKey> {
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id); public static ChannelFilterKey DirectMessages { get; } = new (Type: 1, ServerId: null, Title: "Direct Messages");
public static ChannelFilterKey GroupMessages { get; } = new (Type: 2, ServerId: null, Title: "Group Messages");
await foreach (var channel in state.Db.Channels.Get()) { public static ChannelFilterKey Unknown { get; } = new (Type: 4, ServerId: null, Title: "Unknown");
var channelId = channel.Id;
var channelName = channel.Name; public static ChannelFilterKey For(DHT.Server.Data.Server server) {
return server.Type switch {
string title; ServerType.Server => new ChannelFilterKey(Type: 3, server.Id, "Server - " + server.Name),
if (servers.TryGetValue(channel.Server, out var server)) { ServerType.Group => GroupMessages,
var titleBuilder = new StringBuilder(); ServerType.DirectMessage => DirectMessages,
var serverType = server.Type; _ => Unknown,
};
titleBuilder.Append('[')
.Append(ServerTypes.ToString(serverType))
.Append("] ");
if (serverType == ServerType.DirectMessage) {
titleBuilder.Append(channelName);
}
else {
titleBuilder.Append(server.Name)
.Append(" - ")
.Append(channelName);
}
title = titleBuilder.ToString();
}
else {
title = channelName;
}
items.Add(new CheckBoxItem<ulong>(channelId) {
Title = title,
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
});
}
return items;
} }
public bool Equals(ChannelFilterKey other) {
return Type == other.Type && ServerId == other.ServerId;
}
public override int GetHashCode() {
return HashCode.Combine(Type, ServerId);
}
public int CompareTo(ChannelFilterKey other) {
int result = Type.CompareTo(other.Type);
if (result != 0) {
return result;
}
else {
return Title.CompareTo(other.Title);
}
}
}
public async Task OpenChannelFilterDialog() {
async Task<ImmutableArray<ICheckBoxItem>> PrepareChannelItems(ProgressDialog dialog) {
CheckBoxItemList<ChannelFilterKey, ulong> items = new CheckBoxItemList<ChannelFilterKey, ulong>();
Dictionary<ulong, DHT.Server.Data.Server> servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
foreach (ChannelFilterKey channelFilterKey in servers.Values.Select(ChannelFilterKey.For).Order()) {
items.AddParent(channelFilterKey, channelFilterKey.Title);
}
await foreach (Channel channel in state.Db.Channels.Get().OrderBy(static channel => channel.Position ?? int.MinValue).ThenBy(static channel => channel.Name)) {
ChannelFilterKey key = servers.TryGetValue(channel.Server, out var server)
? ChannelFilterKey.For(server)
: ChannelFilterKey.Unknown;
items.Add(key, channel.Id, channel.Name, isChecked: IncludedChannels == null || IncludedChannels.Contains(channel.Id));
}
return items.ToCheckBoxItems();
}
const string Title = "Included Channels"; const string Title = "Included Channels";
List<CheckBoxItem<ulong>> items; ImmutableArray<ICheckBoxItem> items;
try { try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems); items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
} catch (Exception e) { } catch (Exception e) {
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message); await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
return; return;
} }
var result = await OpenIdFilterDialog(Title, items); HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
if (result != null) { if (result != null) {
IncludedChannels = result; IncludedChannels = result;
} }
} }
public async Task OpenUserFilterDialog() { public async Task OpenUserFilterDialog() {
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) { async Task<ImmutableArray<ICheckBoxItem>> PrepareUserItems(ProgressDialog dialog) {
var checkBoxItems = new List<CheckBoxItem<ulong>>(); CheckBoxItemList<ulong, ulong> items = new CheckBoxItemList<ulong, ulong>();
await foreach (var user in state.Db.Users.Get()) { static string GetDisplayName(User user) {
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) { return user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})";
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
});
} }
return checkBoxItems; await foreach ((ulong id, string name) in state.Db.Users.Get().Select(static user => (user.Id, GetDisplayName(user))).OrderBy(static pair => pair.Item2)) {
items.Add(
value: id,
title: name,
isChecked: IncludedUsers == null || IncludedUsers.Contains(id)
);
}
return items.ToCheckBoxItems();
} }
const string Title = "Included Users"; const string Title = "Included Users";
List<CheckBoxItem<ulong>> items; ImmutableArray<ICheckBoxItem> items;
try { try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems); items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
} catch (Exception e) { } catch (Exception e) {
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message); await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
return; return;
} }
var result = await OpenIdFilterDialog(Title, items); HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
if (result != null) { if (result != null) {
IncludedUsers = result; IncludedUsers = result;
} }
} }
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) { private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, ImmutableArray<ICheckBoxItem> items) {
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
var model = new CheckBoxDialogModel<ulong>(items) { var model = new CheckBoxDialogModel<ulong>(items) {
Title = title Title = title,
}; };
var dialog = new CheckBoxDialog { DataContext = model }; var dialog = new CheckBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window); var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null; return result == DialogResult.OkCancel.Ok ? model.SelectedValues.ToHashSet() : null;
} }
public MessageFilter CreateFilter() { public MessageFilter CreateFilter() {
MessageFilter filter = new (); MessageFilter filter = new ();
if (FilterByDate) { if (FilterByDate) {
filter.StartDate = StartDate; filter.StartDate = StartDate;
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1); filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
} }
if (FilterByChannel && IncludedChannels != null) { if (FilterByChannel && IncludedChannels != null) {
filter.ChannelIds = new HashSet<ulong>(IncludedChannels); filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
} }
if (FilterByUser && IncludedUsers != null) { if (FilterByUser && IncludedUsers != null) {
filter.UserIds = new HashSet<ulong>(IncludedUsers); filter.UserIds = new HashSet<ulong>(IncludedUsers);
} }
return filter; return filter;
} }
} }

View File

@@ -16,18 +16,21 @@
<Setter Property="FontFamily" Value="Consolas,Courier" /> <Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" /> <Setter Property="FontSize" Value="15" />
</Style> </Style>
<Style Selector="Label">
<Setter Property="Margin" Value="0 5" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="WrapPanel > StackPanel"> <Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" /> <Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel> <StackPanel Spacing="10">
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" /> <Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
<TextBlock TextWrapping="Wrap" Margin="0 15"> <TextBlock TextWrapping="Wrap" Margin="0 5">
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script again. The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script or connection code again.
</TextBlock> </TextBlock>
<WrapPanel> <WrapPanel ItemSpacing="10" LineSpacing="10">
<StackPanel> <StackPanel>
<Label Target="Port">Port</Label> <Label Target="Port">Port</Label>
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" /> <TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />

View File

@@ -1,40 +1,40 @@
using System; using System;
using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable { sealed partial class ServerConfigurationPanelModel : IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>(); private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
[ObservableProperty] [Notify]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputPort; private string inputPort;
[ObservableProperty] [Notify]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputToken; private string inputToken;
[DependsOn(nameof(InputPort), nameof(InputToken))]
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken; public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private bool isToggleServerButtonEnabled = true; private bool isToggleServerButtonEnabled = true;
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server"; public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
private readonly Window window; private readonly Window window;
private readonly ServerManager server; private readonly ServerManager server;
[Obsolete("Designer")] [Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {} public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
public ServerConfigurationPanelModel(Window window, State state) { public ServerConfigurationPanelModel(Window window, State state) {
this.window = window; this.window = window;
this.server = state.Server; this.server = state.Server;
@@ -43,47 +43,47 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
server.StatusChanged += OnServerStatusChanged; server.StatusChanged += OnServerStatusChanged;
} }
public void Dispose() { public void Dispose() {
server.StatusChanged -= OnServerStatusChanged; server.StatusChanged -= OnServerStatusChanged;
} }
private void OnServerStatusChanged(object? sender, ServerManager.Status e) { private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus); Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
} }
private void UpdateServerStatus() { private void UpdateServerStatus() {
OnPropertyChanged(nameof(ToggleServerButtonText)); OnPropertyChanged(new PropertyChangedEventArgs(nameof(ToggleServerButtonText)));
} }
private async Task StartServer() { private async Task StartServer() {
IsToggleServerButtonEnabled = false; IsToggleServerButtonEnabled = false;
try { try {
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token); await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception e) { } catch (Exception e) {
Log.Error(e); Log.Error("Could not start internal server.", e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message); await Dialog.ShowOk(window, "Internal Server Error", e.Message);
} }
UpdateServerStatus(); UpdateServerStatus();
IsToggleServerButtonEnabled = true; IsToggleServerButtonEnabled = true;
} }
private async Task StopServer() { private async Task StopServer() {
IsToggleServerButtonEnabled = false; IsToggleServerButtonEnabled = false;
try { try {
await server.Stop(); await server.Stop();
} catch (Exception e) { } catch (Exception e) {
Log.Error(e); Log.Error("Could not stop internal server.", e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message); await Dialog.ShowOk(window, "Internal Server Error", e.Message);
} }
UpdateServerStatus(); UpdateServerStatus();
IsToggleServerButtonEnabled = true; IsToggleServerButtonEnabled = true;
} }
public async Task OnClickToggleServerButton() { public async Task OnClickToggleServerButton() {
if (server.IsRunning) { if (server.IsRunning) {
await StopServer(); await StopServer();
@@ -92,25 +92,25 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
await StartServer(); await StartServer();
} }
} }
public void OnClickRandomizeToken() { public void OnClickRandomizeToken() {
InputToken = ServerUtils.GenerateRandomToken(20); InputToken = ServerUtils.GenerateRandomToken(20);
} }
public async Task OnClickApplyChanges() { public async Task OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) { if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535."); await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return; return;
} }
ServerConfiguration.Port = port; ServerConfiguration.Port = port;
ServerConfiguration.Token = inputToken; ServerConfiguration.Token = inputToken;
OnPropertyChanged(nameof(HasMadeChanges)); OnPropertyChanged(new PropertyChangedEventArgs(nameof(HasMadeChanges)));
await StartServer(); await StartServer();
} }
public void OnClickCancelChanges() { public void OnClickCancelChanges() {
InputPort = ServerConfiguration.Port.ToString(); InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerConfiguration.Token; InputToken = ServerConfiguration.Token;

View File

@@ -2,62 +2,62 @@ using System;
using System.Reactive.Linq; using System.Reactive.Linq;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Server; using DHT.Server;
using DHT.Server.Service; using DHT.Server.Service;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed partial class StatusBarModel : ObservableObject, IDisposable { sealed partial class StatusBarModel : IDisposable {
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private long? serverCount; private long? serverCount;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private long? channelCount; private long? channelCount;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private long? messageCount; private long? messageCount;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
private ServerManager.Status serverStatus; private ServerManager.Status serverStatus;
public string ServerStatusText => serverStatus switch { [DependsOn(nameof(ServerStatus))]
public string ServerStatusText => ServerStatus switch {
ServerManager.Status.Starting => "STARTING", ServerManager.Status.Starting => "STARTING",
ServerManager.Status.Started => "READY", ServerManager.Status.Started => "READY",
ServerManager.Status.Stopping => "STOPPING", ServerManager.Status.Stopping => "STOPPING",
ServerManager.Status.Stopped => "STOPPED", ServerManager.Status.Stopped => "STOPPED",
_ => "" _ => "",
}; };
private readonly State state; private readonly State state;
private readonly IDisposable serverCountSubscription; private readonly IDisposable serverCountSubscription;
private readonly IDisposable channelCountSubscription; private readonly IDisposable channelCountSubscription;
private readonly IDisposable messageCountSubscription; private readonly IDisposable messageCountSubscription;
[Obsolete("Designer")] [Obsolete("Designer")]
public StatusBarModel() : this(State.Dummy) {} public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel(State state) { public StatusBarModel(State state) {
this.state = state; this.state = state;
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount); serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount); channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount); messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
state.Server.StatusChanged += OnServerStatusChanged; state.Server.StatusChanged += OnStateServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped; serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
} }
public void Dispose() { public void Dispose() {
serverCountSubscription.Dispose(); serverCountSubscription.Dispose();
channelCountSubscription.Dispose(); channelCountSubscription.Dispose();
messageCountSubscription.Dispose(); messageCountSubscription.Dispose();
state.Server.StatusChanged -= OnServerStatusChanged; state.Server.StatusChanged -= OnStateServerStatusChanged;
} }
private void OnServerStatusChanged(object? sender, ServerManager.Status e) { private void OnStateServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e); Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
} }
} }

View File

@@ -0,0 +1,57 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:namespace="clr-namespace:DHT.Desktop.Main.Dialogs"
mc:Ignorable="d" d:DesignWidth="450"
x:Class="DHT.Desktop.Main.Dialogs.NewDatabaseSettingsDialog"
x:DataType="namespace:NewDatabaseSettingsDialogModel"
Title="New Database Settings"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Closing="OnClosing"
Width="450" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<namespace:NewDatabaseSettingsDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Label > StackPanel">
<Setter Property="Margin" Value="0 4 0 0" />
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Spacing" Value="5" />
</Style>
<Style Selector="TextBlock.dim">
<Setter Property="Foreground" Value="DimGray" />
<Setter Property="FontSize" Value="13" />
</Style>
</Window.Styles>
<StackPanel Orientation="Vertical" Margin="20">
<StackPanel Orientation="Vertical" Spacing="15">
<CheckBox IsChecked="{Binding SeparateFileForDownloads}">
<Label>
<StackPanel>
<TextBlock>Use a Separate Database File for Downloads</TextBlock>
<TextBlock Classes="dim">Downloaded files will be stored separately from messages. This cannot be changed later.</TextBlock>
</StackPanel>
</Label>
</CheckBox>
<CheckBox IsChecked="{Binding DownloadsAutoStart}">
<Label>
<StackPanel>
<TextBlock>Download Files Automatically</TextBlock>
<TextBlock Classes="dim">Enables automatic downloads of files hosted on Discord. This can be changed later in the Downloads tab.</TextBlock>
</StackPanel>
</Label>
</CheckBox>
</StackPanel>
<Panel Classes="buttons">
<WrapPanel HorizontalAlignment="Right">
<Button Click="ClickOk">OK</Button>
</WrapPanel>
</Panel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,22 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace DHT.Desktop.Main.Dialogs;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class NewDatabaseSettingsDialog : Window {
public NewDatabaseSettingsDialog() {
InitializeComponent();
}
private void OnClosing(object? sender, WindowClosingEventArgs e) {
if (!e.IsProgrammatic) {
e.Cancel = true;
}
}
private void ClickOk(object? sender, RoutedEventArgs e) {
Close();
}
}

View File

@@ -0,0 +1,11 @@
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Dialogs;
sealed partial class NewDatabaseSettingsDialogModel {
[Notify]
private bool separateFileForDownloads = true;
[Notify]
private bool downloadsAutoStart = true;
}

View File

@@ -16,23 +16,23 @@ public sealed partial class MainWindow : Window {
InitializeComponent(); InitializeComponent();
DataContext = new MainWindowModel(this, Arguments.Empty); DataContext = new MainWindowModel(this, Arguments.Empty);
} }
internal MainWindow(Arguments args) { internal MainWindow(Arguments args) {
InitializeComponent(); InitializeComponent();
DataContext = new MainWindowModel(this, args); DataContext = new MainWindowModel(this, args);
} }
public async void OnClosing(object? sender, WindowClosingEventArgs e) { public async void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = true; e.Cancel = true;
Closing -= OnClosing; Closing -= OnClosing;
try { try {
await Dispose(); await Dispose();
} finally { } finally {
Close(); Close();
} }
} }
private async Task Dispose() { private async Task Dispose() {
if (DataContext is MainWindowModel model) { if (DataContext is MainWindowModel model) {
try { try {

View File

@@ -3,59 +3,60 @@ using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Screens; using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main; namespace DHT.Desktop.Main;
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { sealed partial class MainWindowModel : IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker"; private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>(); private static readonly Log Log = Log.ForType<MainWindowModel>();
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private string title = DefaultTitle; private string title = DefaultTitle;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private UserControl currentScreen; private UserControl currentScreen;
private readonly WelcomeScreen welcomeScreen; private readonly WelcomeScreen welcomeScreen;
private readonly WelcomeScreenModel welcomeScreenModel; private readonly WelcomeScreenModel welcomeScreenModel;
private MainContentScreenModel? mainContentScreenModel; private MainContentScreenModel? mainContentScreenModel;
private readonly Window window; private readonly Window window;
private readonly int? concurrentDownloads; private readonly int? concurrentDownloads;
private State? state; private State? state;
[Obsolete("Designer")] [Obsolete("Designer")]
public MainWindowModel() : this(null!, Arguments.Empty) {} public MainWindowModel() : this(null!, Arguments.Empty) {}
public MainWindowModel(Window window, Arguments args) { public MainWindowModel(Window window, Arguments args) {
this.window = window; this.window = window;
welcomeScreenModel = new WelcomeScreenModel(window); welcomeScreenModel = new WelcomeScreenModel(window);
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected; welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel }; welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
currentScreen = welcomeScreen; currentScreen = welcomeScreen;
var dbFile = args.DatabaseFile; string? dbFile = args.DatabaseFile;
if (!string.IsNullOrWhiteSpace(dbFile)) { if (!string.IsNullOrWhiteSpace(dbFile)) {
async void OnWindowOpened(object? o, EventArgs eventArgs) { async void OnWindowOpened(object? o, EventArgs eventArgs) {
window.Opened -= OnWindowOpened; window.Opened -= OnWindowOpened;
// https://github.com/AvaloniaUI/Avalonia/issues/3071 // https://github.com/AvaloniaUI/Avalonia/issues/3071
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
await Task.Delay(500); await Task.Delay(500);
} }
if (File.Exists(dbFile)) { if (File.Exists(dbFile)) {
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile); await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
} }
@@ -63,73 +64,81 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile); await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
} }
} }
window.Opened += OnWindowOpened; window.Opened += OnWindowOpened;
} }
if (args.ServerPort != null) { if (args.ServerPort != null) {
ServerConfiguration.Port = args.ServerPort.Value; ServerConfiguration.Port = args.ServerPort.Value;
} }
if (args.ServerToken != null) { if (args.ServerToken != null) {
ServerConfiguration.Token = args.ServerToken; ServerConfiguration.Token = args.ServerToken;
} }
concurrentDownloads = args.ConcurrentDownloads; concurrentDownloads = args.ConcurrentDownloads;
} }
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) { private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected; welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
await DisposeState(); await DisposeState();
state = new State(db, concurrentDownloads); state = new State(db, concurrentDownloads);
try { try {
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token); await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception ex) { } catch (Exception ex) {
Log.Error(ex); Log.Error("Could not start internal server.", ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message); await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
} }
mainContentScreenModel = new MainContentScreenModel(window, state);
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
await mainContentScreenModel.Initialize(); try {
mainContentScreenModel = new MainContentScreenModel(window, state);
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
await mainContentScreenModel.Initialize();
} catch (Exception ex) {
Log.Error("Could not initialize content screen.", ex);
await Dialog.ShowOk(window, "Initialization Error", ex.Message);
await DisposeContent();
await DisposeState();
return;
}
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle; Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel }; CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
window.Focus(); window.Focus();
} }
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) { private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
await DisposeContent();
Title = DefaultTitle;
CurrentScreen = welcomeScreen;
await DisposeState();
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
}
private async Task DisposeContent() {
if (mainContentScreenModel != null) { if (mainContentScreenModel != null) {
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed; mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
await mainContentScreenModel.DisposeAsync(); await mainContentScreenModel.DisposeAsync();
mainContentScreenModel = null; mainContentScreenModel = null;
} }
await DisposeState();
Title = DefaultTitle;
CurrentScreen = welcomeScreen;
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
} }
private async Task DisposeState() { private async Task DisposeState() {
if (state != null) { if (state != null) {
await state.DisposeAsync(); await DelayedProgressDialog.Await(() => state.DisposeAsync().AsTask(), TimeSpan.FromMilliseconds(200), window, "Close Database", "Please wait for the database to close...");
state = null; state = null;
} }
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
if (mainContentScreenModel != null) { await DisposeContent();
await mainContentScreenModel.DisposeAsync();
}
await DisposeState(); await DisposeState();
} }
} }

View File

@@ -16,11 +16,5 @@
<Expander Header="Internal Server Configuration" IsExpanded="True"> <Expander Header="Internal Server Configuration" IsExpanded="True">
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" /> <controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
</Expander> </Expander>
<Expander Header="Database Tools" IsExpanded="True">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock TextWrapping="Wrap">Recreates the database to remove any traces of deleted data, which frees up space and prevents forensic recovery.</TextBlock>
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
</StackPanel>
</Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -1,8 +1,5 @@
using System; using System;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server; using DHT.Server;
@@ -11,26 +8,14 @@ namespace DHT.Desktop.Main.Pages;
sealed class AdvancedPageModel : IDisposable { sealed class AdvancedPageModel : IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; } public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window;
private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public AdvancedPageModel() : this(null!, State.Dummy) {} public AdvancedPageModel() : this(null!, State.Dummy) {}
public AdvancedPageModel(Window window, State state) { public AdvancedPageModel(Window window, State state) {
this.window = window;
this.state = state;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state); ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
} }
public void Dispose() { public void Dispose() {
ServerConfigurationModel.Dispose(); ServerConfigurationModel.Dispose();
} }
public async Task VacuumDatabase() {
const string Title = "Vacuum Database";
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
await Dialog.ShowOk(window, Title, "Done.");
}
} }

View File

@@ -11,22 +11,22 @@
<pages:DatabasePageModel /> <pages:DatabasePageModel />
</Design.DataContext> </Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<DockPanel> <DockPanel>
<Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button> <Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button>
<TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsReadOnly="True" /> <TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsReadOnly="True" />
</DockPanel> </DockPanel>
<WrapPanel> <WrapPanel ItemSpacing="10" LineSpacing="10">
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button> <Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button> <Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button> <Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
</WrapPanel> </WrapPanel>
<Expander Header="Advanced Tools" Margin="0 25 0 0">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock TextWrapping="Wrap">Recreate the database to free up space after deleting data.</TextBlock>
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
</StackPanel>
</Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -19,75 +19,89 @@ using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Import; using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite.Schema; using DHT.Server.Database.Sqlite.Schema;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel { sealed class DatabasePageModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>(); private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; } public IDatabaseFile Db { get; }
public event EventHandler? DatabaseClosed; public event EventHandler? DatabaseClosed;
private readonly Window window; private readonly Window window;
[Obsolete("Designer")] [Obsolete("Designer")]
public DatabasePageModel() : this(null!, State.Dummy) {} public DatabasePageModel() : this(null!, State.Dummy) {}
public DatabasePageModel(Window window, State state) { public DatabasePageModel(Window window, State state) {
this.window = window; this.window = window;
this.Db = state.Db; this.Db = state.Db;
} }
public async Task OpenDatabaseFolder() { public async Task OpenDatabaseFolder() {
string file = Db.Path; string file = Db.Path;
string? folder = Path.GetDirectoryName(file); string? folder = Path.GetDirectoryName(file);
if (folder == null) { if (folder == null) {
return; return;
} }
switch (Environment.OSVersion.Platform) { switch (Environment.OSVersion.Platform) {
case PlatformID.Win32NT: case PlatformID.Win32NT:
Process.Start("explorer.exe", "/select,\"" + file + "\""); Process.Start("explorer.exe", "/select,\"" + file + "\"");
break; break;
case PlatformID.Unix: case PlatformID.Unix:
Process.Start("xdg-open", new string[] { folder }); Process.Start("xdg-open", [ folder ]);
break; break;
case PlatformID.MacOSX: case PlatformID.MacOSX:
Process.Start("open", new string[] { folder }); Process.Start("open", [ folder ]);
break; break;
default: default:
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system."); await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
break; break;
} }
} }
public void CloseDatabase() { public void CloseDatabase() {
DatabaseClosed?.Invoke(this, EventArgs.Empty); DatabaseClosed?.Invoke(this, EventArgs.Empty);
} }
public async Task MergeWithDatabase() { public async Task MergeWithDatabase() {
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path)); string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length > 0) { if (paths.Length == 0) {
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback)); return;
} }
const string Title = "Database Merge";
ImportResult? result;
try {
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
} catch (Exception e) {
Log.Error("Could not merge databases.", e);
await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message);
return;
}
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file"));
} }
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length); var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => { return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => {
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks); IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
if (db == null) { if (db == null) {
return false; return false;
} }
try { try {
await target.AddFrom(db); await target.AddFrom(db);
return true; return true;
@@ -96,60 +110,70 @@ sealed class DatabasePageModel {
} }
}); });
} }
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks { private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks {
private readonly ProgressDialog dialog;
private readonly int total;
private bool? decision; private bool? decision;
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) { public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
this.total = total; return Task.FromResult<InitialDatabaseSettings?>(null);
this.dialog = dialog;
} }
public async Task<bool> CanUpgrade() { public async Task<bool> CanUpgrade() {
return decision ??= (total > 1 return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog) ? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes; : await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
} }
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) { public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter()); return doUpgrade(new NullReporter());
} }
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter { private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() { public Task NextVersion() {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task MainWork(string message, int finishedItems, int totalItems) { public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SubWork(string message, int finishedItems, int totalItems) { public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
} }
public async Task ImportLegacyArchive() { public async Task ImportLegacyArchive() {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive", Title = "Open Legacy DHT Archive",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)), SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
AllowMultiple = true AllowMultiple = true,
}); });
if (paths.Length > 0) { if (paths.Length == 0) {
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback)); return;
} }
const string Title = "Legacy Archive Import";
ImportResult? result;
try {
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
} catch (Exception e) {
Log.Error("Could not import legacy archives.", e);
await Dialog.ShowOk(window, Title, "Could not import legacy archives: " + e.Message);
return;
}
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "archive file"));
} }
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task<ImportResult?> ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var fakeSnowflake = new FakeSnowflake(); var fakeSnowflake = new FakeSnowflake();
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => { return await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", async path => {
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => { return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current; SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext()); SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
@@ -159,75 +183,74 @@ sealed class DatabasePageModel {
}); });
}); });
} }
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) { private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
static bool IsValidSnowflake(string value) { static bool IsValidSnowflake(string value) {
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _); return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
} }
var items = new List<TextBoxItem<DHT.Server.Data.Server>>(); var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) { foreach (DHT.Server.Data.Server server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) { items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")", Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
ValidityCheck = IsValidSnowflake ValidityCheck = IsValidSnowflake,
}); });
} }
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) { var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
Title = "Imported Server IDs", Title = "Imported Server IDs",
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID." Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID.",
}; };
var dialog = new TextBoxDialog { DataContext = model }; var dialog = new TextBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window); var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
if (result != DialogResult.OkCancel.Ok) { if (result != DialogResult.OkCancel.Ok) {
return null; return null;
} }
return model.ValidItems return model.ValidItems
.Where(static item => !string.IsNullOrEmpty(item.Value)) .Where(static item => !string.IsNullOrEmpty(item.Value))
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value)); .ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
} }
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) { private static async Task<ImportResult?> PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string dialogTitle, Func<string, Task<bool>> performImport) {
int total = paths.Length; int total = paths.Length;
var oldStatistics = await DatabaseStatistics.Take(target); DatabaseStatistics oldStatistics = await DatabaseStatistics.Take(target);
int successful = 0; int successful = 0;
int finished = 0; int finished = 0;
foreach (string path in paths) { foreach (string path in paths) {
await callback.Update(Path.GetFileName(path), finished, total); await callback.Update(Path.GetFileName(path), finished, total);
++finished; ++finished;
if (!File.Exists(path)) { if (!File.Exists(path)) {
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists."); await Dialog.ShowOk(dialog, dialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
continue; continue;
} }
try { try {
if (await performImport(path)) { if (await performImport(path)) {
++successful; ++successful;
} }
} catch (Exception ex) { } catch (Exception ex) {
Log.Error(ex); Log.Error("Could not import file: " + path, ex);
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message); await Dialog.ShowOk(dialog, dialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
} }
} }
await callback.Update("Done", finished, total); await callback.Update("Done", finished, total);
if (successful == 0) { if (successful == 0) {
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported."); return null;
return;
} }
var newStatistics = await DatabaseStatistics.Take(target); DatabaseStatistics newStatistics = await DatabaseStatistics.Take(target);
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName)); return new ImportResult(oldStatistics, newStatistics, successful, total);
} }
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) { private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) { public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
return new DatabaseStatistics( return new DatabaseStatistics(
@@ -238,29 +261,52 @@ sealed class DatabasePageModel {
); );
} }
} }
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) { private sealed record ImportResult(DatabaseStatistics OldStatistics, DatabaseStatistics NewStatistics, int SuccessfulItems, int TotalItems);
private static string GetImportDialogMessage(ImportResult? result, string itemName) {
if (result == null) {
return "Nothing was imported.";
}
var oldStatistics = result.OldStatistics;
var newStatistics = result.NewStatistics;
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount; long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount; long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
long newUsers = newStatistics.UserCount - oldStatistics.UserCount; long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount; long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
StringBuilder message = new StringBuilder(); var message = new StringBuilder();
message.Append("Processed "); message.Append("Processed ");
if (successfulItems == totalItems) { if (result.SuccessfulItems == result.TotalItems) {
message.Append(successfulItems.Pluralize(itemName)); message.Append(result.SuccessfulItems.Pluralize(itemName));
} }
else { else {
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName)); message.Append(result.SuccessfulItems.Format()).Append(" out of ").Append(result.TotalItems.Pluralize(itemName));
} }
message.Append(" and added:\n\n \u2022 "); message.Append(" and added:\n\n \u2022 ");
message.Append(newServers.Pluralize("server")).Append("\n \u2022 "); message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 "); message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 "); message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
message.Append(newMessages.Pluralize("message")); message.Append(newMessages.Pluralize("message"));
return message.ToString(); return message.ToString();
} }
public async Task VacuumDatabase() {
const string Title = "Vacuum Database";
try {
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => Db.Vacuum());
} catch (Exception e) {
Log.Error("Could not vacuum database.", e);
await Dialog.ShowOk(window, Title, "Could not vacuum database: " + e.Message);
return;
}
await Dialog.ShowOk(window, Title, "Done.");
}
} }

View File

@@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage" x:Class="DHT.Desktop.Main.Pages.DebugPage"
x:DataType="pages:DebugPageModel"> x:DataType="pages:DebugPageModel">
<Design.DataContext> <Design.DataContext>
<pages:DebugPageModel /> <pages:DebugPageModel />
</Design.DataContext> </Design.DataContext>
@@ -16,24 +16,27 @@
<Setter Property="FontFamily" Value="Consolas,Courier" /> <Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" /> <Setter Property="FontSize" Value="15" />
</Style> </Style>
<Style Selector="Label">
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="WrapPanel > StackPanel"> <Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" /> <Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="10"> <StackPanel Orientation="Vertical" Spacing="10">
<Expander Header="Generate Random Data" IsExpanded="True"> <Expander Header="Generate Random Data" IsExpanded="True">
<WrapPanel> <WrapPanel ItemSpacing="10" LineSpacing="10">
<StackPanel> <StackPanel Spacing="8">
<Label Target="Channels">Channels</Label> <Label Target="Channels">Channels</Label>
<TextBox x:Name="Channels" Width="100" Text="{Binding GenerateChannels}" /> <TextBox x:Name="Channels" Width="100" Text="{Binding GenerateChannels}" />
</StackPanel> </StackPanel>
<StackPanel> <StackPanel Spacing="8">
<Label Target="Users">Users</Label> <Label Target="Users">Users</Label>
<TextBox x:Name="Users" Width="100" Text="{Binding GenerateUsers}" /> <TextBox x:Name="Users" Width="100" Text="{Binding GenerateUsers}" />
</StackPanel> </StackPanel>
<StackPanel> <StackPanel Spacing="8">
<Label Target="Messages">Messages</Label> <Label Target="Messages">Messages</Label>
<TextBox x:Name="Messages" Width="100" Text="{Binding GenerateMessages}" /> <TextBox x:Name="Messages" Width="100" Text="{Binding GenerateMessages}" />
</StackPanel> </StackPanel>
@@ -42,5 +45,8 @@
</StackPanel> </StackPanel>
</WrapPanel> </WrapPanel>
</Expander> </Expander>
<Expander Header="About" IsExpanded="True">
<TextBlock Text="{Binding SqliteVersion, StringFormat=Sqlite Version: {0}}" />
</Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -9,97 +9,105 @@ using DHT.Desktop.Dialogs.Progress;
using DHT.Server; using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Service; using DHT.Server.Service;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class DebugPageModel { sealed partial class DebugPageModel {
public string GenerateChannels { get; set; } = "0"; public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0"; public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0"; public string GenerateMessages { get; set; } = "0";
[Notify(Setter.Private)]
private string? sqliteVersion = string.Empty;
private readonly Window window; private readonly Window window;
private readonly State state; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public DebugPageModel() : this(null!, State.Dummy) {} public DebugPageModel() : this(null!, State.Dummy) {}
public DebugPageModel(Window window, State state) { public DebugPageModel(Window window, State state) {
this.window = window; this.window = window;
this.state = state; this.state = state;
} }
public async Task Initialize() {
SqliteVersion = await state.Db.GetVersion() ?? "<unknown>";
}
public async void OnClickAddRandomDataToDatabase() { public async void OnClickAddRandomDataToDatabase() {
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) { if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!"); await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
return; return;
} }
if (!int.TryParse(GenerateUsers, out int users) || users < 1) { if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!"); await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
return; return;
} }
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) { if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!"); await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
return; return;
} }
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback)); await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
} }
private const int BatchSize = 500; private const int BatchSize = 500;
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) { private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
int batchCount = (messageCount + BatchSize - 1) / BatchSize; int batchCount = (messageCount + BatchSize - 1) / BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount); await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, totalItems: batchCount);
var rand = new Random(); var rand = new Random();
var server = new DHT.Server.Data.Server { var server = new DHT.Server.Data.Server {
Id = RandomId(rand), Id = RandomId(rand),
Name = RandomName("s"), Name = RandomName("s"),
Type = ServerType.Server, Type = ServerType.Server,
}; };
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel { Channel[] channels = Enumerable.Range(start: 0, channelCount).Select(i => new Channel {
Id = RandomId(rand), Id = RandomId(rand),
Server = server.Id, Server = server.Id,
Name = RandomName("c"), Name = RandomName("c"),
ParentId = null, ParentId = null,
Position = i, Position = i,
Topic = RandomText(rand, 10), Topic = RandomText(rand, maxWords: 10),
Nsfw = rand.Next(4) == 0, Nsfw = rand.Next(4) == 0,
}).ToArray(); }).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User { User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
Id = RandomId(rand), Id = RandomId(rand),
Name = RandomName("u"), Name = RandomName("u"),
DisplayName = RandomName("u"), DisplayName = RandomName("u"),
AvatarUrl = null, AvatarHash = null,
Discriminator = rand.Next(0, 9999).ToString(), Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
}).ToArray(); }).ToArray();
await state.Db.Users.Add(users); await state.Db.Users.Add(users);
await state.Db.Servers.Add([server]); await state.Db.Servers.Add([server]);
await state.Db.Channels.Add(channels); await state.Db.Channels.Add(channels);
var now = DateTimeOffset.Now; DateTimeOffset now = DateTimeOffset.Now;
int batchIndex = 0; int batchIndex = 0;
while (messageCount > 0) { while (messageCount > 0) {
int hourOffset = batchIndex; int hourOffset = batchIndex;
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => { Message[] messages = Enumerable.Range(start: 0, Math.Min(messageCount, BatchSize)).Select(i => {
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize); DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null; DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(minValue: 1, maxValue: 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds(); long timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds(); long? editMillis = edit?.ToUnixTimeMilliseconds();
return new Message { return new Message {
Id = (ulong) timeMillis, Id = (ulong) timeMillis,
Sender = RandomBiasedIndex(rand, users).Id, Sender = RandomBiasedIndex(rand, users).Id,
Channel = RandomBiasedIndex(rand, channels).Id, Channel = RandomBiasedIndex(rand, channels).Id,
Text = RandomText(rand, 100), Text = RandomText(rand, maxWords: 100),
Timestamp = timeMillis, Timestamp = timeMillis,
EditTimestamp = editMillis, EditTimestamp = editMillis,
RepliedToId = null, RepliedToId = null,
@@ -108,28 +116,28 @@ sealed class DebugPageModel {
Reactions = ImmutableList<Reaction>.Empty, Reactions = ImmutableList<Reaction>.Empty,
}; };
}).ToArray(); }).ToArray();
await state.Db.Messages.Add(messages); await state.Db.Messages.Add(messages);
messageCount -= BatchSize; messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount); await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
} }
} }
private static ulong RandomId(Random rand) { private static ulong RandomId(Random rand) {
ulong h = unchecked((ulong) rand.Next()); ulong h = unchecked((ulong) rand.Next());
ulong l = unchecked((ulong) rand.Next()); ulong l = unchecked((ulong) rand.Next());
return (h << 32) | l; return (h << 32) | l;
} }
private static string RandomName(string prefix) { private static string RandomName(string prefix) {
return prefix + "-" + ServerUtils.GenerateRandomToken(5); return prefix + "-" + ServerUtils.GenerateRandomToken(5);
} }
private static T RandomBiasedIndex<T>(Random rand, T[] options) { private static T RandomBiasedIndex<T>(Random rand, T[] options) {
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())]; return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
} }
private static readonly string[] RandomWords = [ private static readonly string[] RandomWords = [
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado", "apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli", "banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
@@ -153,12 +161,12 @@ sealed class DebugPageModel {
"vanilla", "vanilla",
"watercress", "watermelon", "watercress", "watermelon",
"yam", "yam",
"zucchini" "zucchini",
]; ];
private static string RandomText(Random rand, int maxWords) { private static string RandomText(Random rand, int maxWords) {
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3)); int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), y: 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)])); return string.Join(separator: ' ', Enumerable.Range(start: 0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
} }
} }
#else #else
@@ -169,6 +177,8 @@ sealed class DebugPageModel {
public string GenerateUsers { get; set; } = "0"; public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0"; public string GenerateMessages { get; set; } = "0";
public string SqliteVersion => string.Empty;
public void OnClickAddRandomDataToDatabase() {} public void OnClickAddRandomDataToDatabase() {}
} }
#endif #endif

View File

@@ -13,9 +13,6 @@
</Design.DataContext> </Design.DataContext>
<UserControl.Styles> <UserControl.Styles>
<Style Selector="Expander">
<Setter Property="Margin" Value="0 5 0 0" />
</Style>
<Style Selector="DataGridColumnHeader"> <Style Selector="DataGridColumnHeader">
<Setter Property="FontWeight" Value="Medium" /> <Setter Property="FontWeight" Value="Medium" />
</Style> </Style>
@@ -31,24 +28,24 @@
</UserControl.Styles> </UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="20"> <StackPanel Orientation="Vertical" Spacing="20">
<StackPanel Orientation="Horizontal" Spacing="10"> <WrapPanel Orientation="Horizontal" ItemSpacing="10" LineSpacing="10">
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" /> <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button> <Button Command="{Binding OnClickRetryFailed}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed</Button>
</StackPanel> <Button Command="{Binding OnClickDeleteOrphaned}">Delete Orphaned</Button>
<Button Command="{Binding OnClickExportAll}" IsEnabled="{Binding HasSuccessfulDownloads}">Export All...</Button>
</WrapPanel>
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" /> <controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
<TextBlock TextWrapping="Wrap"> <TextBlock TextWrapping="Wrap">
Downloading state and filter settings are remembered per-database. Downloading state and filter settings are remembered per-database.
</TextBlock> </TextBlock>
<StackPanel Orientation="Vertical" Spacing="12"> <Expander Header="Download Status" IsExpanded="True">
<Expander Header="Download Status" IsExpanded="True"> <DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True"> <DataGrid.Columns>
<DataGrid.Columns> <DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" /> <DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" /> <DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" /> </DataGrid.Columns>
</DataGrid.Columns> </DataGrid>
</DataGrid> </Expander>
</Expander>
</StackPanel>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -1,172 +1,299 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server; using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Aggregations; using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Data.Settings; using DHT.Server.Data.Settings;
using DHT.Server.Download; using DHT.Server.Download;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable { sealed partial class DownloadsPageModel : IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloadsPageModel>(); private static readonly Log Log = Log.ForType<DownloadsPageModel>();
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private bool isToggleDownloadButtonEnabled = true; private bool isToggleDownloadButtonEnabled = true;
[DependsOn(nameof(IsDownloading))]
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading"; public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool isRetryingFailedDownloads = false; private bool isRetryingFailedDownloads = false;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))] private bool hasSuccessfulDownloads;
[Notify(Setter.Private)]
private bool hasFailedDownloads; private bool hasFailedDownloads;
[DependsOn(nameof(IsRetryingFailedDownloads), nameof(HasFailedDownloads))]
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads; public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private string downloadMessage = ""; private string downloadMessage = "";
public DownloadItemFilterPanelModel FilterModel { get; } public DownloadItemFilterPanelModel FilterModel { get; }
private readonly StatisticsRow statisticsPending = new ("Pending"); private readonly StatisticsRow statisticsPending = new ("Pending");
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded"); private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
private readonly StatisticsRow statisticsFailed = new ("Failed"); private readonly StatisticsRow statisticsFailed = new ("Failed");
private readonly StatisticsRow statisticsSkipped = new ("Skipped"); private readonly StatisticsRow statisticsSkipped = new ("Skipped");
public ObservableCollection<StatisticsRow> StatisticsRows { get; } public ObservableCollection<StatisticsRow> StatisticsRows { get; }
public bool IsDownloading => state.Downloader.IsDownloading; public bool IsDownloading => state.Downloader.IsDownloading;
private readonly Window window;
private readonly State state; private readonly State state;
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask; private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
private readonly IDisposable downloadItemCountSubscription; private readonly IDisposable downloadItemCountSubscription;
private IDisposable? finishedItemsSubscription; private IDisposable? finishedItemsSubscription;
private DownloadItemFilter? currentDownloadFilter; private DownloadItemFilter? currentDownloadFilter;
public DownloadsPageModel() : this(State.Dummy) {} public DownloadsPageModel() : this(null!, State.Dummy) {}
public DownloadsPageModel(State state) { public DownloadsPageModel(Window window, State state) {
this.window = window;
this.state = state; this.state = state;
FilterModel = new DownloadItemFilterPanelModel(state); FilterModel = new DownloadItemFilterPanelModel(state);
StatisticsRows = [ StatisticsRows = [
statisticsPending, statisticsPending,
statisticsDownloaded, statisticsDownloaded,
statisticsFailed, statisticsFailed,
statisticsSkipped statisticsSkipped,
]; ];
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext()); downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged); downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
RecomputeDownloadStatistics(); RecomputeDownloadStatistics();
} }
public async Task Initialize() { public async Task Initialize() {
await FilterModel.Initialize(); await FilterModel.Initialize();
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) { if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
await StartDownload(); try {
await StartDownload();
} catch (Exception e) {
Log.Error("Could not automatically start downloads.", e);
await Dialog.ShowOk(window, "Database Error", "Could not automatically start downloads: " + e.Message);
}
} }
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
finishedItemsSubscription?.Dispose(); finishedItemsSubscription?.Dispose();
downloadItemCountSubscription.Dispose(); downloadItemCountSubscription.Dispose();
downloadStatisticsTask.Dispose(); downloadStatisticsTask.Dispose();
await FilterModel.DisposeAsync(); await FilterModel.DisposeAsync();
} }
private void OnDownloadCountChanged(long newDownloadCount) { private void OnDownloadCountChanged(long newDownloadCount) {
RecomputeDownloadStatistics(); RecomputeDownloadStatistics();
} }
public async Task OnClickToggleDownload() { public async Task OnClickToggleDownload() {
IsToggleDownloadButtonEnabled = false; IsToggleDownloadButtonEnabled = false;
try {
if (IsDownloading) { if (IsDownloading) {
await StopDownload(); await StopDownload();
}
else {
try {
await StartDownload();
} catch (Exception e) {
Log.Error("Could not start downloads.", e);
await Dialog.ShowOk(window, "Database Error", "Could not start downloads: " + e.Message);
return;
}
}
try {
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
} catch (Exception e) {
Log.Error("Could not update auto-start setting in database.", e);
}
} finally {
IsToggleDownloadButtonEnabled = true;
} }
else {
await StartDownload();
}
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
IsToggleDownloadButtonEnabled = true;
} }
private async Task StartDownload() { private async Task StartDownload() {
await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); try {
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); currentDownloadFilter = FilterModel.CreateFilter();
IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter);
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
} catch (Exception) {
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
currentDownloadFilter = null;
throw;
}
OnDownloadStateChanged(); OnDownloadStateChanged();
} }
private async Task StopDownload() { private async Task StopDownload() {
await state.Downloader.Stop(); await state.Downloader.Stop();
await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
finishedItemsSubscription?.Dispose(); finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null; finishedItemsSubscription = null;
currentDownloadFilter = null; currentDownloadFilter = null;
OnDownloadStateChanged(); OnDownloadStateChanged();
} }
private void OnDownloadStateChanged() { private void OnDownloadStateChanged() {
RecomputeDownloadStatistics(); RecomputeDownloadStatistics();
OnPropertyChanged(nameof(ToggleDownloadButtonText)); OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsDownloading)));
OnPropertyChanged(nameof(IsDownloading));
} }
private void OnItemFinished(DownloadItem item) { private void OnItemFinished(DownloadItem item) {
RecomputeDownloadStatistics(); RecomputeDownloadStatistics();
} }
public async Task OnClickRetryFailedDownloads() { public async Task OnClickRetryFailed() {
IsRetryingFailedDownloads = true; IsRetryingFailedDownloads = true;
try { try {
await state.Db.Downloads.RetryFailed(); await state.Db.Downloads.RetryFailed();
RecomputeDownloadStatistics(); RecomputeDownloadStatistics();
} catch (Exception e) { } catch (Exception e) {
Log.Error(e); Log.Error("Could not retry failed downloads.", e);
await Dialog.ShowOk(window, "Retry Failed", "Could not retry failed downloads: " + e.Message);
} finally { } finally {
IsRetryingFailedDownloads = false; IsRetryingFailedDownloads = false;
} }
} }
private void RecomputeDownloadStatistics() { private void RecomputeDownloadStatistics() {
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken)); downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
} }
public async Task OnClickDeleteOrphaned() {
const string Title = "Delete Orphaned Downloads";
try {
await ProgressDialog.Show(window, Title, async (_, callback) => {
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
HashSet<string> reachableNormalizedUrls = [];
HashSet<string> orphanedNormalizedUrls = [];
await foreach (FileUrl fileUrl in state.Db.Downloads.FindReachableFiles()) {
reachableNormalizedUrls.Add(fileUrl.NormalizedUrl);
}
await foreach (Download download in state.Db.Downloads.Get()) {
string normalizedUrl = download.NormalizedUrl;
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
orphanedNormalizedUrls.Add(normalizedUrl);
}
}
if (orphanedNormalizedUrls.Count == 0) {
await Dialog.ShowOk(window, Title, "No orphaned downloads found.");
return;
}
if (await Dialog.ShowYesNo(window, Title, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
RecomputeDownloadStatistics();
if (await Dialog.ShowYesNo(window, Title, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Vacuuming database...");
await state.Db.Vacuum();
});
} catch (Exception e) {
Log.Error("Could not delete orphaned downloads.", e);
await Dialog.ShowOk(window, Title, "Could not delete orphaned downloads: " + e.Message);
}
}
public async Task OnClickExportAll() {
const string Title = "Export Downloaded Files";
string[] folders = await window.StorageProvider.OpenFolders(new FolderPickerOpenOptions {
Title = Title,
AllowMultiple = false,
});
if (folders.Length != 1) {
return;
}
string folderPath = folders[0];
DownloadExporter exporter = new DownloadExporter(state.Db, folderPath);
DownloadExporter.Result result;
try {
result = await ProgressDialog.Show(window, Title, async (_, callback) => {
await callback.UpdateIndeterminate("Exporting downloaded files...");
return await exporter.Export(new ExportProgressReporter(callback));
});
} catch (Exception e) {
Log.Error("Could not export downloaded files.", e);
await Dialog.ShowOk(window, Title, "Could not export downloaded files: " + e.Message);
return;
}
string messageStart = "Exported " + result.SuccessfulCount.Pluralize("file");
if (result.FailedCount > 0L) {
await Dialog.ShowOk(window, Title, messageStart + " (" + result.FailedCount.Format() + " failed).");
}
else {
await Dialog.ShowOk(window, Title, messageStart + ".");
}
}
private sealed class ExportProgressReporter(IProgressCallback callback) : DownloadExporter.IProgressReporter {
public Task ReportProgress(long processedCount, long totalCount) {
return callback.Update("Exporting downloaded files...", processedCount, totalCount);
}
}
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) { private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
statisticsPending.Items = statusStatistics.PendingCount; statisticsPending.Items = statusStatistics.PendingCount;
statisticsPending.Size = statusStatistics.PendingTotalSize; statisticsPending.Size = statusStatistics.PendingTotalSize;
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0; statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
statisticsDownloaded.Items = statusStatistics.SuccessfulCount; statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize; statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0; statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
statisticsFailed.Items = statusStatistics.FailedCount; statisticsFailed.Items = statusStatistics.FailedCount;
statisticsFailed.Size = statusStatistics.FailedTotalSize; statisticsFailed.Size = statusStatistics.FailedTotalSize;
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0; statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
@@ -174,25 +301,24 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
statisticsSkipped.Items = statusStatistics.SkippedCount; statisticsSkipped.Items = statusStatistics.SkippedCount;
statisticsSkipped.Size = statusStatistics.SkippedTotalSize; statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0; statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0;
HasFailedDownloads = statusStatistics.FailedCount > 0; HasFailedDownloads = statusStatistics.FailedCount > 0;
} }
[ObservableObject]
public sealed partial class StatisticsRow(string state) { public sealed partial class StatisticsRow(string state) {
public string State { get; } = state; public string State { get; } = state;
[ObservableProperty] [Notify]
private int items; private int items;
[ObservableProperty] [Notify]
[NotifyPropertyChangedFor(nameof(SizeText))]
private ulong? size; private ulong? size;
[ObservableProperty] [Notify]
[NotifyPropertyChangedFor(nameof(SizeText))]
private bool hasFilesWithUnknownSize; private bool hasFilesWithUnknownSize;
[DependsOn(nameof(Size), nameof(HasFilesWithUnknownSize))]
public string SizeText { public string SizeText {
get { get {
if (size == null) { if (size == null) {

View File

@@ -11,17 +11,44 @@
<pages:TrackingPageModel /> <pages:TrackingPageModel />
</Design.DataContext> </Design.DataContext>
<StackPanel Spacing="10"> <UserControl.Styles>
<TextBlock TextWrapping="Wrap"> <Style Selector="TextBlock">
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I. <Setter Property="TextWrapping" Value="Wrap" />
</TextBlock> </Style>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10"> </UserControl.Styles>
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
</StackPanel> <StackPanel Spacing="20">
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0"> <Expander Header="Method 1: Manual" IsExpanded="True">
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled. <StackPanel Orientation="Vertical" Spacing="10">
</TextBlock> <TextBlock>
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" /> <TextBlock.Text>
<MultiBinding StringFormat="Use {0} to open Dev Tools in your browser or the Discord app, copy the tracking script, and paste it into the console.">
<Binding Path="OpenDevToolsShortcutText" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
<TextBlock Margin="0 5 0 0">
By default, the Discord app blocks the Dev Tools shortcut. The button below changes a hidden setting to unblock the shortcut.
</TextBlock>
<Button Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
</StackPanel>
</Expander>
<Expander Header="Method 2: Userscript" IsExpanded="True">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock>
Requires a userscript manager in your browser. The userscript adds a DHT icon next to the Help icon on Discord.
If the icon does not appear, update this app and reinstall the userscript.
</TextBlock>
<TextBlock>
Copy the Connection Code, click the DHT icon, and paste the code into the prompt.
</TextBlock>
<WrapPanel ItemSpacing="10" LineSpacing="10">
<Button Command="{Binding OnClickInstallOrUpdateUserscript}">Install or Update Userscript</Button>
<Button x:Name="CopyConnectionCode" Click="CopyConnectionScriptButton_OnClick">Copy Connection Code</Button>
</WrapPanel>
</StackPanel>
</Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
@@ -8,24 +9,36 @@ namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class TrackingPage : UserControl { public sealed partial class TrackingPage : UserControl {
private bool isCopyingScript; private readonly HashSet<Button> copyingButtons = new (ReferenceEqualityComparer.Instance);
public TrackingPage() { public TrackingPage() {
InitializeComponent(); InitializeComponent();
} }
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) { public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
await HandleCopyButton(CopyTrackingScript, "Script Copied!", static model => model.OnClickCopyTrackingScript());
}
public async void CopyConnectionScriptButton_OnClick(object? sender, RoutedEventArgs e) {
await HandleCopyButton(CopyConnectionCode, "Code Copied!", static model => model.OnClickCopyConnectionCode());
}
private async Task HandleCopyButton(Button button, string copiedText, Func<TrackingPageModel, Task<bool>> onClick) {
if (DataContext is TrackingPageModel model) { if (DataContext is TrackingPageModel model) {
var originalText = CopyTrackingScript.Content; object? originalText = button.Content;
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width; button.MinWidth = button.Bounds.Width;
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) { if (await onClick(model) && copyingButtons.Add(button)) {
isCopyingScript = true; button.IsEnabled = false;
CopyTrackingScript.Content = "Script Copied!"; button.Content = copiedText;
await Task.Delay(TimeSpan.FromSeconds(2)); try {
CopyTrackingScript.Content = originalText; await Task.Delay(TimeSpan.FromSeconds(2));
isCopyingScript = false; } finally {
copyingButtons.Remove(button);
button.IsEnabled = true;
button.Content = originalText;
}
} }
} }
} }

View File

@@ -1,85 +1,66 @@
using System; using System;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel; using Avalonia.Input.Platform;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord; using DHT.Desktop.Discord;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed partial class TrackingPageModel : ObservableObject { sealed partial class TrackingPageModel {
[ObservableProperty(Setter = Access.Private)] private static readonly Log Log = Log.ForType<TrackingPageModel>();
private bool isCopyTrackingScriptButtonEnabled = true;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool? areDevToolsEnabled = null; private bool? areDevToolsEnabled = null;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool isToggleAppDevToolsButtonEnabled = false; private bool isToggleAppDevToolsButtonEnabled = false;
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
[DependsOn(nameof(AreDevToolsEnabled), nameof(IsToggleAppDevToolsButtonEnabled))]
public string ToggleAppDevToolsButtonText { public string ToggleAppDevToolsButtonText {
get { get {
if (!AreDevToolsEnabled.HasValue) { if (!AreDevToolsEnabled.HasValue) {
return "Loading..."; return "Loading...";
} }
if (!IsToggleAppDevToolsButtonEnabled) { if (!IsToggleAppDevToolsButtonEnabled) {
return "Unavailable"; return "Unavailable";
} }
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I"; return (AreDevToolsEnabled.Value ? "Disable" : "Enable") + " " + OpenDevToolsShortcutText;
} }
} }
private readonly Window window; private readonly Window window;
[Obsolete("Designer")] [Obsolete("Designer")]
public TrackingPageModel() : this(null!) {} public TrackingPageModel() : this(null!) {}
public TrackingPageModel(Window window) { public TrackingPageModel(Window window) {
this.window = window; this.window = window;
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
} }
public async Task<bool> OnClickCopyTrackingScript() { public async Task<bool> OnClickCopyTrackingScript() {
IsCopyTrackingScriptButtonEnabled = false; string url = ServerConfiguration.HttpHost + $"/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
try {
return await CopyTrackingScript();
} finally {
IsCopyTrackingScriptButtonEnabled = true;
}
}
private async Task<bool> CopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url); string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
return await TryCopy(script, "Copy Tracking Script");
var clipboard = window.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false;
}
try {
await clipboard.SetTextAsync(script);
return true;
} catch {
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
return false;
}
} }
private async Task InitializeDevToolsToggle() { private async Task InitializeDevToolsToggle() {
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled); bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
if (devToolsEnabled.HasValue) { if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value; AreDevToolsEnabled = devToolsEnabled.Value;
IsToggleAppDevToolsButtonEnabled = true; IsToggleAppDevToolsButtonEnabled = true;
@@ -88,46 +69,86 @@ sealed partial class TrackingPageModel : ObservableObject {
IsToggleAppDevToolsButtonEnabled = false; IsToggleAppDevToolsButtonEnabled = false;
} }
} }
public async Task OnClickToggleAppDevTools() { public async Task OnClickToggleAppDevTools() {
const string DialogTitle = "Discord App Settings File"; const string DialogTitle = "Discord App Settings File";
if (!AreDevToolsEnabled.HasValue) { if (!AreDevToolsEnabled.HasValue) {
return; return;
} }
bool oldState = AreDevToolsEnabled.Value; bool oldState = AreDevToolsEnabled.Value;
bool newState = !oldState; bool newState = !oldState;
switch (await DiscordAppSettings.ConfigureDevTools(newState)) { switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
case SettingsJsonResult.Success: case SettingsJsonResult.Success:
AreDevToolsEnabled = newState; AreDevToolsEnabled = newState;
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect."); await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
break; break;
case SettingsJsonResult.AlreadySet: case SettingsJsonResult.AlreadySet:
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled.")); await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
AreDevToolsEnabled = newState; AreDevToolsEnabled = newState;
break; break;
case SettingsJsonResult.FileNotFound: case SettingsJsonResult.FileNotFound:
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
case SettingsJsonResult.ReadError: case SettingsJsonResult.ReadError:
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
case SettingsJsonResult.InvalidJson: case SettingsJsonResult.InvalidJson:
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
case SettingsJsonResult.WriteError: case SettingsJsonResult.WriteError:
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
default: default:
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
} }
} }
public async Task OnClickInstallOrUpdateUserscript() {
try {
SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/get-userscript/dht.user.js");
} catch (Exception e) {
await Dialog.ShowOk(window, "Install or Update Userscript", "Could not open the browser: " + e.Message);
}
}
[GeneratedRegex("^[a-zA-Z0-9]{1,100}$")]
private static partial Regex ConnectionCodeTokenRegex();
public async Task<bool> OnClickCopyConnectionCode() {
const string Title = "Copy Connection Code";
if (ConnectionCodeTokenRegex().IsMatch(ServerConfiguration.Token)) {
return await TryCopy(ServerConfiguration.Port + ":" + ServerConfiguration.Token, Title);
}
else {
await Dialog.ShowOk(window, Title, "The internal server token cannot be used to create a connection code.\n\nCheck the 'Advanced' tab and ensure the token is 1-100 characters long, and only contains plain letters and numbers.");
return false;
}
}
private async Task<bool> TryCopy(string script, string errorDialogTitle) {
IClipboard? clipboard = window.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, errorDialogTitle, "Clipboard is not available on this system.");
return false;
}
try {
await clipboard.SetTextAsync(script);
return true;
} catch (Exception e) {
Log.Error("Could not copy to clipboard.", e);
await Dialog.ShowOk(window, errorDialogTitle, "An error occurred while copying to clipboard.");
return false;
}
}
} }

View File

@@ -1,10 +1,8 @@
using System; using System;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
@@ -13,71 +11,91 @@ using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Service.Viewer; using DHT.Server.Service.Viewer;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed partial class ViewerPageModel : ObservableObject, IDisposable { sealed partial class ViewerPageModel : IDisposable {
private static readonly Log Log = Log.ForType<ViewerPageModel>();
public bool DatabaseToolFilterModeKeep { get; set; } = true; public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false; public bool DatabaseToolFilterModeRemove { get; set; } = false;
[ObservableProperty] [Notify]
private bool hasFilters = false; private bool hasFilters = false;
public MessageFilterPanelModel FilterModel { get; } public MessageFilterPanelModel FilterModel { get; }
private readonly Window window; private readonly Window window;
private readonly State state; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public ViewerPageModel() : this(null!, State.Dummy) {} public ViewerPageModel() : this(null!, State.Dummy) {}
public ViewerPageModel(Window window, State state) { public ViewerPageModel(Window window, State state) {
this.window = window; this.window = window;
this.state = state; this.state = state;
FilterModel = new MessageFilterPanelModel(window, state, "Will export"); FilterModel = new MessageFilterPanelModel(window, state, "Will export");
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged; FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
} }
public void Dispose() { public void Dispose() {
FilterModel.Dispose(); FilterModel.Dispose();
} }
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
HasFilters = FilterModel.HasAnyFilters; HasFilters = FilterModel.HasAnyFilters;
} }
public async void OnClickOpenViewer() { public async void OnClickOpenViewer() {
try { try {
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
string serverToken = ServerConfiguration.Token; string serverToken = ServerConfiguration.Token;
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString(); string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)) {
UseShellExecute = true
});
} catch (Exception e) { } catch (Exception e) {
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message); await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
} }
} }
public async Task OnClickApplyFiltersToDatabase() { public async Task OnClickApplyFiltersToDatabase() {
var filter = FilterModel.CreateFilter(); try {
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter)); MessageFilter filter = FilterModel.CreateFilter();
long messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) { if (DatabaseToolFilterModeKeep) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching); if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
}
} }
} else if (DatabaseToolFilterModeRemove) {
else if (DatabaseToolFilterModeRemove) { if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) { await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching); }
} }
} catch (Exception e) {
Log.Error("Could not apply filters.", e);
await Dialog.ShowOk(window, "Apply Filters", "Could not apply filters: " + e.Message);
} }
} }
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) { private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode)); await ProgressDialog.Show(window, "Apply Filters", async (_, callback) => {
await callback.UpdateIndeterminate("Removing messages...");
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
await callback.UpdateIndeterminate("Cleaning up attachments...");
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
await callback.UpdateIndeterminate("Cleaning up users...");
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
await callback.UpdateIndeterminate("Cleaning up channels...");
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
await callback.UpdateIndeterminate("Cleaning up servers...");
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
});
} }
} }

View File

@@ -40,13 +40,13 @@
<Style Selector="TabItem:pointerover /template/ Border"> <Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#455785" /> <Setter Property="Background" Value="#455785" />
</Style> </Style>
<Style Selector="TabItem:pointerover > TextBlock"> <Style Selector="TabItem:pointerover /template/ ContentPresenter">
<Setter Property="Foreground" Value="#E9E9E9" /> <Setter Property="Foreground" Value="#E9E9E9" />
</Style> </Style>
<Style Selector="TabItem:selected:pointerover /template/ Border"> <Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" /> <Setter Property="Background" Value="#FFFFFF" />
</Style> </Style>
<Style Selector="TabItem:selected:pointerover > TextBlock"> <Style Selector="TabItem:selected:pointerover /template/ ContentPresenter">
<Setter Property="Foreground" Value="#1A2234" /> <Setter Property="Foreground" Value="#1A2234" />
</Style> </Style>
<Style Selector="TabItem:selected"> <Style Selector="TabItem:selected">
@@ -56,9 +56,11 @@
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe"> <Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" /> <Setter Property="IsVisible" Value="False" />
</Style> </Style>
<Style Selector="TabItem:disabled > TextBlock"> <Style Selector="TabItem:disabled /template/ ContentPresenter">
<Setter Property="Foreground" Value="#B2B2B2" /> <Setter Property="Foreground" Value="#B2B2B2" />
<Setter Property="TextDecorations" Value="Strikethrough" /> </Style>
<Style Selector="TabItem:disabled /template/ ContentPresenter">
<Setter Property="AccessText.TextDecorations" Value="Strikethrough" />
</Style> </Style>
<Style Selector="TabItem.first"> <Style Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" /> <Setter Property="Margin" Value="0 13 0 0" />

View File

@@ -10,72 +10,72 @@ namespace DHT.Desktop.Main.Screens;
sealed class MainContentScreenModel : IAsyncDisposable { sealed class MainContentScreenModel : IAsyncDisposable {
public DatabasePage DatabasePage { get; } public DatabasePage DatabasePage { get; }
private DatabasePageModel DatabasePageModel { get; } private DatabasePageModel DatabasePageModel { get; }
public TrackingPage TrackingPage { get; } public TrackingPage TrackingPage { get; }
private TrackingPageModel TrackingPageModel { get; } private TrackingPageModel TrackingPageModel { get; }
public DownloadsPage DownloadsPage { get; } public DownloadsPage DownloadsPage { get; }
private DownloadsPageModel DownloadsPageModel { get; } private DownloadsPageModel DownloadsPageModel { get; }
public ViewerPage ViewerPage { get; } public ViewerPage ViewerPage { get; }
private ViewerPageModel ViewerPageModel { get; } private ViewerPageModel ViewerPageModel { get; }
public AdvancedPage AdvancedPage { get; } public AdvancedPage AdvancedPage { get; }
private AdvancedPageModel AdvancedPageModel { get; } private AdvancedPageModel AdvancedPageModel { get; }
public DebugPage? DebugPage { get; } public DebugPage? DebugPage { get; }
#if DEBUG #if DEBUG
public bool HasDebugPage => true; public bool HasDebugPage => true;
private DebugPageModel DebugPageModel { get; } private DebugPageModel DebugPageModel { get; }
#else #else
public bool HasDebugPage => false; public bool HasDebugPage => false;
#endif #endif
public StatusBarModel StatusBarModel { get; } public StatusBarModel StatusBarModel { get; }
public event EventHandler? DatabaseClosed { public event EventHandler? DatabaseClosed {
add { add => DatabasePageModel.DatabaseClosed += value;
DatabasePageModel.DatabaseClosed += value; remove => DatabasePageModel.DatabaseClosed -= value;
}
remove {
DatabasePageModel.DatabaseClosed -= value;
}
} }
[Obsolete("Designer")] [Obsolete("Designer")]
public MainContentScreenModel() : this(null!, State.Dummy) {} public MainContentScreenModel() : this(null!, State.Dummy) {}
public MainContentScreenModel(Window window, State state) { public MainContentScreenModel(Window window, State state) {
DatabasePageModel = new DatabasePageModel(window, state); DatabasePageModel = new DatabasePageModel(window, state);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel }; DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window); TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel }; TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
DownloadsPageModel = new DownloadsPageModel(state); DownloadsPageModel = new DownloadsPageModel(window, state);
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel }; DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
ViewerPageModel = new ViewerPageModel(window, state); ViewerPageModel = new ViewerPageModel(window, state);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel }; ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, state); AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel }; AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG #if DEBUG
DebugPageModel = new DebugPageModel(window, state); DebugPageModel = new DebugPageModel(window, state);
DebugPage = new DebugPage { DataContext = DebugPageModel }; DebugPage = new DebugPage { DataContext = DebugPageModel };
#else #else
DebugPage = null; DebugPage = null;
#endif #endif
StatusBarModel = new StatusBarModel(state); StatusBarModel = new StatusBarModel(state);
} }
public async Task Initialize() { public async Task Initialize() {
await DownloadsPageModel.Initialize(); await DownloadsPageModel.Initialize();
#if DEBUG
await DebugPageModel.Initialize();
#endif
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
await DownloadsPageModel.DisposeAsync(); await DownloadsPageModel.DisposeAsync();
ViewerPageModel.Dispose(); ViewerPageModel.Dispose();

View File

@@ -22,20 +22,21 @@
<Setter Property="HorizontalAlignment" Value="Center" /> <Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Button"> <Style Selector="Grid#ButtonPanel > Button">
<Setter Property="Margin" Value="5 0" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<Panel Name="RootPanel"> <Panel Name="RootPanel">
<StackPanel Margin="42"> <StackPanel Margin="42 30">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" /> <TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button> <Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
<Button Command="{Binding ShowAboutDialog}">About</Button> <Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
<Button Command="{Binding Exit}">Exit</Button> <Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
</StackPanel> <Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
</Grid>
</StackPanel> </StackPanel>
</Panel> </Panel>
</UserControl> </UserControl>

View File

@@ -1,41 +1,47 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Data.Settings; using DHT.Desktop.Main.Dialogs;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Sqlite.Schema; using DHT.Server.Database.Sqlite.Schema;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens;
sealed partial class WelcomeScreenModel : ObservableObject { sealed partial class WelcomeScreenModel {
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
public string Version => Program.Version; public string Version => Program.Version;
[ObservableProperty(Setter = Access.Private)] [Notify(Setter.Private)]
private bool isOpenOrCreateDatabaseButtonEnabled = true; private bool isOpenOrCreateDatabaseButtonEnabled = true;
public event EventHandler<IDatabaseFile>? DatabaseSelected; public event EventHandler<IDatabaseFile>? DatabaseSelected;
private readonly Window window; private readonly Window window;
private string? dbFilePath; private string? dbFilePath;
[Obsolete("Designer")] [Obsolete("Designer")]
public WelcomeScreenModel() : this(null!) {} public WelcomeScreenModel() : this(null!) {}
public WelcomeScreenModel(Window window) { public WelcomeScreenModel(Window window) {
this.window = window; this.window = window;
} }
public async Task OpenOrCreateDatabase() { public async Task OpenOrCreateDatabase() {
IsOpenOrCreateDatabaseButtonEnabled = false; IsOpenOrCreateDatabaseButtonEnabled = false;
try { try {
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath)); string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) { if (path != null) {
await OpenOrCreateDatabaseFromPath(path); await OpenOrCreateDatabaseFromPath(path);
} }
@@ -43,35 +49,30 @@ sealed partial class WelcomeScreenModel : ObservableObject {
IsOpenOrCreateDatabaseButtonEnabled = true; IsOpenOrCreateDatabaseButtonEnabled = true;
} }
} }
public async Task OpenOrCreateDatabaseFromPath(string path) { public async Task OpenOrCreateDatabaseFromPath(string path) {
dbFilePath = path; dbFilePath = path;
bool isNew = !File.Exists(path); IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
if (db != null) {
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window)); DatabaseSelected?.Invoke(this, db);
if (db == null) {
return;
} }
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
}
DatabaseSelected?.Invoke(this, db);
} }
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks { private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
private readonly Window window; public async Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
var model = new NewDatabaseSettingsDialogModel();
public SchemaUpgradeCallbacks(Window window) {
this.window = window; var dialog = new NewDatabaseSettingsDialog { DataContext = model };
await dialog.ShowDialog(window);
return new InitialDatabaseSettings(model.SeparateFileForDownloads, model.DownloadsAutoStart);
} }
public async Task<bool> CanUpgrade() { public async Task<bool> CanUpgrade() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window); return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
} }
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) { public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) { async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
var reporter = new ProgressReporter(versionSteps, callbacks); var reporter = new ProgressReporter(versionSteps, callbacks);
@@ -83,33 +84,25 @@ sealed partial class WelcomeScreenModel : ObservableObject {
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window); await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
} }
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter { private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
private readonly IReadOnlyList<IProgressCallback> callbacks;
private readonly int versionSteps;
private int versionProgress = 0; private int versionProgress = 0;
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
this.callbacks = callbacks;
this.versionSteps = versionSteps;
}
public async Task NextVersion() { public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps); await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0); await HideChildren(0);
} }
public async Task MainWork(string message, int finishedItems, int totalItems) { public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems); await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1); await HideChildren(1);
} }
public async Task SubWork(string message, int finishedItems, int totalItems) { public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems); await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2); await HideChildren(2);
} }
private async Task HideChildren(int parentIndex) { private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) { for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide(); await callbacks[i].Hide();
@@ -117,11 +110,52 @@ sealed partial class WelcomeScreenModel : ObservableObject {
} }
} }
} }
public async Task CheckUpdates() {
string response;
try {
response = await ProgressDialog.ShowIndeterminate<string>(window, "Check Updates", "Checking for updates...", static async _ => {
var client = new HttpClient(new SocketsHttpHandler {
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
UseCookies = false,
});
client.Timeout = TimeSpan.FromSeconds(30);
client.MaxResponseContentBufferSize = 1024;
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
return await client.GetStringAsync(Program.Website + "/version");
});
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
return;
} catch (Exception e) {
Log.Error("Could not check for updates.", e);
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
return;
}
if (!System.Version.TryParse(response, out Version? latestVersion)) {
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
return;
}
if (Program.AssemblyVersion >= latestVersion) {
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
return;
}
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
SystemUtils.OpenUrl(Program.Website);
Exit();
}
}
public async Task ShowAboutDialog() { public async Task ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window); await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
} }
public void Exit() { public void Exit() {
window.Close(); window.Close();
} }

View File

@@ -2,6 +2,7 @@
using System.Globalization; using System.Globalization;
using System.Reflection; using System.Reflection;
using Avalonia; using Avalonia;
using DHT.Desktop.Common;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Resources; using DHT.Utils.Resources;
@@ -9,33 +10,44 @@ namespace DHT.Desktop;
static class Program { static class Program {
public static string Version { get; } public static string Version { get; }
public static Version AssemblyVersion { get; }
public static CultureInfo Culture { get; } public static CultureInfo Culture { get; }
public static ResourceLoader Resources { get; } public static ResourceLoader Resources { get; }
public static Arguments Arguments { get; } public static Arguments Arguments { get; }
public const string Website = "https://dht.chylex.com";
static Program() { static Program() {
var assembly = Assembly.GetExecutingAssembly(); var assembly = Assembly.GetExecutingAssembly();
Version = assembly.GetName().Version?.ToString() ?? ""; AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 0);
while (Version.EndsWith(".0")) { Version = VersionToString(AssemblyVersion);
Version = Version[..^2];
}
Culture = CultureInfo.CurrentCulture; Culture = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
Resources = new ResourceLoader(assembly); Resources = new ResourceLoader(assembly);
Arguments = new Arguments(Environment.GetCommandLineArgs()); Arguments = new Arguments(Environment.GetCommandLineArgs());
} }
public static string VersionToString(Version version) {
string versionStr = version.ToString();
while (versionStr.EndsWith(".0")) {
versionStr = versionStr[..^2];
}
return versionStr;
}
public static void Main(string[] args) { public static void Main(string[] args) {
if (Arguments.Console && OperatingSystem.IsWindows()) { if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.AllocConsole(); WindowsConsole.AllocConsole();
} }
try { try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} finally { } finally {
@@ -44,8 +56,10 @@ static class Program {
} }
} }
} }
private static AppBuilder BuildAvaloniaApp() { private static AppBuilder BuildAvaloniaApp() {
AvaloniaReflection.Check();
return AppBuilder.Configure<App>() return AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.WithInterFont() .WithInterFont()

View File

@@ -3,6 +3,8 @@ using DHT.Server.Service;
namespace DHT.Desktop.Server; namespace DHT.Desktop.Server;
static class ServerConfiguration { static class ServerConfiguration {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000); public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(min: 50000, max: 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20); public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
public static string HttpHost => "http://127.0.0.1:" + Port;
} }

View File

@@ -1,8 +1,8 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<LangVersion>12</LangVersion> <LangVersion>13</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -21,12 +21,13 @@
<PropertyGroup> <PropertyGroup>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings> <SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<PublishTrimmed>true</PublishTrimmed> <PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode> <TrimMode>full</TrimMode>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization> <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding> <EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<EventSourceSupport>false</EventSourceSupport> <EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport> <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault> <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<XmlResolverIsNetworkingEnabledByDefault>false</XmlResolverIsNetworkingEnabledByDefault>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
@@ -39,11 +40,12 @@
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType> <DebugType>none</DebugType>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput> <UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath> <ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -10,11 +10,16 @@
return; return;
} }
/*[IMPORTS]*/
if (!DISCORD.isCompatible()) {
alert("Discord History Tracker is not compatible with this version of Discord.");
return;
}
window.DHT_LOADED = true; window.DHT_LOADED = true;
window.DHT_ON_UNLOAD = []; window.DHT_ON_UNLOAD = [];
/*[IMPORTS]*/
const port = 0; /*[PORT]*/ const port = 0; /*[PORT]*/
const token = "/*[TOKEN]*/"; const token = "/*[TOKEN]*/";
STATE.setup(port, token); STATE.setup(port, token);
@@ -46,7 +51,7 @@
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING; return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
}; };
const onTrackingContinued = function(anyNewMessages) { const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
if (!STATE.isTracking()) { if (!STATE.isTracking()) {
return; return;
} }
@@ -63,7 +68,7 @@
if (SETTINGS.autoscroll) { if (SETTINGS.autoscroll) {
let action = null; let action = null;
if (!DISCORD.hasMoreMessages()) { if (!hasMoreBefore) {
console.debug("[DHT] Reached first message."); console.debug("[DHT] Reached first message.");
action = SETTINGS.afterFirstMsg; action = SETTINGS.afterFirstMsg;
} }
@@ -84,7 +89,7 @@
let waitUntilSendingFinishedTimer = null; let waitUntilSendingFinishedTimer = null;
const onMessagesUpdated = async messages => { const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
if (!STATE.isTracking() || delayedStopRequests > 0) { if (!STATE.isTracking() || delayedStopRequests > 0) {
return; return;
} }
@@ -94,24 +99,16 @@
waitUntilSendingFinishedTimer = window.setTimeout(() => { waitUntilSendingFinishedTimer = window.setTimeout(() => {
waitUntilSendingFinishedTimer = null; waitUntilSendingFinishedTimer = null;
onMessagesUpdated(messages); onMessagesUpdated(server, channel, messages, hasMoreBefore);
}, 100); }, 100);
return; return;
} }
const info = DISCORD.getSelectedChannel();
if (!info) {
GUI.setStatus("Error (Unknown Channel)");
stopTrackingDelayed();
return;
}
isSending = true; isSending = true;
try { try {
await STATE.addDiscordChannel(info.server, info.channel); await STATE.addDiscordChannel(server, channel);
} catch (e) { } catch (e) {
onError(e); onError(e);
return; return;
@@ -120,35 +117,33 @@
try { try {
if (!messages.length) { if (!messages.length) {
isSending = false; isSending = false;
onTrackingContinued(false); onTrackingContinued(false, hasMoreBefore);
} }
else { else {
const anyNewMessages = await STATE.addDiscordMessages(messages); const anyNewMessages = await STATE.addDiscordMessages(messages);
onTrackingContinued(anyNewMessages); onTrackingContinued(anyNewMessages, hasMoreBefore);
} }
} catch (e) { } catch (e) {
onError(e); onError(e);
} }
}; };
DISCORD.setupMessageCallback(onMessagesUpdated); const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
STATE.onTrackingStateChanged(enabled => { STATE.onTrackingStateChanged(enabled => {
if (enabled) { if (enabled) {
const messages = DISCORD.getMessages();
if (messages.length === 0) {
stopTrackingDelayed(() => alert("Cannot see any messages."));
return;
}
GUI.setStatus("Starting"); GUI.setStatus("Starting");
GUI.createTrackingStyles();
hasJustStarted = true; hasJustStarted = true;
// noinspection JSIgnoredPromiseFromCall
onMessagesUpdated(messages); if (!starter()) {
stopTrackingDelayed(() => alert("Cannot see any messages."));
hasJustStarted = false;
}
} }
else { else {
isSending = false; isSending = false;
GUI.deleteTrackingStyles();
} }
}); });

View File

@@ -0,0 +1,273 @@
// ==UserScript==
// @name Discord History Tracker
// @license MIT
// @namespace https://chylex.com
// @homepageURL https://dht.chylex.com/
// @supportURL https://github.com/chylex/Discord-History-Tracker/issues
// @include https://discord.com/*
// @run-at document-idle
// @grant none
// @noframes
// ==/UserScript==
function startMutationObserver(callback) {
let hasDebounceStarted = false;
function handleMutations(mutations, observer) {
if (hasDebounceStarted) {
return;
}
hasDebounceStarted = true;
window.setTimeout(() => {
hasDebounceStarted = false;
callback(observer);
}, 20);
}
new MutationObserver(handleMutations).observe(document.body, { childList: true, subtree: true });
}
startMutationObserver(observer => {
const triggerButton = installConnectDialogButton();
if (triggerButton === null) {
return;
}
observer.disconnect();
startMutationObserver(() => {
if (!triggerButton.isConnected) {
getHelpIcon()?.parentElement.parentElement.insertAdjacentElement("afterend", triggerButton);
}
});
});
function installConnectDialogButton() {
const helpIcon = getHelpIcon();
if (!helpIcon) {
return null;
}
const helpIconWrapper = helpIcon.parentElement;
const helpIconLink = helpIconWrapper.parentElement;
helpIconLink.insertAdjacentHTML("afterend", `
<div id="dht-connector-show-dialog" role="button" class="${helpIconWrapper.getAttribute("class")}">
<svg viewBox="0 0 24 20" fill="currentColor" class="${helpIcon.getAttribute("class")}" style="width: auto;">
<path d="M7.446,9.924c-0,0.846 -0.105,1.593 -0.315,2.234c-0.21,0.644 -0.497,1.181 -0.86,1.615c-0.365,0.431 -0.795,0.761 -1.292,0.982c-0.494,0.22 -1.029,0.332 -1.605,0.332l-3.374,-0l0,-10.174l3.021,0c0.645,0 1.239,0.1 1.781,0.296c0.542,0.198 1.011,0.501 1.4,0.911c0.391,0.408 0.695,0.928 0.914,1.56c0.22,0.629 0.33,1.378 0.33,2.244Zm-1.765,-0c0,-0.592 -0.067,-1.1 -0.198,-1.524c-0.134,-0.422 -0.318,-0.77 -0.554,-1.042c-0.236,-0.272 -0.518,-0.473 -0.848,-0.604c-0.332,-0.128 -0.697,-0.195 -1.096,-0.195l-1.237,-0l0,6.882l1.483,0c0.351,0 0.676,-0.076 0.977,-0.224c0.298,-0.15 0.556,-0.372 0.776,-0.668c0.22,-0.295 0.389,-0.663 0.513,-1.101c0.122,-0.439 0.184,-0.947 0.184,-1.524Z"/>
<path d="M14.147,15.087l-0,-4.362l-3.637,-0l-0,4.362l-1.748,-0l-0,-10.174l1.748,0l-0,4.052l3.637,-0l-0,-4.052l1.75,0l0,10.174l-1.75,-0Z"/>
<path d="M21.297,6.559l-0,8.528l-1.748,-0l-0,-8.528l-2.699,-0l0,-1.646l7.15,0l0,1.646l-2.703,-0Z"/>
</svg>
</div>
`);
const button = document.getElementById("dht-connector-show-dialog");
button.addEventListener("click", showConnectDialog);
return button;
}
function getHelpIcon() {
return document.querySelector("div[class*='bar_'] a[href$='://support.discord.com'] > div[class*='clickable'] > svg");
}
function showConnectDialog() {
if (window.DHT_LOADED) {
alert("Discord History Tracker is already loaded.");
return;
}
const dialogElement = document.createElement("dialog");
dialogElement.id = "dht-connector-dialog";
dialogElement.innerHTML = `
<form id="dht-connector-dialog-form">
<label for="dht-connector-dialog-input-code">Connection Code</label>
<input id="dht-connector-dialog-input-code" type="text">
<p id="dht-connector-dialog-input-error"></p>
<div id="dht-connector-dialog-buttons">
<button type="submit" id="dht-connector-dialog-button-connect">Connect</button>
<button type="button" id="dht-connector-dialog-button-close">Cancel</button>
</div>
</form>
<style>
#dht-connector-dialog {
width: 300px;
padding: 18px;
font-size: 16px;
border: none;
border-radius: 8px;
background-color: #313338;
}
#dht-connector-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.6);
}
#dht-connector-dialog label {
display: block;
margin: 0 0 9px;
font-weight: 500;
letter-spacing: .02em;
color: #fbfbfb;
}
#dht-connector-dialog-input-error {
margin: 11px 1px;
color: #ff484c;
font-size: 15px;
font-weight: 500;
}
#dht-connector-dialog input[type="text"] {
box-sizing: border-box;
width: 100%;
padding: 12px 10px;
border: 1px solid rgba(151, 151, 159, 0.2);
border-radius: 8px;
color: #efeff0;
background-color: #1d1d21;
}
#dht-connector-dialog-buttons {
margin-top: 15px;
display: flex;
justify-content: end;
gap: 12px;
}
#dht-connector-dialog button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: #fff;
background-color: #5865f2;
transition: background-color 200ms ease, color 200ms ease;
}
#dht-connector-dialog button:hover {
background-color: #4752c4;
}
#dht-connector-dialog button[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
</style>
`;
document.body.insertAdjacentElement("beforeend", dialogElement);
const formElement = document.getElementById("dht-connector-dialog-form");
const codeErrorElement = document.getElementById("dht-connector-dialog-input-error");
const codeInputElement = document.getElementById("dht-connector-dialog-input-code");
const connectButtonElement = document.getElementById("dht-connector-dialog-button-connect");
const closeButtonElement = document.getElementById("dht-connector-dialog-button-close");
dialogElement.addEventListener("close", function() {
dialogElement.remove();
});
closeButtonElement.addEventListener("click", function() {
dialogElement.close();
});
codeInputElement.addEventListener("paste", function() {
setTimeout(async function() {
const code = parseConnectionCode(codeInputElement.value);
if (code !== null) {
await onSubmit(code);
}
}, 0);
});
formElement.addEventListener("submit", async function(e) {
e.preventDefault();
const code = parseConnectionCode(codeInputElement.value);
if (code === null) {
codeErrorElement.innerText = "Code is not valid.";
}
else {
await onSubmit(code);
}
});
let isSubmitting = false;
async function onSubmit(code) {
if (isSubmitting) {
return;
}
isSubmitting = true;
connectButtonElement.setAttribute("disabled", "");
try {
await loadTrackingScript(code);
} catch (e) {
onError("Could not load tracking script.", e);
} finally {
isSubmitting = false;
connectButtonElement.removeAttribute("disabled");
}
}
async function loadTrackingScript(code) {
const trackingScript = await getTrackingScript(code.port, code.token);
if (dialogElement.isConnected) {
// noinspection DynamicallyGeneratedCodeJS
eval(trackingScript);
dialogElement.close();
}
}
function onError(message, e) {
console.error(message, e);
codeErrorElement.innerText = message;
}
dialogElement.showModal();
}
/**
* @param {string} code
* @return {?{port: number, token: string}}
*/
function parseConnectionCode(code) {
if (code.length > 5 + 1 + 100) {
return null;
}
const match = code.match(/^(\d{1,5}):([a-zA-Z0-9]{1,100})$/);
if (!match) {
return null;
}
const port = Number(match[1]);
if (port < 0 || port > 65535) {
return null;
}
return { port, token: match[2] };
}
async function getTrackingScript(port, token) {
const url = "http://127.0.0.1:" + port + "/get-tracking-script?token=" + encodeURIComponent(token);
const response = await fetch(url, {
credentials: "omit",
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
throw response.status + " " + response.statusText;
}
if (response.headers.get("X-DHT") !== "1") {
throw "Invalid response";
}
return await response.text();
}

View File

@@ -4,11 +4,26 @@ class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types // https://discord.com/developers/docs/resources/channel#channel-object-channel-types
static CHANNEL_TYPE = { static CHANNEL_TYPE = {
GUILD_TEXT: 0,
DM: 1, DM: 1,
GROUP_DM: 3, GROUP_DM: 3,
GUILD_ANNOUNCEMENT: 5,
ANNOUNCEMENT_THREAD: 10, ANNOUNCEMENT_THREAD: 10,
PUBLIC_THREAD: 11, PUBLIC_THREAD: 11,
PRIVATE_THREAD: 12 PRIVATE_THREAD: 12,
isPrivate(type) {
return type === this.DM
|| type === this.GROUP_DM;
},
isNavigableGuildChannel(type) {
return type === this.GUILD_TEXT
|| type === this.GUILD_ANNOUNCEMENT
|| type === this.ANNOUNCEMENT_THREAD
|| type === this.PUBLIC_THREAD
|| type === this.PRIVATE_THREAD;
}
}; };
// https://discord.com/developers/docs/resources/channel#message-object-message-types // https://discord.com/developers/docs/resources/channel#message-object-message-types
@@ -18,6 +33,74 @@ class DISCORD {
THREAD_STARTER: 21 THREAD_STARTER: 21
}; };
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
static PERMISSION = {
VIEW_CHANNEL: 1n << 10n
};
/**
* @type {Object}
* @property {function(String): ?DiscordGuild} getGuild
*/
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
/**
* @type {Object}
* @property {function(String): Boolean} isOptInEnabled
* @property {function(String): Set<String>} getOptedInChannels
*/
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
/**
* @type {Object}
* @property {function(String): ?DiscordChannel} getChannel
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
*/
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
/**
* @type {function(BigInt, Object): Boolean}
*/
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
/**
* @type {function(String): MessageData}
*/
static #getMessages = WEBPACK.findFunction("getMessages", [ "isLoadingMessages" ]);
/**
* @type {function(String): void}
*/
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
/**
* @type {function(): String}
*/
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
/**
* @type {function(String): void}
*/
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
/**
* @type {function(String, Object, String=null): void}
*/
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
static isCompatible() {
return !!this.#guildStore
&& !!this.#guildSettings
&& !!this.#channelStore
&& !!this.#hasPermission
&& !!this.#getMessages
&& !!this.#jumpToMessage
&& !!this.#getCurrentlySelectedChannelId
&& !!this.#selectPrivateChannel
&& !!this.#transitionToGuildSync;
}
static getMessageOuterElement() { static getMessageOuterElement() {
return DOM.queryReactClass("messagesWrapper"); return DOM.queryReactClass("messagesWrapper");
} }
@@ -26,14 +109,6 @@ class DISCORD {
return DOM.queryReactClass("scroller", this.getMessageOuterElement()); return DOM.queryReactClass("scroller", this.getMessageOuterElement());
} }
static getMessageElements() {
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
}
static hasMoreMessages() {
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
}
static loadOlderMessages() { static loadOlderMessages() {
const view = this.getMessageScrollerElement(); const view = this.getMessageScrollerElement();
@@ -42,26 +117,43 @@ class DISCORD {
} }
} }
static getMessagesFromSelectedChannel() {
const channelId = this.#getCurrentlySelectedChannelId();
return channelId ? this.#getMessages(channelId) : null;
}
/** /**
* Calls the provided function with a list of messages whenever the currently loaded messages change. * Calls the provided function with a list of messages whenever the currently loaded messages change.
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
*/ */
static setupMessageCallback(callback) { static setupMessageCallback(callback) {
const previousMessages = new Set(); const previousMessages = new Set();
const onMessageElementsChanged = function() { const onMessageElementsChanged = force => {
const messages = DISCORD.getMessages(); const messages = this.getMessagesFromSelectedChannel();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages(); if (!messages || !messages.ready || messages.loadingMore) {
return false;
}
const channel = this.#channelStore.getChannel(messages.channelId);
if (!channel) {
return false;
}
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
if (!hasChanged) { if (!hasChanged) {
return; return false;
} }
previousMessages.clear(); previousMessages.clear();
for (const message of messages) { for (const message of messages._array) {
previousMessages.add(message.id); previousMessages.add(message.id);
} }
callback(messages); const server = this.#guildStore.getGuild(channel.guild_id);
callback(server, channel, messages._array, messages.hasMoreBefore);
return true;
}; };
let debounceTimer; let debounceTimer;
@@ -74,7 +166,7 @@ class DISCORD {
debounceTimer = window.setTimeout(onMessageElementsChanged, 100); debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
}; };
const observer = new MutationObserver(function () { const observer = new MutationObserver(function() {
onMessageElementsChangedLater(); onMessageElementsChangedLater();
}); });
@@ -112,216 +204,54 @@ class DISCORD {
observedElement = null; observedElement = null;
window.clearInterval(observerTimer); window.clearInterval(observerTimer);
}); });
}
/**
* Returns the message from a message element.
* @returns { null | DiscordMessage } }
*/
static getMessageFromElement(ele) {
const props = DOM.getReactProps(ele);
if (props && Array.isArray(props.children)) { return () => onMessageElementsChanged(true);
for (const child of props.children) {
if (!(child instanceof Object)) {
continue;
}
const childProps = child.props;
if (childProps instanceof Object && "message" in childProps) {
return childProps.message;
}
}
}
return null;
}
/**
* Returns an array containing currently loaded messages.
*/
static getMessages() {
try {
const messages = [];
for (const ele of this.getMessageElements()) {
try {
const message = this.getMessageFromElement(ele);
if (message != null) {
messages.push(message);
}
} catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
}
}
return messages;
} catch (e) {
console.error("[DHT] Error retrieving messages.", e);
return [];
}
}
/**
* Returns an object containing the selected server and channel information.
* For types DM and GROUP, the server and channel ids and names are identical.
* @returns { {} | null }
*/
static getSelectedChannel() {
try {
let obj = null;
try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
if (child && child.props && child.props.channel) {
obj = child.props.channel;
break;
}
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
}
if (!obj || typeof obj.id !== "string") {
return null;
}
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
let name;
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) {
name = node.nodeValue;
break;
}
}
if (!name) {
return null;
}
let type;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) {
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
default: return null;
}
const id = obj.id;
const server = { id, name, type };
const channel = { id, name };
return { server, channel };
}
else if (obj.guild_id) {
let guild;
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
if (child && child.props && child.props.guild) {
guild = child.props.guild;
break;
}
}
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
return null;
}
const server = {
"id": guild.id,
"name": guild.name,
"type": "SERVER"
};
const channel = {
"id": obj.id,
"name": obj.name,
"extra": {
"nsfw": obj.nsfw
}
};
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
channel["extra"]["parent"] = obj.parent_id;
}
else {
channel["extra"]["position"] = obj.position;
channel["extra"]["topic"] = obj.topic;
}
return { server, channel };
}
else {
return null;
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel.", e);
return null;
}
} }
/** /**
* Selects the next text channel and returns true, otherwise returns false if there are no more channels. * Selects the next text channel and returns true, otherwise returns false if there are no more channels.
*/ */
static selectNextTextChannel() { static selectNextTextChannel() {
const dms = DOM.queryReactClass("privateChannels"); const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
if (!currentChannel) {
return false;
}
if (dms) { if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
const currentChannel = DOM.queryReactClass("selected", dms); const privateChannel = this.#channelStore.getSortedPrivateChannels();
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']"); const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) { if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
return false; return false;
} }
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']"); this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannelLink.scrollIntoView(true);
return true; return true;
} }
else { else {
const channelListEle = document.getElementById("channels"); const guildId = currentChannel.guild_id;
if (!channelListEle) {
let isChannelOptedIn;
if (this.#guildSettings.isOptInEnabled(guildId)) {
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
isChannelOptedIn = channel => optedInChannels.has(channel.id);
}
else {
isChannelOptedIn = _ => true;
}
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
const guildChannels = Object.values(guildChannelMap)
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
.sort((a, b) => a.position - b.position);
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
return false; return false;
} }
function getLinkElement(channel) { this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
return channel.querySelector("a[href^='/channels/'][role='link']");
}
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
let nextChannel = null;
for (let index = 0; index < allTextChannels.length - 1; index++) {
if (allTextChannels[index].className.includes("selected_")) {
nextChannel = allTextChannels[index + 1];
break;
}
}
if (nextChannel === null) {
return false;
}
const nextChannelLink = getLinkElement(nextChannel);
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannel.scrollIntoView(true);
return true; return true;
} }
} }

View File

@@ -1,6 +1,8 @@
class DOM { class DOM {
/** /**
* Returns a child element by its ID. Parent defaults to the entire document. * Returns a child element by its ID. Parent defaults to the entire document.
* @param {string} id
* @param {HTMLElement?} [parent]
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
static id(id, parent) { static id(id, parent) {
@@ -9,6 +11,9 @@ class DOM {
/** /**
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document. * Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
* @param {string} cls
* @param {HTMLElement?} [parent]
* @returns {HTMLElement}
*/ */
static queryReactClass(cls, parent) { static queryReactClass(cls, parent) {
return (parent || document).querySelector(`[class*="${cls}_"]`); return (parent || document).querySelector(`[class*="${cls}_"]`);
@@ -55,31 +60,4 @@ class DOM {
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1"); const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
return value.length ? JSON.parse(decodeURIComponent(value)) : null; return value.length ? JSON.parse(decodeURIComponent(value)) : null;
} }
/**
* Returns internal React state object of an element.
*/
static getReactProps(ele) {
const keys = Object.keys(ele || {});
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
if (key) {
// noinspection JSUnresolvedVariable
return ele[key].memoizedProps;
}
key = keys.find(key => key.startsWith("__reactProps$"));
return key ? ele[key] : null;
}
/**
* Returns internal React state object of an element, or null if the retrieval throws.
*/
static tryGetReactProps(ele) {
try {
return this.getReactProps(ele);
} catch (e) {
return null;
}
}
} }

View File

@@ -2,14 +2,17 @@
const GUI = (function() { const GUI = (function() {
let controller = null; let controller = null;
let settings = null; let settings = null;
let trackingStyles = null;
const stateChangedEvent = () => { const stateChangedEvent = () => {
if (settings) { if (settings) {
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll; settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true; settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true; settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
const autoscrollDisabled = !SETTINGS.autoscroll; const autoscrollDisabled = !SETTINGS.autoscroll;
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled); Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled); Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
} }
@@ -54,6 +57,7 @@ const GUI = (function() {
controller.ui.btnClose.addEventListener("click", () => { controller.ui.btnClose.addEventListener("click", () => {
this.hideController(); this.hideController();
this.deleteTrackingStyles();
window.DHT_ON_UNLOAD.forEach(f => f()); window.DHT_ON_UNLOAD.forEach(f => f());
delete window.DHT_ON_UNLOAD; delete window.DHT_ON_UNLOAD;
delete window.DHT_LOADED; delete window.DHT_LOADED;
@@ -84,6 +88,7 @@ const GUI = (function() {
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>"; const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
const html = ` const html = `
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br> <label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br>
<br> <br>
<label>After reaching the first message in channel...</label><br> <label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Continue Tracking")} ${radio("afm", "nothing", "Continue Tracking")}
@@ -93,8 +98,7 @@ ${radio("afm", "switch", "Switch to Next Channel")}
<label>After reaching a previously saved message...</label><br> <label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Continue Tracking")} ${radio("asm", "nothing", "Continue Tracking")}
${radio("asm", "pause", "Pause Tracking")} ${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")} ${radio("asm", "switch", "Switch to Next Channel")}`;
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
settings = { settings = {
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`), styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
@@ -107,9 +111,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
}); });
settings.ui = { settings.ui = {
cbAutoscroll: DOM.id("dht-cfg-autoscroll"), /** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
optsAfterFirstMsg: {}, /** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
optsAfterSavedMsg: {} /** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
}; };
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing"); settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
@@ -124,6 +129,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked; SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
}); });
settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
});
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => { Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => { settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
SETTINGS.afterFirstMsg = key; SETTINGS.afterFirstMsg = key;
@@ -152,6 +161,29 @@ ${radio("asm", "switch", "Switch to Next Channel")}
if (controller) { if (controller) {
controller.ui.textStatus.innerText = state; controller.ui.textStatus.innerText = state;
} }
},
createTrackingStyles() {
if (trackingStyles) {
return;
}
let style = "";
if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) {
style += `div[id^="message-accessories-"] { display: none; }`;
}
if (style.length > 0) {
trackingStyles = DOM.createStyle(style);
}
},
deleteTrackingStyles() {
if (trackingStyles) {
DOM.removeElement(trackingStyles);
trackingStyles = null;
}
} }
}; };
})(); })();

View File

@@ -35,17 +35,28 @@ const SETTINGS = (function() {
obj[name] = value; obj[name] = value;
}; };
const defaults = {
"_autoscroll": true,
"_hidePreviewsWhileAutoscrolling": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
};
let loaded = DOM.loadFromCookie("DHT_SETTINGS"); let loaded = DOM.loadFromCookie("DHT_SETTINGS");
let hasChanged = false;
if (!loaded) { if (!loaded) {
loaded = { loaded = defaults;
"_autoscroll": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
};
IS_FIRST_RUN = true; IS_FIRST_RUN = true;
} }
else {
for (const property in defaults) {
if (!(property in loaded)) {
loaded[property] = defaults[property];
hasChanged = true;
}
}
}
const root = { const root = {
onSettingsChanged(callback) { onSettingsChanged(callback) {
@@ -54,10 +65,11 @@ const SETTINGS = (function() {
}; };
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll); defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg); defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg); defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
if (IS_FIRST_RUN) { if (IS_FIRST_RUN || hasChanged) {
saveSettings(); saveSettings();
} }

View File

@@ -58,65 +58,39 @@ const STATE = (function() {
} }
}; };
/**
* @param {DiscordChannel} channel
*/
const getPrivateChannelName = function(channel) {
if (channel.name === "") {
return channel.rawRecipients.map(user => user.username).join(", ");
}
else {
return channel.name;
}
};
/**
* @param {Number} type
*/
const getChannelTypeName = function(type) {
if (type === DISCORD.CHANNEL_TYPE.DM) {
return "DM";
}
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
return "GROUP";
}
else {
return "SERVER";
}
};
const trackingStateChangedListeners = []; const trackingStateChangedListeners = [];
let isTracking = false; let isTracking = false;
const addedChannels = new Set(); const addedChannels = new Set();
const addedUsers = new Set(); const addedUsers = new Set();
/**
* @name DiscordUser
* @property {String} id
* @property {String} username
* @property {String} discriminator
* @property {String} [globalName]
* @property {String} [avatar]
* @property {Boolean} [bot]
*/
/**
* @name DiscordMessage
* @property {String} id
* @property {String} channel_id
* @property {DiscordUser} author
* @property {String} content
* @property {Date} timestamp
* @property {Date|null} editedTimestamp
* @property {DiscordAttachment[]} attachments
* @property {Object[]} embeds
* @property {DiscordMessageReaction[]} [reactions]
* @property {DiscordMessageReference} [messageReference]
* @property {Number} type
* @property {String} state
*/
/**
* @name DiscordAttachment
* @property {String} id
* @property {String} filename
* @property {String} [content_type]
* @property {String} size
* @property {String} url
*/
/**
* @name DiscordMessageReaction
* @property {DiscordEmoji} emoji
* @property {Number} count
*/
/**
* @name DiscordMessageReference
* @property {String} [message_id]
*/
/**
* @name DiscordEmoji
* @property {String|null} id
* @property {String|null} name
* @property {Boolean} animated
*/
return { return {
setup(port, token) { setup(port, token) {
serverPort = port; serverPort = port;
@@ -147,32 +121,59 @@ const STATE = (function() {
} }
}, },
/**
* @param {?DiscordGuild} serverInfo
* @param {DiscordChannel} channelInfo
*/
async addDiscordChannel(serverInfo, channelInfo) { async addDiscordChannel(serverInfo, channelInfo) {
if (addedChannels.has(channelInfo.id)) { if (addedChannels.has(channelInfo.id)) {
return; return;
} }
const server = { const server = {
id: serverInfo.id, type: getChannelTypeName(channelInfo.type)
name: serverInfo.name,
type: serverInfo.type
}; };
const channel = { const channel = {
id: channelInfo.id, id: channelInfo.id,
name: channelInfo.name extra: {}
}; };
if ("extra" in channelInfo) { if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
const extra = channelInfo.extra; server.id = channelInfo.id;
server.name = channel.name = getPrivateChannelName(channelInfo);
if ("parent" in extra) { if (channelInfo.icon) {
channel.parent = extra.parent; server.icon = channelInfo.icon;
} }
}
else if (serverInfo) {
server.id = serverInfo.id;
server.name = serverInfo.name;
channel.name = channelInfo.name;
channel.position = extra.position; if (serverInfo.icon) {
channel.topic = extra.topic; server.icon = serverInfo.icon;
channel.nsfw = extra.nsfw; }
}
else {
return;
}
if ("nsfw" in channelInfo) {
channel.extra.nsfw = channelInfo.nsfw;
}
if ("topic" in channelInfo) {
channel.extra.topic = channelInfo.topic;
}
if ("position" in channelInfo) {
channel.extra.position = channelInfo.position;
}
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
channel.extra.parent = channelInfo.parent_id;
} }
await post("/track-channel", { server, channel }); await post("/track-channel", { server, channel });
@@ -293,7 +294,7 @@ const STATE = (function() {
} }
if (msg.reactions.length > 0) { if (msg.reactions.length > 0) {
obj.reactions = msg.reactions.map(reaction => { obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => {
const emoji = reaction.emoji; const emoji = reaction.emoji;
const mapped = { const mapped = {

View File

@@ -0,0 +1,84 @@
/**
* @name DiscordGuild
* @property {String} id
* @property {String} name
* @property {String|null|undefined} [icon]
*/
/**
* @name DiscordChannel
* @property {String} id
* @property {String} name
* @property {Number} type
* @property {String} [guild_id]
* @property {String} [parent_id]
* @property {Number} [position]
* @property {String} [topic]
* @property {Boolean} [nsfw]
* @property {String|null|undefined} [icon]
* @property {DiscordUser[]} [rawRecipients]
*/
/**
* @name DiscordUser
* @property {String} id
* @property {String} username
* @property {String} discriminator
* @property {String} [globalName]
* @property {String} [avatar]
* @property {Boolean} [bot]
*/
/**
* @name DiscordMessage
* @property {String} id
* @property {String} channel_id
* @property {DiscordUser} author
* @property {String} content
* @property {Date} timestamp
* @property {Date|null} editedTimestamp
* @property {DiscordAttachment[]} attachments
* @property {Object[]} embeds
* @property {DiscordMessageReaction[]} [reactions]
* @property {DiscordMessageReference} [messageReference]
* @property {Number} type
* @property {String} state
*/
/**
* @name DiscordAttachment
* @property {String} id
* @property {String} filename
* @property {String} [content_type]
* @property {String} size
* @property {String} url
*/
/**
* @name DiscordMessageReaction
* @property {DiscordEmoji} emoji
* @property {Number} count
*/
/**
* @name DiscordMessageReference
* @property {String} [message_id]
*/
/**
* @name DiscordEmoji
* @property {String|null} id
* @property {String|null} name
* @property {Boolean} animated
*/
/**
* @name MessageData
* @type {Object}
* @property {String} channelId
* @property {Boolean} ready
* @property {Boolean} loadingMore
* @property {Boolean} hasMoreAfter
* @property {Boolean} hasMoreBefore
* @property {Array<DiscordMessage>} _array
*/

View File

@@ -0,0 +1,100 @@
/**
* Parts copied from Better Discord, licensed under Apache License 2.0.
*
* https://github.com/BetterDiscord/BetterDiscord/blob/2752daf64f98625fc67c569361bd56021307d058/renderer/src/modules/webpackmodules.js
* https://github.com/BetterDiscord/BetterDiscord/blob/2752daf64f98625fc67c569361bd56021307d058/LICENSE
*/
class WEBPACK {
static get require() {
if (this._require) {
return this._require;
}
/**
* @type {Object}
* @property {Object} m
* @property {Object} c
*/
let hookedRequire;
const id = Symbol("dht-webpackmodules-" + new Date().getTime());
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => {
if ("b" in internalRequire) {
hookedRequire = internalRequire;
}
}]);
}
return this._require = hookedRequire;
}
static getAllModules() {
return this.require.c;
}
static filterByProps(...props) {
return module => props.every(prop => prop in module);
}
static filterByPropsWithPredicate(predicate, ...props) {
return module => props.every(prop => prop in module && predicate(module[prop]));
}
static findModules(filter) {
const defaultExport = true;
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
const results = [];
for (const module of Object.values(this.getAllModules())) {
/**
* @type {Object}
* @property [Z]
* @property [ZP]
* @property [__esModule]
* @property [default]
*/
const exports = module.exports;
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
continue;
}
let foundModule = null;
if (exports.Z && moduleFilter(exports.Z)) {
foundModule = defaultExport ? exports.Z : exports;
}
if (exports.ZP && moduleFilter(exports.ZP)) {
foundModule = defaultExport ? exports.ZP : exports;
}
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
foundModule = defaultExport ? exports.default : exports;
}
if (moduleFilter(exports)) {
foundModule = exports;
}
if (foundModule) {
results.push(foundModule);
}
}
return results;
}
static findModule(name, filter) {
const modules = this.findModules(filter);
if (modules.length === 1) {
return modules[0];
}
console.error("[DHT] Cannot find module " + name + ", results found:", modules.length, modules);
return null;
}
static findFunction(name, additionalRequiredProps) {
const searchedProps = additionalRequiredProps ? [ name, ...additionalRequiredProps ] : [ name ];
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof (prop) === "function", ...searchedProps));
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
}
}

View File

@@ -18,11 +18,13 @@
height: 262px; height: 262px;
margin-left: -400px; margin-left: -400px;
margin-top: -131px; margin-top: -131px;
line-height: 120%;
padding: 8px; padding: 8px;
background-color: #fff; background-color: #fff;
z-index: 1000002; z-index: 1000002;
} }
#dht-cfg-note { #dht-cfg label {
margin-top: 22px; display: inline-block;
margin: 1px 0;
} }

View File

@@ -10,6 +10,7 @@
<link rel="stylesheet" href="styles/main.css"> <link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="styles/menu.css"> <link rel="stylesheet" href="styles/menu.css">
<link rel="stylesheet" href="styles/servers.css">
<link rel="stylesheet" href="styles/channels.css"> <link rel="stylesheet" href="styles/channels.css">
<link rel="stylesheet" href="styles/messages.css"> <link rel="stylesheet" href="styles/messages.css">
<link rel="stylesheet" href="styles/modal.css"> <link rel="stylesheet" href="styles/modal.css">
@@ -75,6 +76,7 @@
</div> </div>
<div id="app"> <div id="app">
<div id="servers"></div>
<div id="channels"> <div id="channels">
<div class="loading"></div> <div class="loading"></div>
</div> </div>
@@ -86,4 +88,4 @@
<div id="dialog"></div> <div id="dialog"></div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -2,6 +2,7 @@ import discord from "./discord.mjs";
import gui from "./gui.mjs"; import gui from "./gui.mjs";
import state from "./state.mjs"; import state from "./state.mjs";
import "./polyfills.mjs"; import "./polyfills.mjs";
import servers from "./servers.mjs";
window.DISCORD = discord; window.DISCORD = discord;
@@ -25,10 +26,12 @@ document.addEventListener("DOMContentLoaded", () => {
state.onUsersRefreshed(users => { state.onUsersRefreshed(users => {
gui.updateUserList(users); gui.updateUserList(users);
servers.update()
}); });
state.onChannelsRefreshed((channels, selected) => { state.onChannelsRefreshed((channels, selected) => {
gui.updateChannelList(channels, selected, state.selectChannel); gui.updateChannelList(channels, selected, state.selectChannel);
servers.update()
}); });
state.onMessagesRefreshed(messages => { state.onMessagesRefreshed(messages => {

View File

@@ -113,16 +113,17 @@ export default (function() {
return { return {
setup() { setup() {
templateChannelServer = new template([ templateChannelServer = new template([
"<div class='channel' data-channel='{id}'>", "<div class='channel ServerChannel' data-channel='{id}' server-id='{serverId}' server-name='{server.name}' server-type='{server.type}'>",
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>", "<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
"<span class='server'>{server.name} ({server.type})</span>", "<!--<span class='server'>{server.name} ({server.type})</span>-->",
"</div>" "</div>"
].join("")); ].join(""));
templateChannelPrivate = new template([ templateChannelPrivate = new template([
"<div class='channel' data-channel='{id}'>", "<div class='channel UserChannel' data-channel='{id}' server-id='0' server-name='{server.name}' server-type='{server.type}'>",
"<div class='avatar'>{icon}</div>",
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>", "<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
"<span class='server'>({server.type})</span>", "<!--<span class='server'>{server.name} ({server.type})</span>-->",
"</div>" "</div>"
].join("")); ].join(""));
@@ -166,7 +167,7 @@ export default (function() {
// noinspection HtmlUnknownTarget // noinspection HtmlUnknownTarget
templateEmbedImageWithSize = new template([ templateEmbedImageWithSize = new template([
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>" "<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
].join("")); ].join(""));
// noinspection HtmlUnknownTarget // noinspection HtmlUnknownTarget
@@ -298,13 +299,13 @@ export default (function() {
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents; return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
} }
else if (property === "reactions"){ else if (property === "reactions") {
if (!value){ if (!value) {
return ""; return "";
} }
return "<div class='reactions'>" + value.map(reaction => { return "<div class='reactions'>" + value.map(reaction => {
if ("id" in reaction){ if ("id" in reaction) {
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp"; const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext); const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols

View File

@@ -0,0 +1,109 @@
import state from "./state.mjs";
const servers = (() => {
let currentServerId = "0";
function getIcon(name) {
return name.split(" ").map(word => {
if (word.startsWith("[") && word.length > 1) return word[1];
return word[0] || "";
}).join("");
}
function update() {
const channels = document.querySelectorAll("#channels .channel");
const serversMap = new Map();
// Check if there are any channels with server-id 0 (DM)
const hasDMChannels = Array.from(channels).some(channel => {
return channel.getAttribute("server-id") === "0";
});
if (hasDMChannels) {
serversMap.set("0", {
id: "0",
name: "DM",
icon: "DM",
});
}
channels.forEach(channel => {
const serverId = channel.getAttribute("server-id") || "0";
const serverType = channel.getAttribute("server-type");
const serverName = channel.getAttribute("server-name");
if (serverType === "server" && !serversMap.has(serverId)) {
serversMap.set(serverId, {
id: serverId,
name: serverName,
icon: getIcon(serverName),
});
}
});
const serversDiv = document.getElementById("servers");
serversDiv.innerHTML = "";
if (hasDMChannels) {
const dmServer = serversMap.get("0");
const dmElement = document.createElement("div");
dmElement.className = `Server${dmServer.id === currentServerId ? " active" : ""}`;
dmElement.id = "DM";
dmElement.dataset.serverId = dmServer.id;
dmElement.innerHTML = `
<div class="icon">DM</div>
<div class="name" title="Direct Messages">Direct Messages</div>
`;
dmElement.addEventListener("click", () => selectServer(dmServer.id));
serversDiv.appendChild(dmElement);
}
serversMap.forEach(server => {
if (server.id === "0") return; // Skip DM since it's already added
const serverElement = document.createElement("div");
serverElement.className = `Server${server.id === currentServerId ? " active" : ""}`;
serverElement.dataset.serverId = server.id;
serverElement.innerHTML = `
<div class="icon">${server.icon}</div>
<div class="name" title="${server.name}">${server.name}</div>
`;
serverElement.addEventListener("click", () => selectServer(server.id));
serversDiv.appendChild(serverElement);
});
if (!serversMap.has(currentServerId)) {
currentServerId = "0";
}
updateChannelVisibility();
}
function selectServer(serverId) {
// Remove active class from all servers
document.querySelectorAll("#servers .Server").forEach(server => {
server.classList.remove("active");
});
// Add active class to the selected server
const selectedServer = document.querySelector(`#servers .Server[data-server-id="${serverId}"]`);
if (selectedServer) {
selectedServer.classList.add("active");
}
currentServerId = serverId;
updateChannelVisibility();
state.selectChannel(null);
}
function updateChannelVisibility() {
document.querySelectorAll("#channels .channel").forEach(channel => {
const channelServerId = channel.getAttribute("server-id") || "0";
channel.classList.toggle("visible", channelServerId === currentServerId);
});
}
return { update };
})();
export default servers;

View File

@@ -2,192 +2,229 @@ import settings from "./settings.mjs";
import processor from "./processor.mjs"; import processor from "./processor.mjs";
// noinspection FunctionWithInconsistentReturnsJS // noinspection FunctionWithInconsistentReturnsJS
export default (function() { export default (function () {
/** /**
* @type {{}} * @type {{}}
* @property {{}} users * @property {{}} users
* @property {{}} servers * @property {{}} servers
* @property {{}} channels * @property {{}} channels
*/ */
const fileUrlProcessor = function (serverToken) {
if (typeof serverToken === "string") {
return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken);
}
else {
return url => url;
}
}(window.DHT_SERVER_TOKEN);
let loadedFileMeta; let loadedFileMeta;
let loadedFileData; let loadedFileData;
let loadedMessages; let loadedMessages;
let filterFunction; let filterFunction;
let selectedChannel; let selectedChannel;
let currentPage; let currentPage;
let messagesPerPage; let messagesPerPage;
const getUser = function(id) { const getUser = function (id) {
return loadedFileMeta.users[id] || { "name": "&lt;unknown&gt;" }; return loadedFileMeta.users[id] || { "name": "&lt;unknown&gt;" };
}; };
const getUserList = function() { const getUserList = function () {
return loadedFileMeta ? loadedFileMeta.users : []; return loadedFileMeta ? loadedFileMeta.users : [];
}; };
const getServer = function(id) { const getServer = function (id) {
return loadedFileMeta.servers[id] || { "name": "&lt;unknown&gt;", "type": "unknown" }; return loadedFileMeta.servers[id] || { "name": "&lt;unknown&gt;", "type": "unknown" };
}; };
const generateChannelHierarchy = function() { const getUserIDByName = function (name) {
for (let userId in loadedFileMeta.users) {
let user = loadedFileMeta.users[userId];
if (user.name === name) {
return userId;
}
}
return 0;
};
const getUserByName = function (name) {
for (let userId in loadedFileMeta.users) {
let user = loadedFileMeta.users[userId];
if (user.name === name) {
return user;
}
}
return 0;
};
const generateChannelHierarchy = function () {
/** /**
* @type {Map<string, Set>} * @type {Map<string, Set>}
*/ */
const hierarchy = new Map(); const hierarchy = new Map();
if (!loadedFileMeta) { if (!loadedFileMeta) {
return hierarchy; return hierarchy;
} }
/** /**
* @returns {Set} * @returns {Set}
*/ */
function getChildren(parentId) { function getChildren(parentId) {
let children = hierarchy.get(parentId); let children = hierarchy.get(parentId);
if (!children) { if (!children) {
children = new Set(); children = new Set();
hierarchy.set(parentId, children); hierarchy.set(parentId, children);
} }
return children; return children;
} }
for (const [ id, channel ] of Object.entries(loadedFileMeta.channels)) { for (const [id, channel] of Object.entries(loadedFileMeta.channels)) {
getChildren(channel.parent || "").add(id); getChildren(channel.parent || "").add(id);
} }
const unreachableIds = new Set(hierarchy.keys()); const unreachableIds = new Set(hierarchy.keys());
function reachIds(parentId) { function reachIds(parentId) {
unreachableIds.delete(parentId); unreachableIds.delete(parentId);
const children = hierarchy.get(parentId); const children = hierarchy.get(parentId);
if (children) { if (children) {
for (const id of children) { for (const id of children) {
reachIds(id); reachIds(id);
} }
} }
} }
reachIds(""); reachIds("");
const rootChildren = getChildren(""); const rootChildren = getChildren("");
for (const unreachableId of unreachableIds) { for (const unreachableId of unreachableIds) {
for (const id of hierarchy.get(unreachableId)) { for (const id of hierarchy.get(unreachableId)) {
rootChildren.add(id); rootChildren.add(id);
} }
hierarchy.delete(unreachableId); hierarchy.delete(unreachableId);
} }
return hierarchy; return hierarchy;
}; };
const generateChannelOrder = function() { const generateChannelOrder = function () {
if (!loadedFileMeta) { if (!loadedFileMeta) {
return {}; return {};
} }
const channels = loadedFileMeta.channels; const channels = loadedFileMeta.channels;
const hierarchy = generateChannelHierarchy(); const hierarchy = generateChannelHierarchy();
function getSortedSubTree(parentId) { function getSortedSubTree(parentId) {
const children = hierarchy.get(parentId); const children = hierarchy.get(parentId);
if (!children) { if (!children) {
return []; return [];
} }
const sortedChildren = Array.from(children); const sortedChildren = Array.from(children);
sortedChildren.sort((id1, id2) => { sortedChildren.sort((id1, id2) => {
const c1 = channels[id1]; const c1 = channels[id1];
const c2 = channels[id2]; const c2 = channels[id2];
const s1 = getServer(c1.server); const s1 = getServer(c1.server);
const s2 = getServer(c2.server); const s2 = getServer(c2.server);
return s1.type.localeCompare(s2.type, "en") || return s1.type.localeCompare(s2.type, "en") ||
s1.name.toLocaleLowerCase().localeCompare(s2.name.toLocaleLowerCase(), undefined, { numeric: true }) || s1.name.toLocaleLowerCase().localeCompare(s2.name.toLocaleLowerCase(), undefined, { numeric: true }) ||
(c1.position || -1) - (c2.position || -1) || (c1.position || -1) - (c2.position || -1) ||
c1.name.toLocaleLowerCase().localeCompare(c2.name.toLocaleLowerCase(), undefined, { numeric: true }); c1.name.toLocaleLowerCase().localeCompare(c2.name.toLocaleLowerCase(), undefined, { numeric: true });
}); });
const subTree = []; const subTree = [];
for (const id of sortedChildren) { for (const id of sortedChildren) {
subTree.push(id); subTree.push(id);
subTree.push(...getSortedSubTree(id)); subTree.push(...getSortedSubTree(id));
} }
return subTree; return subTree;
} }
const orderArray = getSortedSubTree(""); const orderArray = getSortedSubTree("");
const orderMap = {}; const orderMap = {};
for (let i = 0; i < orderArray.length; i++) { for (let i = 0; i < orderArray.length; i++) {
orderMap[orderArray[i]] = i; orderMap[orderArray[i]] = i;
} }
return orderMap; return orderMap;
}; };
const getChannelList = function() { const getChannelList = function () {
if (!loadedFileMeta) { if (!loadedFileMeta) {
return []; return [];
} }
const channels = loadedFileMeta.channels; const channels = loadedFileMeta.channels;
const channelOrder = generateChannelOrder(); const channelOrder = generateChannelOrder();
return Object.keys(channels).map(key => ({ return Object.keys(channels).map(key => ({
"id": key, "id": key,
"serverId": channels[key].server,
"name": channels[key].name, "name": channels[key].name,
"server": getServer(channels[key].server), "server": getServer(channels[key].server),
"msgcount": getFilteredMessageKeys(key).length, "msgcount": getFilteredMessageKeys(key).length,
"topic": channels[key].topic || "", "topic": channels[key].topic || "",
"nsfw": channels[key].nsfw || false, "nsfw": channels[key].nsfw || false,
"icon": getServer(channels[key].server).type === "group"
? `<!--<span>${channels[key].name.split(" ").map(word => word[0]).join("")}</span>-->` //Discord default naming without a icon
: `<img src='${fileUrlProcessor("https://cdn.discordapp.com/avatars/" + getUserIDByName(channels[key].name) + "/" + getUserByName(channels[key].name).avatar+".webp")}'>`
})).sort((ac, bc) => { })).sort((ac, bc) => {
return channelOrder[ac.id] - channelOrder[bc.id]; return channelOrder[ac.id] - channelOrder[bc.id];
}); });
}; };
const getMessages = function(channel) { const getMessages = function (channel) {
return loadedFileData[channel] || {}; return loadedFileData[channel] || {};
}; };
const getMessageById = function(id) { const getMessageById = function (id) {
for (const messages of Object.values(loadedFileData)) { for (const messages of Object.values(loadedFileData)) {
if (id in messages) { if (id in messages) {
return messages[id]; return messages[id];
} }
} }
return null; return null;
}; };
const getMessageChannel = function(id) { const getMessageChannel = function (id) {
for (const [ channel, messages ] of Object.entries(loadedFileData)) { for (const [channel, messages] of Object.entries(loadedFileData)) {
if (id in messages) { if (id in messages) {
return channel; return channel;
} }
} }
return null; return null;
}; };
const getMessageList = function() { const getMessageList = function () {
if (!loadedMessages) { if (!loadedMessages) {
return []; return [];
} }
const messages = getMessages(selectedChannel); const messages = getMessages(selectedChannel);
const startIndex = messagesPerPage * (root.getCurrentPage() - 1); const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => { return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => {
/** /**
* @type {{}} * @type {{}}
@@ -203,35 +240,35 @@ export default (function() {
const message = messages[key]; const message = messages[key];
const user = getUser(message.u); const user = getUser(message.u);
const avatar = user.avatar ? { id: message.u, path: user.avatar } : null; const avatar = user.avatar ? { id: message.u, path: user.avatar } : null;
const obj = { const obj = {
user, user,
avatar, avatar,
"timestamp": message.t, "timestamp": message.t,
"jump": key, "jump": key,
}; };
if ("m" in message) { if ("m" in message) {
obj["contents"] = message.m; obj["contents"] = message.m;
} }
if ("e" in message) { if ("e" in message) {
obj["embeds"] = message.e.map(embed => JSON.parse(embed)); obj["embeds"] = message.e.map(embed => JSON.parse(embed));
} }
if ("a" in message) { if ("a" in message) {
obj["attachments"] = message.a; obj["attachments"] = message.a;
} }
if ("te" in message) { if ("te" in message) {
obj["edit"] = message.te; obj["edit"] = message.te;
} }
if ("r" in message) { if ("r" in message) {
const replyMessage = getMessageById(message.r); const replyMessage = getMessageById(message.r);
const replyUser = replyMessage ? getUser(replyMessage.u) : null; const replyUser = replyMessage ? getUser(replyMessage.u) : null;
const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null; const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null;
obj["reply"] = replyMessage ? { obj["reply"] = replyMessage ? {
"id": message.r, "id": message.r,
"user": replyUser, "user": replyUser,
@@ -239,212 +276,212 @@ export default (function() {
"contents": replyMessage.m "contents": replyMessage.m
} : null; } : null;
} }
if ("re" in message) { if ("re" in message) {
obj["reactions"] = message.re; obj["reactions"] = message.re;
} }
return obj; return obj;
}); });
}; };
let eventOnUsersRefreshed; let eventOnUsersRefreshed;
let eventOnChannelsRefreshed; let eventOnChannelsRefreshed;
let eventOnMessagesRefreshed; let eventOnMessagesRefreshed;
const triggerUsersRefreshed = function() { const triggerUsersRefreshed = function () {
eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList()); eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
}; };
const triggerChannelsRefreshed = function(selectedChannel) { const triggerChannelsRefreshed = function (selectedChannel) {
eventOnChannelsRefreshed && eventOnChannelsRefreshed(getChannelList(), selectedChannel); eventOnChannelsRefreshed && eventOnChannelsRefreshed(getChannelList(), selectedChannel);
}; };
const triggerMessagesRefreshed = function() { const triggerMessagesRefreshed = function () {
eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList()); eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList());
}; };
const getFilteredMessageKeys = function(channel) { const getFilteredMessageKeys = function (channel) {
const messages = getMessages(channel); const messages = getMessages(channel);
let keys = Object.keys(messages); let keys = Object.keys(messages);
if (filterFunction) { if (filterFunction) {
keys = keys.filter(key => filterFunction(messages[key])); keys = keys.filter(key => filterFunction(messages[key]));
} }
return keys; return keys;
}; };
const root = { const root = {
onChannelsRefreshed(callback) { onChannelsRefreshed(callback) {
eventOnChannelsRefreshed = callback; eventOnChannelsRefreshed = callback;
}, },
onMessagesRefreshed(callback) { onMessagesRefreshed(callback) {
eventOnMessagesRefreshed = callback; eventOnMessagesRefreshed = callback;
}, },
onUsersRefreshed(callback) { onUsersRefreshed(callback) {
eventOnUsersRefreshed = callback; eventOnUsersRefreshed = callback;
}, },
uploadFile(meta, data) { uploadFile(meta, data) {
if (loadedFileMeta != null) { if (loadedFileMeta != null) {
throw "A file is already loaded!"; throw "A file is already loaded!";
} }
if (typeof meta !== "object" || typeof data !== "object") { if (typeof meta !== "object" || typeof data !== "object") {
throw "Invalid file format!"; throw "Invalid file format!";
} }
loadedFileMeta = meta; loadedFileMeta = meta;
loadedFileData = data; loadedFileData = data;
loadedMessages = null; loadedMessages = null;
selectedChannel = null; selectedChannel = null;
currentPage = 1; currentPage = 1;
triggerUsersRefreshed(); triggerUsersRefreshed();
triggerChannelsRefreshed(); triggerChannelsRefreshed();
triggerMessagesRefreshed(); triggerMessagesRefreshed();
settings.onSettingsChanged(() => triggerMessagesRefreshed()); settings.onSettingsChanged(() => triggerMessagesRefreshed());
}, },
getChannelName(channel) { getChannelName(channel) {
const channelObj = loadedFileMeta.channels[channel]; const channelObj = loadedFileMeta.channels[channel];
return (channelObj && channelObj.name) || channel; return (channelObj && channelObj.name) || channel;
}, },
getUserName(user) { getUserName(user) {
const userObj = loadedFileMeta.users[user]; const userObj = loadedFileMeta.users[user];
return (userObj && userObj.name) || user; return (userObj && userObj.name) || user;
}, },
getUserDisplayName(user) { getUserDisplayName(user) {
const userObj = loadedFileMeta.users[user]; const userObj = loadedFileMeta.users[user];
return (userObj && (userObj.displayName || userObj.name)) || user; return (userObj && (userObj.displayName || userObj.name)) || user;
}, },
selectChannel(channel) { selectChannel(channel) {
currentPage = 1; currentPage = 1;
selectedChannel = channel; selectedChannel = channel;
loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest); loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest);
triggerMessagesRefreshed(); triggerMessagesRefreshed();
}, },
setMessagesPerPage(amount) { setMessagesPerPage(amount) {
messagesPerPage = amount; messagesPerPage = amount;
triggerMessagesRefreshed(); triggerMessagesRefreshed();
}, },
updateCurrentPage(action) { updateCurrentPage(action) {
switch (action) { switch (action) {
case "first": case "first":
currentPage = 1; currentPage = 1;
break; break;
case "prev": case "prev":
currentPage = Math.max(1, currentPage - 1); currentPage = Math.max(1, currentPage - 1);
break; break;
case "next": case "next":
currentPage = Math.min(this.getPageCount(), currentPage + 1); currentPage = Math.min(this.getPageCount(), currentPage + 1);
break; break;
case "last": case "last":
currentPage = this.getPageCount(); currentPage = this.getPageCount();
break; break;
case "pick": case "pick":
const page = parseInt(prompt("Select page:", currentPage), 10); const page = parseInt(prompt("Select page:", currentPage), 10);
if (!page && page !== 0) { if (!page && page !== 0) {
return; return;
} }
currentPage = Math.max(1, Math.min(this.getPageCount(), page)); currentPage = Math.max(1, Math.min(this.getPageCount(), page));
break; break;
} }
triggerMessagesRefreshed(); triggerMessagesRefreshed();
}, },
getCurrentPage() { getCurrentPage() {
const total = this.getPageCount(); const total = this.getPageCount();
if (currentPage > total && total > 0) { if (currentPage > total && total > 0) {
currentPage = total; currentPage = total;
} }
return currentPage || 1; return currentPage || 1;
}, },
getPageCount() { getPageCount() {
return !loadedMessages ? 0 : (!messagesPerPage ? 1 : Math.ceil(loadedMessages.length / messagesPerPage)); return !loadedMessages ? 0 : (!messagesPerPage ? 1 : Math.ceil(loadedMessages.length / messagesPerPage));
}, },
navigateToMessage(id) { navigateToMessage(id) {
if (!loadedMessages) { if (!loadedMessages) {
return -1; return -1;
} }
const channel = getMessageChannel(id); const channel = getMessageChannel(id);
if (channel !== null && channel !== selectedChannel) { if (channel !== null && channel !== selectedChannel) {
triggerChannelsRefreshed(channel); triggerChannelsRefreshed(channel);
this.selectChannel(channel); this.selectChannel(channel);
} }
const index = loadedMessages.indexOf(id); const index = loadedMessages.indexOf(id);
if (index === -1) { if (index === -1) {
return -1; return -1;
} }
currentPage = Math.max(1, Math.min(this.getPageCount(), 1 + Math.floor(index / messagesPerPage))); currentPage = Math.max(1, Math.min(this.getPageCount(), 1 + Math.floor(index / messagesPerPage)));
triggerMessagesRefreshed(); triggerMessagesRefreshed();
return index % messagesPerPage; return index % messagesPerPage;
}, },
setActiveFilter(filter) { setActiveFilter(filter) {
switch (filter ? filter.type : "") { switch (filter ? filter.type : "") {
case "user": case "user":
filterFunction = processor.FILTER.byUser(filter.value); filterFunction = processor.FILTER.byUser(filter.value);
break; break;
case "contents": case "contents":
filterFunction = processor.FILTER.byContents(filter.value); filterFunction = processor.FILTER.byContents(filter.value);
break; break;
case "withimages": case "withimages":
filterFunction = processor.FILTER.withImages(); filterFunction = processor.FILTER.withImages();
break; break;
case "withdownloads": case "withdownloads":
filterFunction = processor.FILTER.withDownloads(); filterFunction = processor.FILTER.withDownloads();
break; break;
case "edited": case "edited":
filterFunction = processor.FILTER.isEdited(); filterFunction = processor.FILTER.isEdited();
break; break;
default: default:
filterFunction = null; filterFunction = null;
break; break;
} }
this.hasActiveFilter = filterFunction != null; this.hasActiveFilter = filterFunction != null;
triggerChannelsRefreshed(selectedChannel); triggerChannelsRefreshed(selectedChannel);
if (selectedChannel) { if (selectedChannel) {
this.selectChannel(selectedChannel); // resets current page and updates messages this.selectChannel(selectedChannel); // resets current page and updates messages
} }
} }
}; };
root.hasActiveFilter = false; root.hasActiveFilter = false;
return root; return root;
})(); })();

View File

@@ -8,15 +8,21 @@ export default class {
}; };
apply(obj, processor) { apply(obj, processor) {
//Keys to not escape
const allowHTMLKeys = new Set(["icon"]); //Example with more: Set(["icon", "description", "content"]);
return this.contents.replace(TEMPLATE_REGEX, (full, match) => { return this.contents.replace(TEMPLATE_REGEX, (full, match) => {
const value = match.split(".").reduce((o, property) => o[property], obj); const value = match.split(".").reduce((o, property) => o[property], obj);
if (processor) { if (processor) {
const updated = processor(match, value); const updated = processor(match, value);
return typeof updated === "undefined" ? dom.escapeHTML(value) : updated; return typeof updated === "undefined" ? (allowHTMLKeys.has(match) ? value : dom.escapeHTML(value)) : updated;
} }
return dom.escapeHTML(value);
return allowHTMLKeys.has(match) ? value : dom.escapeHTML(value);
}); });
} }
} }

View File

@@ -4,8 +4,9 @@
max-width: 300px; max-width: 300px;
overflow-y: auto; overflow-y: auto;
color: #eee; color: #eee;
background-color: #1c1e22; background-color: #2B2D31;
font-size: 15px; font-size: 15px;
padding: 2px;
} }
#channels > div.loading { #channels > div.loading {
@@ -14,27 +15,93 @@
height: 150px; height: 150px;
} }
#channels > div.channel { #channels > div.loading {
cursor: pointer; background-color: rgba(0, 0, 0, 0) !important;
padding: 10px 12px;
border-bottom: 1px solid #333333;
} }
#channels > div.channel:hover, #channels > div.channel.active {
background-color: #282b30; #channels > div.channel.visible {
display: flex !important;
} }
#channels > div.channel {
display: none !important;
cursor: pointer;
padding: 5px 8px;
color: #eee;
font-size: 15px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-start;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
border-radius: 5px;
}
.ServerChannel {
padding: 8px 8px !important;
}
#channels > div.channel:hover,
#channels > div.channel.active {
background-color: rgba(78, 80, 88, 0.6);
}
#channels > div.channel.active > .info > .name {
color: oklab(0.999994 0.0000455678 0.0000200868);
}
#channels .info { #channels .info {
display: flex; display: flex;
height: 16px; width: 100%;
margin-bottom: 4px; }
.ServerChannel > .info {
padding: 0px !important;
}
.UserChannel > .info {
align-self: center;
padding-left: 6px !important;
align-items: center;
width: 150px !important;
}
[server-type="group"] > .info {
padding: 6px;
width: 172px !important;
}
.UserChannel > .info > .name {
min-height: 18px;
} }
#channels .name { #channels .name {
flex-grow: 1; flex-grow: 1;
overflow-x: hidden;
overflow-y: clip;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; color: oklab(0.686636 -0.00407365 -0.0149199);
font-weight: 500;
font-size: 16px;
/* max-width: 200px; */
}
.ServerChannel .name {
height: 18px;
} }
#channels .tag { #channels .tag {
@@ -46,3 +113,25 @@
padding: 2px 5px; padding: 2px 5px;
font-size: 11px; font-size: 11px;
} }
#channels > div:hover,
#channels > div.active {
background-color: #404249
}
#channels > div > .avatar {
padding: 0;
margin: 0;
}
#channels > div > .avatar > * {
width: 30px;
min-width: 30px;
min-height: 30px;
/*Make not loaded images be 30px*/
border-radius: 100%;
font-size: 1em;
}

View File

@@ -17,6 +17,10 @@ body {
--loading-backdrop: rgba(0, 0, 0, 0); --loading-backdrop: rgba(0, 0, 0, 0);
} }
.loading:hover {
background-color: rgba(0, 0, 0, 0) !important;
}
.loading::after { .loading::after {
content: ""; content: "";
background: var(--loading-backdrop) background: var(--loading-backdrop)

View File

@@ -4,8 +4,8 @@
align-items: stretch; align-items: stretch;
gap: 8px; gap: 8px;
padding: 8px; padding: 8px;
background-color: #17181c; background-color: #313338;
border-bottom: 1px dotted #5d626b; border-bottom: 2px solid #27292D;
} }
#menu .splitter { #menu .splitter {
@@ -23,7 +23,9 @@
cursor: default; cursor: default;
} }
#menu button, #menu select, #menu input[type="text"] { #menu button,
#menu select,
#menu input[type="text"] {
height: 31px; height: 31px;
padding: 0 10px; padding: 0 10px;
background-color: #7289da; background-color: #7289da;
@@ -64,11 +66,13 @@
padding: 0 8px; padding: 0 8px;
} }
#menu .nav > button, #menu .nav > p { #menu .nav > button,
#menu .nav > p {
margin: 0 1px; margin: 0 1px;
} }
#opt-filter-list > select, #opt-filter-list > input { #opt-filter-list > select,
#opt-filter-list > input {
display: none; display: none;
} }
@@ -78,4 +82,4 @@
#btn-about { #btn-about {
margin-left: auto; margin-left: auto;
} }

View File

@@ -1,13 +1,18 @@
#messages { #messages {
flex: 1 1 0; flex: 1 1 0;
overflow-y: auto; overflow-y: auto;
background-color: #36393E; background-color: #36393e;
} }
#messages > div { #messages > div {
margin: 0 24px; padding: 0 4px 0 24px;
padding: 4px 0 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04); margin-bottom: 17px;
}
#messages > div:hover {
background-color: oklab(0.0846607 0.00000385568 0.00000169128 / 0.06);
} }
#messages h2 { #messages h2 {
@@ -23,6 +28,8 @@
align-content: flex-start; align-content: flex-start;
} }
#messages .avatar-wrapper > div { #messages .avatar-wrapper > div {
flex: 1 1 auto; flex: 1 1 auto;
} }
@@ -38,7 +45,7 @@
} }
#messages .username { #messages .username {
color: #FFF; color: #fff;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
margin-right: 3px; margin-right: 3px;
@@ -50,14 +57,9 @@
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0; letter-spacing: 0;
margin-left: 5px;
} }
#messages .info::before {
content: "\2022";
text-align: center;
display: inline-block;
width: 14px;
}
#messages .jump { #messages .jump {
cursor: pointer; cursor: pointer;
@@ -66,21 +68,23 @@
} }
.message { .message {
margin-top: 6px; margin-top: 2px;
color: rgba(255, 255, 255, 0.7); color: oklab(0.89908 -0.00192907 -0.0048306);
font-size: 15px; font-size: 16px;
line-height: 1.1em; line-height: 1.1em;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
.message .link, .reply-message .link { .message .link,
color: #7289DA; .reply-message .link {
color: #7289da;
background-color: rgba(115, 139, 215, 0.1); background-color: rgba(115, 139, 215, 0.1);
} }
.message a, .reply-message a { .message a,
color: #0096CF; .reply-message a {
color: #0096cf;
text-decoration: none; text-decoration: none;
} }
@@ -126,12 +130,13 @@
border-radius: 3px; border-radius: 3px;
} }
.message .embed:first-child, .message .download + .download { .message .embed:first-child,
.message .download+.download {
margin-top: 0; margin-top: 0;
} }
.message code { .message code {
background-color: #2E3136; background-color: #2e3136;
border-radius: 5px; border-radius: 5px;
font-family: Menlo, Consolas, Monaco, monospace; font-family: Menlo, Consolas, Monaco, monospace;
font-size: 14px; font-size: 14px;
@@ -144,7 +149,7 @@
.message code.block { .message code.block {
display: block; display: block;
border: 2px solid #282B30; border: 2px solid #282b30;
margin-top: 6px; margin-top: 6px;
padding: 7px; padding: 7px;
} }
@@ -198,7 +203,7 @@
} }
.reply-username { .reply-username {
color: #FFF; color: #fff;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0; letter-spacing: 0;
@@ -218,7 +223,7 @@
} }
.reply-contents code { .reply-contents code {
background-color: #2E3136; background-color: #2e3136;
font-family: Menlo, Consolas, Monaco, monospace; font-family: Menlo, Consolas, Monaco, monospace;
padding: 1px 2px; padding: 1px 2px;
} }
@@ -257,4 +262,4 @@
.reactions .count { .reactions .count {
color: rgba(255, 255, 255, 0.45); color: rgba(255, 255, 255, 0.45);
font-size: 14px; font-size: 14px;
} }

View File

@@ -0,0 +1,81 @@
#servers {
width: 76px;
background-color: #1e1f22;
padding: 0px;
overflow-y: auto;
overflow-x: hidden;
}
.Server {
width: 54px;
height: 54px;
margin-left: 11px;
margin-top: 8px;
border-radius: 100%;
transition: .15s ease-out;
cursor: pointer;
background-color: #313338;
display: flex;
align-items: center;
overflow: hidden;
}
.Server:hover {
border-radius: 20px;
background-color: #5865f2;
}
.Server.active {
border-radius: 20px;
background-color: #5865f2;
}
.ServerImg {
width: 100%;
height: 100%;
}
svg.ServerImg {
width: 100%;
height: 70%;
/* margin-top: 15%; */
color: white;
}
#servers .Server .icon {
font-weight: bold;
font-size: 1.5em;
text-align: center;
word-wrap: break-word;
word-break: break-all;
white-space: normal;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: white;
}
#servers .Server .name {
display: none;
font-weight: bold;
white-space: normal;
word-wrap: break-word;
text-align: center;
}
#servers .Server .name {
display: none;
}
#servers .Server:hover .name {
display: block;
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px;
border-radius: 4px;
margin-left: 60px;
white-space: nowrap;
}

View File

@@ -4,11 +4,11 @@ public sealed class DownloadStatusStatistics {
public int PendingCount { get; internal init; } public int PendingCount { get; internal init; }
public ulong PendingTotalSize { get; internal init; } public ulong PendingTotalSize { get; internal init; }
public int PendingWithUnknownSizeCount { get; internal init; } public int PendingWithUnknownSizeCount { get; internal init; }
public int SuccessfulCount { get; internal init; } public int SuccessfulCount { get; internal init; }
public ulong SuccessfulTotalSize { get; internal init; } public ulong SuccessfulTotalSize { get; internal init; }
public int SuccessfulWithUnknownSizeCount { get; internal init; } public int SuccessfulWithUnknownSizeCount { get; internal init; }
public int FailedCount { get; internal init; } public int FailedCount { get; internal init; }
public ulong FailedTotalSize { get; internal init; } public ulong FailedTotalSize { get; internal init; }
public int FailedWithUnknownSizeCount { get; internal init; } public int FailedWithUnknownSizeCount { get; internal init; }

View File

@@ -6,7 +6,7 @@ public sealed class Download {
public DownloadStatus Status { get; } public DownloadStatus Status { get; }
public string? Type { get; } public string? Type { get; }
public ulong? Size { get; } public ulong? Size { get; }
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) { internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
NormalizedUrl = normalizedUrl; NormalizedUrl = normalizedUrl;
DownloadUrl = downloadUrl; DownloadUrl = downloadUrl;

View File

@@ -10,5 +10,5 @@ public enum DownloadStatus {
GenericError = 1, GenericError = 1,
Downloading = 2, Downloading = 2,
LastCustomCode = 99, LastCustomCode = 99,
Success = HttpStatusCode.OK Success = HttpStatusCode.OK,
} }

View File

@@ -7,7 +7,7 @@ sealed class DiscordEmbedJson {
public JsonImage? Image { get; set; } public JsonImage? Image { get; set; }
public JsonImage? Thumbnail { get; set; } public JsonImage? Thumbnail { get; set; }
public JsonImage? Video { get; set; } public JsonImage? Video { get; set; }
public sealed class JsonImage { public sealed class JsonImage {
public string? Url { get; set; } public string? Url { get; set; }
public string? ProxyUrl { get; set; } public string? ProxyUrl { get; set; }

View File

@@ -5,5 +5,5 @@ namespace DHT.Server.Data;
[Flags] [Flags]
public enum EmojiFlags : ushort { public enum EmojiFlags : ushort {
None = 0, None = 0,
Animated = 0b1 Animated = 0b1,
} }

View File

@@ -0,0 +1,9 @@
namespace DHT.Server.Data;
public readonly record struct FileUrl(string NormalizedUrl, string DownloadUrl, string? Type) {
public FileUrl(string url, string? type) : this(url, url, type) {}
public Download ToPendingDownload() {
return new Download(NormalizedUrl, DownloadUrl, DownloadStatus.Pending, Type, size: null);
}
}

View File

@@ -7,6 +7,6 @@ public sealed class DownloadItemFilter {
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null; public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
public ulong? MaxBytes { get; set; } = null; public ulong? MaxBytes { get; set; } = null;
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null; public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
} }

View File

@@ -2,5 +2,5 @@ namespace DHT.Server.Data.Filters;
public enum FilterRemovalMode { public enum FilterRemovalMode {
KeepMatching, KeepMatching,
RemoveMatching RemoveMatching,
} }

View File

@@ -6,11 +6,11 @@ namespace DHT.Server.Data.Filters;
public sealed class MessageFilter { public sealed class MessageFilter {
public DateTime? StartDate { get; set; } = null; public DateTime? StartDate { get; set; } = null;
public DateTime? EndDate { get; set; } = null; public DateTime? EndDate { get; set; } = null;
public HashSet<ulong>? ChannelIds { get; set; } = null; public HashSet<ulong>? ChannelIds { get; set; } = null;
public HashSet<ulong>? UserIds { get; set; } = null; public HashSet<ulong>? UserIds { get; set; } = null;
public HashSet<ulong>? MessageIds { get; set; } = null; public HashSet<ulong>? MessageIds { get; set; } = null;
public bool IsEmpty => StartDate == null && public bool IsEmpty => StartDate == null &&
EndDate == null && EndDate == null &&
ChannelIds == null && ChannelIds == null &&

Some files were not shown because too many files have changed in this diff Show More