diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3367db83..5d57fce7 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -106,6 +106,7 @@ jobs:
-p:CSharpier_Bypass=true
--output ${{ matrix.app }}/bin/publish/
--configuration Release
+ --use-current-runtime
- name: Upload artifacts
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
diff --git a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj
index dff62425..98b64906 100644
--- a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj
+++ b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj
@@ -11,19 +11,18 @@
-
-
-
+
+
+
-
-
-
-
+
+
+
diff --git a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs
index d0a389f7..aa3c37a8 100644
--- a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs
+++ b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs
@@ -93,13 +93,12 @@ public static class ExportWrapper
Snowflake messageId
)
{
- var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(
- e =>
- string.Equals(
- e.GetAttribute("data-message-id"),
- messageId.ToString(),
- StringComparison.OrdinalIgnoreCase
- )
+ var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
+ string.Equals(
+ e.GetAttribute("data-message-id"),
+ messageId.ToString(),
+ StringComparison.OrdinalIgnoreCase
+ )
);
if (message is null)
@@ -117,13 +116,12 @@ public static class ExportWrapper
Snowflake messageId
)
{
- var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(
- j =>
- string.Equals(
- j.GetProperty("id").GetString(),
- messageId.ToString(),
- StringComparison.OrdinalIgnoreCase
- )
+ var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
+ string.Equals(
+ j.GetProperty("id").GetString(),
+ messageId.ToString(),
+ StringComparison.OrdinalIgnoreCase
+ )
);
if (message.ValueKind == JsonValueKind.Undefined)
diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
index 72577112..41e8cc90 100644
--- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
+++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
@@ -53,10 +53,8 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
],
o =>
- o.Using(
- ctx =>
- ctx.Subject.Should()
- .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
+ o.Using(ctx =>
+ ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs()
);
@@ -97,10 +95,8 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
],
o =>
- o.Using(
- ctx =>
- ctx.Subject.Should()
- .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
+ o.Using(ctx =>
+ ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs()
);
@@ -144,10 +140,8 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
],
o =>
- o.Using(
- ctx =>
- ctx.Subject.Should()
- .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
+ o.Using(ctx =>
+ ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs()
);
diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs
index 9de8c0b9..207e8041 100644
--- a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs
+++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs
@@ -90,12 +90,11 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.WhereNotNull()
- .Where(
- s =>
- s.Contains(
- "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
- StringComparison.Ordinal
- )
+ .Where(s =>
+ s.Contains(
+ "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
+ StringComparison.Ordinal
+ )
)
.Should()
.ContainSingle();
@@ -205,42 +204,38 @@ public class HtmlEmbedSpecs
imageUrls
.Should()
- .Contain(
- u =>
- u.EndsWith(
- "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
- StringComparison.Ordinal
- )
+ .Contain(u =>
+ u.EndsWith(
+ "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
+ StringComparison.Ordinal
+ )
);
imageUrls
.Should()
- .Contain(
- u =>
- u.EndsWith(
- "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
- StringComparison.Ordinal
- )
+ .Contain(u =>
+ u.EndsWith(
+ "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
+ StringComparison.Ordinal
+ )
);
imageUrls
.Should()
- .Contain(
- u =>
- u.EndsWith(
- "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
- StringComparison.Ordinal
- )
+ .Contain(u =>
+ u.EndsWith(
+ "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
+ StringComparison.Ordinal
+ )
);
imageUrls
.Should()
- .Contain(
- u =>
- u.EndsWith(
- "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
- StringComparison.Ordinal
- )
+ .Contain(u =>
+ u.EndsWith(
+ "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
+ StringComparison.Ordinal
+ )
);
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj
index 24e48c4d..95ebf182 100644
--- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj
+++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj
@@ -11,9 +11,9 @@
-
+
-
+
diff --git a/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs b/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs
new file mode 100644
index 00000000..a465aec8
--- /dev/null
+++ b/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DiscordChatExporter.Core.Discord.Data;
+
+public record ChannelNode(Channel Channel, IReadOnlyList Children)
+{
+ public static IReadOnlyList BuildTree(IReadOnlyList channels)
+ {
+ IReadOnlyList GetChildren(Channel parent) =>
+ channels
+ .Where(c => c.Parent?.Id == parent.Id)
+ .Select(c => new ChannelNode(c, GetChildren(c)))
+ .ToArray();
+
+ return channels
+ .Where(c => c.Parent is null)
+ .Select(c => new ChannelNode(c, GetChildren(c)))
+ .ToArray();
+ }
+}
diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs
index a7b19ed1..6fa13c1a 100644
--- a/DiscordChatExporter.Core/Discord/Data/Message.cs
+++ b/DiscordChatExporter.Core/Discord/Data/Message.cs
@@ -83,16 +83,15 @@ public partial record Message
// Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds
.Skip(i + 1)
- .TakeWhile(
- e =>
- e.Url == embed.Url
- && e.Timestamp is null
- && e.Author is null
- && e.Color is null
- && string.IsNullOrWhiteSpace(e.Description)
- && !e.Fields.Any()
- && e.Images.Count == 1
- && e.Footer is null
+ .TakeWhile(e =>
+ e.Url == embed.Url
+ && e.Timestamp is null
+ && e.Author is null
+ && e.Color is null
+ && string.IsNullOrWhiteSpace(e.Description)
+ && !e.Fields.Any()
+ && e.Images.Count == 1
+ && e.Footer is null
)
.ToArray();
diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs
index a3f14cb1..4b85fecd 100644
--- a/DiscordChatExporter.Core/Discord/DiscordClient.cs
+++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs
@@ -66,12 +66,12 @@ public class DiscordClient(string token)
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
- // Adding a small buffer to the reset time reduces the chance of getting
- // rate limited again, because it allows for more requests to be released.
- (resetAfterDelay.Value + TimeSpan.FromSeconds(1))
- // Sometimes Discord returns an absurdly high value for the reset time, which
- // is not actually enforced by the server. So we cap it at a reasonable value.
- .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
+ // Adding a small buffer to the reset time reduces the chance of getting
+ // rate limited again, because it allows for more requests to be released.
+ (resetAfterDelay.Value + TimeSpan.FromSeconds(1))
+ // Sometimes Discord returns an absurdly high value for the reset time, which
+ // is not actually enforced by the server. So we cap it at a reasonable value.
+ .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken);
}
@@ -152,8 +152,13 @@ public class DiscordClient(string token)
_
=> throw new DiscordChatExporterException(
$"""
- Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
- Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
+ Request to '{url}' failed: {response
+ .StatusCode.ToString()
+ .ToSpaceSeparatedWords()
+ .ToLowerInvariant()}.
+ Response content: {await response.Content.ReadAsStringAsync(
+ cancellationToken
+ )}
""",
true
)
diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj
index 8c2b575b..2813f89b 100644
--- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj
+++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj
@@ -2,14 +2,14 @@
-
+
-
-
+
+
-
-
+
+
-
+
\ No newline at end of file
diff --git a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs
index fe957ed2..35620d4b 100644
--- a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs
+++ b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs
@@ -58,16 +58,15 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{
var lastModified = response
.Content.Headers.TryGetValue("Last-Modified")
- ?.Pipe(
- s =>
- DateTimeOffset.TryParse(
- s,
- CultureInfo.InvariantCulture,
- DateTimeStyles.None,
- out var instant
- )
- ? instant
- : (DateTimeOffset?)null
+ ?.Pipe(s =>
+ DateTimeOffset.TryParse(
+ s,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var instant
+ )
+ ? instant
+ : (DateTimeOffset?)null
);
if (lastModified is not null)
diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs
index c4dca3fe..c3d43f62 100644
--- a/DiscordChatExporter.Core/Exporting/ExportContext.cs
+++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs
@@ -93,8 +93,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
public IReadOnlyList GetUserRoles(Snowflake id) =>
TryGetMember(id)
- ?.RoleIds
- .Select(TryGetRole)
+ ?.RoleIds.Select(TryGetRole)
.WhereNotNull()
.OrderByDescending(r => r.Position)
.ToArray() ?? [];
diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs
index 2c3c8efb..7877194c 100644
--- a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs
+++ b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs
@@ -22,12 +22,11 @@ internal class ContainsMessageFilter(string text) : MessageFilter
public override bool IsMatch(Message message) =>
IsMatch(message.Content)
- || message.Embeds.Any(
- e =>
- IsMatch(e.Title)
- || IsMatch(e.Author?.Name)
- || IsMatch(e.Description)
- || IsMatch(e.Footer?.Text)
- || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
+ || message.Embeds.Any(e =>
+ IsMatch(e.Title)
+ || IsMatch(e.Author?.Name)
+ || IsMatch(e.Description)
+ || IsMatch(e.Footer?.Text)
+ || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
);
}
diff --git a/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs
index ae98b14d..5ec9d0af 100644
--- a/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs
+++ b/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs
@@ -7,11 +7,10 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class MentionsMessageFilter(string value) : MessageFilter
{
public override bool IsMatch(Message message) =>
- message.MentionedUsers.Any(
- user =>
- string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
- || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
- || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
- || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
+ message.MentionedUsers.Any(user =>
+ string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
}
diff --git a/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs b/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs
index 8aaac425..20156944 100644
--- a/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs
+++ b/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs
@@ -30,8 +30,8 @@ internal static class FilterGrammar
.OneOf(QuotedString, UnquotedString)
.Named("text string");
- private static readonly TextParser ContainsFilter = String.Select(
- v => (MessageFilter)new ContainsMessageFilter(v)
+ private static readonly TextParser ContainsFilter = String.Select(v =>
+ (MessageFilter)new ContainsMessageFilter(v)
);
private static readonly TextParser FromFilter = Span.EqualToIgnoreCase("from:")
diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs
index eb7bb576..d88faaa7 100644
--- a/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs
+++ b/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs
@@ -7,10 +7,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter(string value) : MessageFilter
{
public override bool IsMatch(Message message) =>
- message.Reactions.Any(
- r =>
- string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
- || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
- || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
+ message.Reactions.Any(r =>
+ string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
}
diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs
index 29435db4..735aade9 100644
--- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs
+++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs
@@ -155,7 +155,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
- {HtmlEncode(inlineCodeBlock.Code)}
+ {HtmlEncode(
+ inlineCodeBlock.Code
+ )}
"""
);
@@ -174,7 +176,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
- {HtmlEncode(multiLineCodeBlock.Code)}
+ {HtmlEncode(
+ multiLineCodeBlock.Code
+ )}
"""
);
@@ -267,7 +271,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
- @{HtmlEncode(displayName)}
+ @{HtmlEncode(
+ displayName
+ )}
"""
);
}
@@ -292,8 +298,12 @@ internal partial class HtmlMarkdownVisitor(
var style = color is not null
? $"""
- color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);
- """
+ color: rgb({color.Value.R}, {color.Value.G}, {color
+ .Value
+ .B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color
+ .Value
+ .B}, 0.1);
+ """
: null;
buffer.Append(
@@ -321,7 +331,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
- {HtmlEncode(formatted)}
+ {HtmlEncode(formatted)}
"""
);
@@ -344,10 +356,8 @@ internal partial class HtmlMarkdownVisitor
var isJumbo =
isJumboAllowed
- && nodes.All(
- n =>
- n is EmojiNode
- || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
+ && nodes.All(n =>
+ n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
);
var buffer = new StringBuilder();
diff --git a/DiscordChatExporter.Core/Utils/Http.cs b/DiscordChatExporter.Core/Utils/Http.cs
index f08984f8..b70852ce 100644
--- a/DiscordChatExporter.Core/Utils/Http.cs
+++ b/DiscordChatExporter.Core/Utils/Http.cs
@@ -25,11 +25,10 @@ public static class Http
private static bool IsRetryableException(Exception exception) =>
exception
.GetSelfAndChildren()
- .Any(
- ex =>
- ex is TimeoutException or SocketException or AuthenticationException
- || ex is HttpRequestException hrex
- && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
+ .Any(ex =>
+ ex is TimeoutException or SocketException or AuthenticationException
+ || ex is HttpRequestException hrex
+ && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
);
public static ResiliencePipeline ResiliencePipeline { get; } =
diff --git a/DiscordChatExporter.Gui/App.axaml b/DiscordChatExporter.Gui/App.axaml
new file mode 100644
index 00000000..52f0d270
--- /dev/null
+++ b/DiscordChatExporter.Gui/App.axaml
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/DiscordChatExporter.Gui/App.axaml.cs b/DiscordChatExporter.Gui/App.axaml.cs
new file mode 100644
index 00000000..5032d8db
--- /dev/null
+++ b/DiscordChatExporter.Gui/App.axaml.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Net;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.Platform;
+using DiscordChatExporter.Gui.Framework;
+using DiscordChatExporter.Gui.Services;
+using DiscordChatExporter.Gui.ViewModels;
+using DiscordChatExporter.Gui.ViewModels.Components;
+using DiscordChatExporter.Gui.ViewModels.Dialogs;
+using DiscordChatExporter.Gui.Views;
+using Material.Styles.Themes;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DiscordChatExporter.Gui;
+
+public partial class App : Application, IDisposable
+{
+ private readonly ServiceProvider _services;
+ private readonly MainViewModel _mainViewModel;
+
+ public App()
+ {
+ var services = new ServiceCollection();
+
+ // Framework
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Services
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // View models
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ _services = services.BuildServiceProvider(true);
+ _mainViewModel = _services.GetRequiredService().CreateMainViewModel();
+ }
+
+ public override void Initialize()
+ {
+ // Increase maximum concurrent connections
+ ServicePointManager.DefaultConnectionLimit = 20;
+
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ desktop.MainWindow = new MainView { DataContext = _mainViewModel };
+
+ base.OnFrameworkInitializationCompleted();
+
+ // Set custom theme colors
+ SetDefaultTheme();
+ }
+
+ public void Dispose() => _services.Dispose();
+}
+
+public partial class App
+{
+ public static void SetLightTheme()
+ {
+ if (Current is null)
+ return;
+
+ Current.LocateMaterialTheme().CurrentTheme = Theme.Create(
+ Theme.Light,
+ Color.Parse("#343838"),
+ Color.Parse("#F9A825")
+ );
+ }
+
+ public static void SetDarkTheme()
+ {
+ if (Current is null)
+ return;
+
+ Current.LocateMaterialTheme().CurrentTheme = Theme.Create(
+ Theme.Dark,
+ Color.Parse("#E8E8E8"),
+ Color.Parse("#F9A825")
+ );
+ }
+
+ public static void SetDefaultTheme()
+ {
+ if (Current is null)
+ return;
+
+ var isDarkModeEnabledByDefault =
+ Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark;
+
+ if (isDarkModeEnabledByDefault)
+ SetDarkTheme();
+ else
+ SetLightTheme();
+ }
+}
diff --git a/DiscordChatExporter.Gui/App.xaml b/DiscordChatExporter.Gui/App.xaml
deleted file mode 100644
index e90a123d..00000000
--- a/DiscordChatExporter.Gui/App.xaml
+++ /dev/null
@@ -1,543 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/DiscordChatExporter.Gui/App.xaml.cs b/DiscordChatExporter.Gui/App.xaml.cs
deleted file mode 100644
index 35c890cf..00000000
--- a/DiscordChatExporter.Gui/App.xaml.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System;
-using System.Reflection;
-using DiscordChatExporter.Gui.Utils;
-using MaterialDesignThemes.Wpf;
-
-namespace DiscordChatExporter.Gui;
-
-public partial class App
-{
- private static Assembly Assembly { get; } = typeof(App).Assembly;
-
- public static string Name { get; } = Assembly.GetName().Name!;
-
- public static Version Version { get; } = Assembly.GetName().Version!;
-
- public static string VersionString { get; } = Version.ToString(3);
-
- public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
-
- public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest";
-
- public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
-}
-
-public partial class App
-{
- private static Theme LightTheme { get; } =
- Theme.Create(
- new MaterialDesignLightTheme(),
- MediaColor.FromHex("#343838"),
- MediaColor.FromHex("#F9A825")
- );
-
- private static Theme DarkTheme { get; } =
- Theme.Create(
- new MaterialDesignDarkTheme(),
- MediaColor.FromHex("#E8E8E8"),
- MediaColor.FromHex("#F9A825")
- );
-
- public static void SetLightTheme()
- {
- var paletteHelper = new PaletteHelper();
- paletteHelper.SetTheme(LightTheme);
- }
-
- public static void SetDarkTheme()
- {
- var paletteHelper = new PaletteHelper();
- paletteHelper.SetTheme(DarkTheme);
- }
-}
diff --git a/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs b/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs
deleted file mode 100644
index 66071f53..00000000
--- a/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-using DiscordChatExporter.Core.Discord.Data;
-
-namespace DiscordChatExporter.Gui.Behaviors;
-
-public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior;
diff --git a/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs b/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs
deleted file mode 100644
index 8e8242ec..00000000
--- a/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using System.Collections;
-using System.Collections.Specialized;
-using System.Linq;
-using System.Windows;
-using System.Windows.Controls;
-using Microsoft.Xaml.Behaviors;
-
-namespace DiscordChatExporter.Gui.Behaviors;
-
-public class MultiSelectionListBoxBehavior : Behavior
-{
- public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
- nameof(SelectedItems),
- typeof(IList),
- typeof(MultiSelectionListBoxBehavior),
- new FrameworkPropertyMetadata(
- null,
- FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
- OnSelectedItemsChanged
- )
- );
-
- private static void OnSelectedItemsChanged(
- DependencyObject sender,
- DependencyPropertyChangedEventArgs args
- )
- {
- var behavior = (MultiSelectionListBoxBehavior)sender;
- if (behavior._modelHandled)
- return;
-
- if (behavior.AssociatedObject is null)
- return;
-
- behavior._modelHandled = true;
- behavior.SelectItems();
- behavior._modelHandled = false;
- }
-
- private bool _viewHandled;
- private bool _modelHandled;
-
- public IList? SelectedItems
- {
- get => (IList?)GetValue(SelectedItemsProperty);
- set => SetValue(SelectedItemsProperty, value);
- }
-
- // Propagate selected items from the model to the view
- private void SelectItems()
- {
- _viewHandled = true;
-
- AssociatedObject.SelectedItems.Clear();
- if (SelectedItems is not null)
- {
- foreach (var item in SelectedItems)
- AssociatedObject.SelectedItems.Add(item);
- }
-
- _viewHandled = false;
- }
-
- // Propagate selected items from the view to the model
- private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args)
- {
- if (_viewHandled)
- return;
- if (AssociatedObject.Items.SourceCollection is null)
- return;
-
- SelectedItems = AssociatedObject.SelectedItems.Cast().ToArray();
- }
-
- private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
- {
- if (_viewHandled)
- return;
- if (AssociatedObject.Items.SourceCollection is null)
- return;
- SelectItems();
- }
-
- protected override void OnAttached()
- {
- base.OnAttached();
-
- AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
- ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged +=
- OnListBoxItemsChanged;
- }
-
- protected override void OnDetaching()
- {
- base.OnDetaching();
-
- if (AssociatedObject is not null)
- {
- AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
- ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -=
- OnListBoxItemsChanged;
- }
- }
-}
diff --git a/DiscordChatExporter.Gui/Bootstrapper.cs b/DiscordChatExporter.Gui/Bootstrapper.cs
deleted file mode 100644
index 1be1e8b1..00000000
--- a/DiscordChatExporter.Gui/Bootstrapper.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using DiscordChatExporter.Gui.Services;
-using DiscordChatExporter.Gui.ViewModels;
-using DiscordChatExporter.Gui.ViewModels.Framework;
-using Stylet;
-using StyletIoC;
-#if !DEBUG
-using System.Windows;
-using System.Windows.Threading;
-#endif
-
-namespace DiscordChatExporter.Gui;
-
-public class Bootstrapper : Bootstrapper
-{
- protected override void OnStart()
- {
- base.OnStart();
-
- // Set the default theme.
- // Preferred theme will be set later, once the settings are loaded.
- App.SetLightTheme();
- }
-
- protected override void ConfigureIoC(IStyletIoCBuilder builder)
- {
- base.ConfigureIoC(builder);
-
- builder.Bind().ToSelf().InSingletonScope();
- builder.Bind().ToAbstractFactory();
- }
-
-#if !DEBUG
- protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
- {
- base.OnUnhandledException(args);
-
- MessageBox.Show(
- args.Exception.ToString(),
- "Error occured",
- MessageBoxButton.OK,
- MessageBoxImage.Error
- );
- }
-#endif
-}
diff --git a/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs b/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs
deleted file mode 100644
index 37c91ba7..00000000
--- a/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using System.Globalization;
-using System.Windows.Data;
-using DiscordChatExporter.Core.Discord.Data;
-
-namespace DiscordChatExporter.Gui.Converters;
-
-[ValueConversion(typeof(Channel), typeof(string))]
-public class ChannelToGroupKeyConverter : IValueConverter
-{
- public static ChannelToGroupKeyConverter Instance { get; } = new();
-
- public object? Convert(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) =>
- value switch
- {
- Channel { IsThread: true, Parent: not null } channel
- => $"Threads in #{channel.Parent.Name}",
-
- Channel channel => channel.Parent?.Name ?? "???",
-
- _ => null
- };
-
- public object ConvertBack(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) => throw new NotSupportedException();
-}
diff --git a/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs b/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs
new file mode 100644
index 00000000..d0922882
--- /dev/null
+++ b/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using DiscordChatExporter.Core.Discord.Data;
+
+namespace DiscordChatExporter.Gui.Converters;
+
+public class ChannelToHierarchicalNameStringConverter : IValueConverter
+{
+ public static ChannelToHierarchicalNameStringConverter Instance { get; } = new();
+
+ public object? Convert(
+ object? value,
+ Type targetType,
+ object? parameter,
+ CultureInfo culture
+ ) => value is Channel channel ? channel.GetHierarchicalName() : null;
+
+ public object ConvertBack(
+ object? value,
+ Type targetType,
+ object? parameter,
+ CultureInfo culture
+ ) => throw new NotSupportedException();
+}
diff --git a/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs b/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs
deleted file mode 100644
index 4511336e..00000000
--- a/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System;
-using System.Globalization;
-using System.Windows.Data;
-
-namespace DiscordChatExporter.Gui.Converters;
-
-[ValueConversion(typeof(DateTimeOffset?), typeof(DateTime?))]
-public class DateTimeOffsetToDateTimeConverter : IValueConverter
-{
- public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
-
- public object? Convert(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) =>
- value is DateTimeOffset dateTimeOffsetValue
- ? dateTimeOffsetValue.DateTime
- : default(DateTime?);
-
- public object? ConvertBack(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) =>
- value is DateTime dateTimeValue
- ? new DateTimeOffset(dateTimeValue)
- : default(DateTimeOffset?);
-}
diff --git a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs
index fe8a1322..1782c895 100644
--- a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs
+++ b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs
@@ -1,11 +1,10 @@
using System;
using System.Globalization;
-using System.Windows.Data;
+using Avalonia.Data.Converters;
using DiscordChatExporter.Core.Exporting;
namespace DiscordChatExporter.Gui.Converters;
-[ValueConversion(typeof(ExportFormat), typeof(string))]
public class ExportFormatToStringConverter : IValueConverter
{
public static ExportFormatToStringConverter Instance { get; } = new();
@@ -15,7 +14,7 @@ public class ExportFormatToStringConverter : IValueConverter
Type targetType,
object? parameter,
CultureInfo culture
- ) => value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default;
+ ) => value is ExportFormat format ? format.GetDisplayName() : default;
public object ConvertBack(
object? value,
diff --git a/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs b/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs
deleted file mode 100644
index d6a8ad04..00000000
--- a/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using System;
-using System.Globalization;
-using System.Windows.Data;
-
-namespace DiscordChatExporter.Gui.Converters;
-
-[ValueConversion(typeof(bool), typeof(bool))]
-public class InverseBoolConverter : IValueConverter
-{
- public static InverseBoolConverter Instance { get; } = new();
-
- public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value is false;
-
- public object ConvertBack(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) => value is false;
-}
diff --git a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
similarity index 71%
rename from DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs
rename to DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
index f7cff19c..5ec65d9d 100644
--- a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs
+++ b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
@@ -1,13 +1,12 @@
using System;
using System.Globalization;
-using System.Windows.Data;
+using Avalonia.Data.Converters;
namespace DiscordChatExporter.Gui.Converters;
-[ValueConversion(typeof(string), typeof(string))]
-public class LocaleToDisplayNameConverter : IValueConverter
+public class LocaleToDisplayNameStringConverter : IValueConverter
{
- public static LocaleToDisplayNameConverter Instance { get; } = new();
+ public static LocaleToDisplayNameStringConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string locale && !string.IsNullOrWhiteSpace(locale)
diff --git a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs b/DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs
similarity index 59%
rename from DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs
rename to DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs
index b4b0f015..11479336 100644
--- a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs
+++ b/DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs
@@ -1,21 +1,20 @@
using System;
using System.Globalization;
-using System.Windows.Data;
+using Avalonia.Data.Converters;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Gui.Converters;
-[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))]
-public class SnowflakeToDateTimeOffsetConverter : IValueConverter
+public class SnowflakeToTimestampStringConverter : IValueConverter
{
- public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new();
+ public static SnowflakeToTimestampStringConverter Instance { get; } = new();
public object? Convert(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
- ) => value is Snowflake snowflake ? snowflake.ToDate() : null;
+ ) => value is Snowflake snowflake ? snowflake.ToDate().ToString("g", culture) : null;
public object ConvertBack(
object? value,
diff --git a/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs b/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs
deleted file mode 100644
index 658d7a5f..00000000
--- a/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System;
-using System.Globalization;
-using System.Windows.Data;
-
-namespace DiscordChatExporter.Gui.Converters;
-
-[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
-public class TimeSpanToDateTimeConverter : IValueConverter
-{
- public static TimeSpanToDateTimeConverter Instance { get; } = new();
-
- public object? Convert(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) => value is TimeSpan timeSpanValue ? DateTime.Today.Add(timeSpanValue) : default(DateTime?);
-
- public object? ConvertBack(
- object? value,
- Type targetType,
- object? parameter,
- CultureInfo culture
- ) => value is DateTime dateTimeValue ? dateTimeValue.TimeOfDay : default(TimeSpan?);
-}
diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj
index fad90a59..8d32b29b 100644
--- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj
+++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj
@@ -2,28 +2,30 @@
WinExe
- $(TargetFramework)-windows
DiscordChatExporter
- true
- ../favicon.ico
+ ..\favicon.ico
-
+
+
+
+
+
-
+
+
-
+
+
-
-
-
-
-
-
+
+
+
+
diff --git a/DiscordChatExporter.Gui/FodyWeavers.xml b/DiscordChatExporter.Gui/FodyWeavers.xml
deleted file mode 100644
index 4e68ed1a..00000000
--- a/DiscordChatExporter.Gui/FodyWeavers.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/DiscordChatExporter.Gui/FodyWeavers.xsd b/DiscordChatExporter.Gui/FodyWeavers.xsd
deleted file mode 100644
index 69dbe488..00000000
--- a/DiscordChatExporter.Gui/FodyWeavers.xsd
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Used to control if the On_PropertyName_Changed feature is enabled.
-
-
-
-
- Used to control if the Dependent properties feature is enabled.
-
-
-
-
- Used to control if the IsChanged property feature is enabled.
-
-
-
-
- Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.
-
-
-
-
- Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project.
-
-
-
-
- Used to control if equality checks should use the Equals method resolved from the base class.
-
-
-
-
- Used to control if equality checks should use the static Equals method resolved from the base class.
-
-
-
-
- Used to turn off build warnings from this weaver.
-
-
-
-
- Used to turn off build warnings about mismatched On_PropertyName_Changed methods.
-
-
-
-
-
-
-
- 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.
-
-
-
-
- A comma-separated list of error codes that can be safely ignored in assembly verification.
-
-
-
-
- 'false' to turn off automatic generation of the XML Schema file.
-
-
-
-
-
\ No newline at end of file
diff --git a/DiscordChatExporter.Gui/Framework/DialogManager.cs b/DiscordChatExporter.Gui/Framework/DialogManager.cs
new file mode 100644
index 00000000..6ec2f322
--- /dev/null
+++ b/DiscordChatExporter.Gui/Framework/DialogManager.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using AsyncKeyedLock;
+using Avalonia;
+using Avalonia.Platform.Storage;
+using DialogHostAvalonia;
+using DiscordChatExporter.Gui.Utils.Extensions;
+
+namespace DiscordChatExporter.Gui.Framework;
+
+public class DialogManager : IDisposable
+{
+ private readonly AsyncNonKeyedLocker _dialogLock = new();
+
+ public async Task ShowDialogAsync(DialogViewModelBase dialog)
+ {
+ using (await _dialogLock.LockAsync())
+ {
+ await DialogHost.Show(
+ dialog,
+ // It's fine to await in a void method here because it's an event handler
+ // ReSharper disable once AsyncVoidLambda
+ async (object _, DialogOpenedEventArgs args) =>
+ {
+ await dialog.WaitForCloseAsync();
+
+ try
+ {
+ args.Session.Close();
+ }
+ catch (InvalidOperationException)
+ {
+ // Dialog host is already processing a close operation
+ }
+ }
+ );
+
+ return dialog.DialogResult;
+ }
+ }
+
+ public async Task PromptSaveFilePathAsync(
+ IReadOnlyList? fileTypes = null,
+ string defaultFilePath = ""
+ )
+ {
+ var topLevel =
+ Application.Current?.ApplicationLifetime?.TryGetTopLevel()
+ ?? throw new ApplicationException("Could not find the top-level visual element.");
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(
+ new FilePickerSaveOptions
+ {
+ FileTypeChoices = fileTypes,
+ SuggestedFileName = defaultFilePath,
+ DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.')
+ }
+ );
+
+ return file?.Path.LocalPath;
+ }
+
+ public async Task PromptDirectoryPathAsync(string defaultDirPath = "")
+ {
+ var topLevel =
+ Application.Current?.ApplicationLifetime?.TryGetTopLevel()
+ ?? throw new ApplicationException("Could not find the top-level visual element.");
+
+ var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
+ defaultDirPath
+ );
+
+ var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
+ new FolderPickerOpenOptions
+ {
+ AllowMultiple = false,
+ SuggestedStartLocation = startLocation
+ }
+ );
+
+ return folderPickResult.FirstOrDefault()?.Path.LocalPath;
+ }
+
+ public void Dispose() => _dialogLock.Dispose();
+}
diff --git a/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs b/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs
new file mode 100644
index 00000000..94f28bac
--- /dev/null
+++ b/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs
@@ -0,0 +1,25 @@
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace DiscordChatExporter.Gui.Framework;
+
+public abstract partial class DialogViewModelBase : ViewModelBase
+{
+ private readonly TaskCompletionSource _closeTcs =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ [ObservableProperty]
+ private T? _dialogResult;
+
+ [RelayCommand]
+ protected void Close(T dialogResult)
+ {
+ DialogResult = dialogResult;
+ _closeTcs.TrySetResult(dialogResult);
+ }
+
+ public async Task WaitForCloseAsync() => await _closeTcs.Task;
+}
+
+public abstract class DialogViewModelBase : DialogViewModelBase;
diff --git a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs
new file mode 100644
index 00000000..a4af9f5d
--- /dev/null
+++ b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs
@@ -0,0 +1,34 @@
+using System;
+using Avalonia.Threading;
+using Material.Styles.Controls;
+using Material.Styles.Models;
+
+namespace DiscordChatExporter.Gui.Framework;
+
+public class SnackbarManager
+{
+ private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5);
+
+ public void Notify(string message, TimeSpan? duration = null) =>
+ SnackbarHost.Post(
+ new SnackbarModel(message, duration ?? _defaultDuration),
+ null,
+ DispatcherPriority.Normal
+ );
+
+ public void Notify(
+ string message,
+ string actionText,
+ Action actionHandler,
+ TimeSpan? duration = null
+ ) =>
+ SnackbarHost.Post(
+ new SnackbarModel(
+ message,
+ duration ?? _defaultDuration,
+ new SnackbarButtonModel { Text = actionText, Action = actionHandler }
+ ),
+ null,
+ DispatcherPriority.Normal
+ );
+}
diff --git a/DiscordChatExporter.Gui/Framework/UserControl.cs b/DiscordChatExporter.Gui/Framework/UserControl.cs
new file mode 100644
index 00000000..fda1b3ea
--- /dev/null
+++ b/DiscordChatExporter.Gui/Framework/UserControl.cs
@@ -0,0 +1,18 @@
+using System;
+using Avalonia.Controls;
+
+namespace DiscordChatExporter.Gui.Framework;
+
+public class UserControl : UserControl
+{
+ public new TDataContext DataContext
+ {
+ get =>
+ base.DataContext is TDataContext dataContext
+ ? dataContext
+ : throw new InvalidCastException(
+ $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'."
+ );
+ set => base.DataContext = value;
+ }
+}
diff --git a/DiscordChatExporter.Gui/Framework/ViewManager.cs b/DiscordChatExporter.Gui/Framework/ViewManager.cs
new file mode 100644
index 00000000..f335e449
--- /dev/null
+++ b/DiscordChatExporter.Gui/Framework/ViewManager.cs
@@ -0,0 +1,37 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+
+namespace DiscordChatExporter.Gui.Framework;
+
+public partial class ViewManager
+{
+ public Control? TryBindView(ViewModelBase viewModel)
+ {
+ var name = viewModel
+ .GetType()
+ .FullName?.Replace("ViewModel", "View", StringComparison.Ordinal);
+
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+
+ var type = Type.GetType(name);
+ if (type is null)
+ return null;
+
+ if (Activator.CreateInstance(type) is not Control view)
+ return null;
+
+ view.DataContext ??= viewModel;
+
+ return view;
+ }
+}
+
+public partial class ViewManager : IDataTemplate
+{
+ bool IDataTemplate.Match(object? data) => data is ViewModelBase;
+
+ Control? ITemplate