Compare commits

...

11 Commits

Author SHA1 Message Date
Solareon
7941981f54
Merge 0414da2fb7 into 1181a35f08 2026-03-21 20:56:27 +02:00
tyrrrz
1181a35f08 Variable names consistency 2026-03-21 20:27:05 +02:00
Copilot
4a62f9c34f
Add DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE env var to suppress auto-update in GUI (#1514)
* Add DISCORDCHATEXPORTER_DISABLE_UPDATE env var to disable auto-update in GUI

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/158dba86-9958-4f57-ab22-174e0606b42f

* Rename IsAutoUpdateDisabled to IsAutoUpdateAllowed and env var to DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/aa3944a4-e542-4770-8d17-2acb2c958ce9

* Refine IsAutoUpdateAllowed: group in (), invert, rename v to env, only accept false

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/1467eee2-3069-4f0b-abdf-b1dc65e00a9a

* Fix CSharpier formatting: move is { } env to indented new line

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/7933cfe4-ecad-4697-8b9c-ee3991aa147e

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-03-21 20:25:53 +02:00
Oleksii Holub
b6e1f92989
Add the GUI flavor of the AUR package to readme 2026-03-21 17:14:45 +02:00
Oleksii Holub
0d3d2ddcf0
Add more package manager install options
Added community-maintained package installation instructions for Scoop and WinGet, and clarified notes regarding MacOS and community-maintained packages.
2026-03-21 17:03:57 +02:00
Solareon
0414da2fb7 refactor: pt 1 of redesign for generic components structure 2026-03-04 11:59:29 +01:00
Solareon
1a671b31e6 fix: remove unused import from test 2026-03-02 14:47:31 +01:00
Solareon
09f9f387d7 chore: add todo for other component types 2026-03-02 14:46:55 +01:00
Solareon
a54e011ad8 fix: add themeing support for dark/light mode for buttons 2026-03-02 14:46:55 +01:00
Solareon
231857e925 refactor: remove excessive test 2026-03-02 14:46:55 +01:00
Solareon
fcf58f5b8e feat: add action row support
this adds support for exporting the action rows sometimes found in embeds. Only was able to test with single button action rows. I used copilot to draft up a proof of concept and tweaked to get it working 100% and fixed the colors to match discord styling

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 14:46:55 +01:00
13 changed files with 353 additions and 27 deletions

View File

@ -0,0 +1,48 @@
using System;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Components;
// https://discord.com/developers/docs/components/reference#button
public partial record ButtonComponent(
ButtonStyle Style,
string? Label,
Emoji? Emoji,
string? Url,
string? CustomId,
Snowflake? SkuId,
bool IsDisabled
)
{
public bool IsUrlButton => !string.IsNullOrWhiteSpace(Url);
}
public partial record ButtonComponent
{
public static ButtonComponent Parse(JsonElement json)
{
var style =
json.GetPropertyOrNull("style")
?.GetInt32OrNull()
?.Pipe(s =>
Enum.IsDefined(typeof(ButtonStyle), s) ? (ButtonStyle)s : (ButtonStyle?)null
)
?? ButtonStyle.Secondary;
var label = json.GetPropertyOrNull("label")?.GetStringOrNull();
var emoji = json.GetPropertyOrNull("emoji")?.Pipe(Emoji.Parse);
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var customId = json.GetPropertyOrNull("custom_id")?.GetNonWhiteSpaceStringOrNull();
var skuId = json.GetPropertyOrNull("sku_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
var isDisabled = json.GetPropertyOrNull("disabled")?.GetBooleanOrNull() ?? false;
return new ButtonComponent(style, label, emoji, url, customId, skuId, isDisabled);
}
}

View File

@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Discord.Data.Components;
// https://discord.com/developers/docs/components/reference#button-button-styles
public enum ButtonStyle
{
Primary = 1,
Secondary = 2,
Success = 3,
Danger = 4,
Link = 5,
Premium = 6,
}

View File

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Components;
// https://docs.discord.com/developers/components/reference#what-is-a-component
public partial record MessageComponent(
MessageComponentKind Kind,
IReadOnlyList<MessageComponent> Components,
ButtonComponent? Button
)
{
public bool HasButtons => Button is not null || Components.Any(c => c.HasButtons);
public IReadOnlyList<ButtonComponent> Buttons =>
Components.Select(c => c.Button).WhereNotNull().ToArray();
}
public partial record MessageComponent
{
public static MessageComponent? Parse(JsonElement json)
{
var rawType = json.GetPropertyOrNull("type")?.GetInt32OrNull();
if (rawType is null)
return null;
var type = rawType.Value;
if (!Enum.IsDefined(typeof(MessageComponentKind), type))
return null;
var kind = (MessageComponentKind)type;
var components =
json.GetPropertyOrNull("components")
?.EnumerateArrayOrNull()
?.Select(Parse)
.WhereNotNull()
.ToArray()
?? [];
var button = kind == MessageComponentKind.Button ? ButtonComponent.Parse(json) : null;
return new MessageComponent(kind, components, button);
}
}

View File

@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Discord.Data.Components;
// https://discord.com/developers/docs/components/reference#component-object-component-types
public enum MessageComponentKind
{
ActionRow = 1,
Button = 2,
StringSelect = 3,
TextInput = 4,
UserSelect = 5,
RoleSelect = 6,
MentionableSelect = 7,
ChannelSelect = 8,
Section = 9,
TextDisplay = 10,
Thumbnail = 11,
MediaGallery = 12,
File = 13,
Separator = 14,
Container = 17,
Label = 18,
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Discord.Data.Components;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
@ -23,6 +24,7 @@ public partial record Message(
IReadOnlyList<Attachment> Attachments,
IReadOnlyList<Embed> Embeds,
IReadOnlyList<Sticker> Stickers,
IReadOnlyList<MessageComponent> Components,
IReadOnlyList<Reaction> Reactions,
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
@ -34,6 +36,7 @@ public partial record Message(
public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Components.Any()
&& !Embeds.Any()
&& !Stickers.Any();
@ -161,6 +164,14 @@ public partial record Message
.ToArray()
?? [];
var components =
json.GetPropertyOrNull("components")
?.EnumerateArrayOrNull()
?.Select(MessageComponent.Parse)
.WhereNotNull()
.ToArray()
?? [];
var reactions =
json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull()
@ -200,6 +211,7 @@ public partial record Message
attachments,
embeds,
stickers,
components,
reactions,
mentionedUsers,
messageReference,

View File

@ -7,6 +7,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Components;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@ -369,6 +370,46 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
);
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteComponentAsync(
MessageComponent component,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("type", component.Kind.ToString());
if (component.Button is not null)
{
_writer.WriteString("style", component.Button.Style.ToString());
_writer.WriteString("label", component.Button.Label);
_writer.WriteString("url", component.Button.Url);
_writer.WriteString("customId", component.Button.CustomId);
_writer.WriteString("skuId", component.Button.SkuId?.ToString());
_writer.WriteBoolean("isDisabled", component.Button.IsDisabled);
if (component.Button.Emoji is not null)
{
_writer.WritePropertyName("emoji");
await WriteEmojiAsync(component.Button.Emoji, cancellationToken);
}
}
if (component.Components.Any())
{
_writer.WriteStartArray("components");
foreach (var child in component.Components)
await WriteComponentAsync(child, cancellationToken);
_writer.WriteEndArray();
}
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
public override async ValueTask WritePreambleAsync(
@ -477,6 +518,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteEndArray();
// Components
_writer.WriteStartArray("components");
foreach (var component in message.Components)
await WriteComponentAsync(component, cancellationToken);
_writer.WriteEndArray();
// Embeds
_writer.WriteStartArray("embeds");

View File

@ -3,6 +3,7 @@
@using System.Linq
@using System.Threading.Tasks
@using DiscordChatExporter.Core.Discord.Data
@using DiscordChatExporter.Core.Discord.Data.Components
@using DiscordChatExporter.Core.Discord.Data.Embeds
@using DiscordChatExporter.Core.Markdown.Parsing
@using DiscordChatExporter.Core.Utils.Extensions
@ -731,6 +732,61 @@
}
}
@* Components *@
@if (message.Components.Any(c => c.Kind == MessageComponentKind.ActionRow && c.HasButtons))
{
<div class="chatlog__components">
@foreach (var actionRow in message.Components.Where(c => c.Kind == MessageComponentKind.ActionRow && c.HasButtons))
{
<div class="chatlog__action-row">
@foreach (var button in actionRow.Buttons)
{
var styleClass = button.Style switch {
ButtonStyle.Primary => "chatlog__component-button--primary",
ButtonStyle.Secondary => "chatlog__component-button--secondary",
ButtonStyle.Success => "chatlog__component-button--success",
ButtonStyle.Danger => "chatlog__component-button--danger",
ButtonStyle.Link => "chatlog__component-button--link",
ButtonStyle.Premium => "chatlog__component-button--premium",
_ => "chatlog__component-button--secondary"
};
var isUrlButton = button.IsUrlButton;
if (isUrlButton)
{
<a class="chatlog__component-button @styleClass" href="@button.Url" target="_blank" rel="noreferrer noopener">
@if (button.Emoji is not null)
{
<img class="chatlog__emoji chatlog__emoji--small" alt="@button.Emoji.Name" src="@await ResolveAssetUrlAsync(button.Emoji.ImageUrl)" loading="lazy">
}
@if (!string.IsNullOrWhiteSpace(button.Label))
{
<span>@button.Label</span>
}
</a>
}
else
{
<button class="chatlog__component-button @styleClass" type="button" disabled>
@if (button.Emoji is not null)
{
<img class="chatlog__emoji chatlog__emoji--small" alt="@button.Emoji.Name" src="@await ResolveAssetUrlAsync(button.Emoji.ImageUrl)" loading="lazy">
}
@if (!string.IsNullOrWhiteSpace(button.Label))
{
<span>@button.Label</span>
}
</button>
}
}
</div>
}
</div>
}
@* Stickers *@
@foreach (var sticker in message.Stickers)
{

View File

@ -430,6 +430,72 @@
font-weight: 500;
}
.chatlog__components {
margin-top: 0.3rem;
}
.chatlog__action-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.3rem;
}
.chatlog__component-button {
display: inline-flex;
gap: 0.35rem;
align-items: center;
justify-content: center;
min-height: 2rem;
min-width: 3.75rem;
padding: 0 1rem;
border: 0;
border-radius: 3px;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: default;
box-sizing: border-box;
}
a.chatlog__component-button:hover {
text-decoration: none;
filter: brightness(0.95);
}
.chatlog__component-button--primary {
background-color: #5865f2;
color: #ffffff;
}
.chatlog__component-button--secondary {
background-color: @Themed("#4e5058", "#e3e5e8");
color: @Themed("#ffffff", "#313338");
}
.chatlog__component-button--success {
background-color: #248046;
color: #ffffff;
}
.chatlog__component-button--danger {
background-color: #da373c;
color: #ffffff;
}
.chatlog__component-button--link {
background-color: @Themed("#4e5058", "#97979f");
color: @Themed("#ffffff", "#2F3035");
}
.chatlog__component-button--premium {
background-color: #5865f2;
color: #ffffff;
}
.chatlog__attachment {
position: relative;
width: fit-content;

View File

@ -9,24 +9,25 @@ namespace DiscordChatExporter.Gui.Services;
public class UpdateService(SettingsService settingsService) : IDisposable
{
private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
? new UpdateManager(
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
)
: null;
private readonly IUpdateManager? _updateManager =
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed
? new UpdateManager(
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
)
: null;
private Version? _updateVersion;
private bool _updatePrepared;
private bool _updaterLaunched;
private bool _isUpdatePrepared;
private bool _isUpdaterLaunched;
public async ValueTask<Version?> CheckForUpdatesAsync()
{
@ -51,7 +52,7 @@ public class UpdateService(SettingsService settingsService) : IDisposable
try
{
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
_updatePrepared = true;
_isUpdatePrepared = true;
}
catch (UpdaterAlreadyLaunchedException)
{
@ -71,13 +72,13 @@ public class UpdateService(SettingsService settingsService) : IDisposable
if (!settingsService.IsAutoUpdateEnabled)
return;
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
if (_updateVersion is null || !_isUpdatePrepared || _isUpdaterLaunched)
return;
try
{
_updateManager.LaunchUpdater(_updateVersion, needRestart);
_updaterLaunched = true;
_isUpdaterLaunched = true;
}
catch (UpdaterAlreadyLaunchedException)
{

View File

@ -6,6 +6,8 @@ namespace DiscordChatExporter.Gui;
public partial class StartOptions
{
public required string SettingsPath { get; init; }
public required bool IsAutoUpdateAllowed { get; init; }
}
public partial class StartOptions
@ -20,5 +22,10 @@ public partial class StartOptions
? Path.Combine(path, "Settings.dat")
: path
: Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
IsAutoUpdateAllowed = !(
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE")
is { } env
&& env.Equals("false", StringComparison.OrdinalIgnoreCase)
),
};
}

View File

@ -46,6 +46,9 @@ public class SettingsViewModel : DialogViewModelBase
set => _settingsService.Language = value;
}
public bool IsAutoUpdateAvailable { get; } =
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed;
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;

View File

@ -50,8 +50,7 @@
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
IsVisible="{OnPlatform False,
Windows=True}"
IsVisible="{Binding IsAutoUpdateAvailable}"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />

View File

@ -43,23 +43,25 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
- **Graphical user interface** (desktop app):
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.*.zip`
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.*.zip`
- 📦 [Scoop](https://scoop.sh/#/apps?q=DiscordChatExporter&p=1&id=c71b7367623c560a2dc746b9739b9568b79b59ae): `scoop install extras/discordchatexporter` (community-maintained)
- 📦 [WinGet](https://winget.run/pkg/Tyrrrz/DiscordChatExporter.GUI): `winget install Tyrrrz.DiscordChatExporter.GUI` (community-maintained)
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-gui): `yay -S discord-chat-exporter-gui` (community-maintained)
- **Command-line interface** (terminal app):
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.Cli.*.zip`
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.Cli.*.zip`
- 🐋 [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `yay -S discord-chat-exporter-cli` (community-maintained)
- 📦 [Nix](https://search.nixos.org/packages?show=discordchatexporter-cli): `nix-shell -p discordchatexporter-cli` (community-maintained)
> [!IMPORTANT]
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
> To launch the GUI version of the app on MacOS, you may need to first remove the downloaded file from quarantine.
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
> [!NOTE]
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
> Community-maintained packages are published independently from this repository and may not always be up to date with the latest release.
> [!NOTE]
> AUR and Nix packages linked above are maintained by the community.
> If you have any issues with them, please contact the corresponding maintainers.
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
## Features