mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-04-26 08:15:20 -05:00
Compare commits
11 Commits
f53f8f9fba
...
7941981f54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7941981f54 | ||
|
|
1181a35f08 | ||
|
|
4a62f9c34f | ||
|
|
b6e1f92989 | ||
|
|
0d3d2ddcf0 | ||
|
|
0414da2fb7 | ||
|
|
1a671b31e6 | ||
|
|
09f9f387d7 | ||
|
|
a54e011ad8 | ||
|
|
231857e925 | ||
|
|
fcf58f5b8e |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}" />
|
||||
|
|
|
|||
14
Readme.md
14
Readme.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user