diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 22407f6f..81496ef4 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -27,7 +27,8 @@ public partial record Message( IReadOnlyList MentionedUsers, MessageReference? Reference, Message? ReferencedMessage, - Interaction? Interaction + Interaction? Interaction, + MessageSnapshot? ForwardedMessage ) : IHasId { public bool IsSystemNotification { get; } = @@ -38,6 +39,9 @@ public partial record Message( // App interactions are rendered as replies in the Discord client, but they are not actually replies public bool IsReplyLike => IsReply || Interaction is not null; + // A message is a forward if its reference type is Forward + public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward; + public bool IsEmpty { get; } = string.IsNullOrWhiteSpace(Content) && !Attachments.Any() @@ -174,6 +178,13 @@ public partial record Message var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); + // Parse message snapshots for forwarded messages + // Currently Discord only supports 1 snapshot per forward + var forwardedMessage = json.GetPropertyOrNull("message_snapshots") + ?.EnumerateArrayOrNull() + ?.Select(MessageSnapshot.Parse) + .FirstOrDefault(); + return new Message( id, kind, @@ -191,7 +202,8 @@ public partial record Message mentionedUsers, messageReference, referencedMessage, - interaction + interaction, + forwardedMessage ); } } diff --git a/DiscordChatExporter.Core/Discord/Data/MessageReference.cs b/DiscordChatExporter.Core/Discord/Data/MessageReference.cs index 6028e942..af024f4c 100644 --- a/DiscordChatExporter.Core/Discord/Data/MessageReference.cs +++ b/DiscordChatExporter.Core/Discord/Data/MessageReference.cs @@ -5,7 +5,12 @@ using JsonExtensions.Reading; namespace DiscordChatExporter.Core.Discord.Data; // https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure -public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId) +public record MessageReference( + Snowflake? MessageId, + Snowflake? ChannelId, + Snowflake? GuildId, + MessageReferenceKind Kind +) { public static MessageReference Parse(JsonElement json) { @@ -21,6 +26,10 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf ?.GetNonWhiteSpaceStringOrNull() ?.Pipe(Snowflake.Parse); - return new MessageReference(messageId, channelId, guildId); + var kind = + json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t) + ?? MessageReferenceKind.Default; + + return new MessageReference(messageId, channelId, guildId, kind); } } diff --git a/DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs b/DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs new file mode 100644 index 00000000..6576a283 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs @@ -0,0 +1,8 @@ +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/resources/channel#message-reference-types +public enum MessageReferenceKind +{ + Default = 0, + Forward = 1, +} diff --git a/DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs b/DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs new file mode 100644 index 00000000..bc51515b --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data.Embeds; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/resources/channel#message-snapshot-object +// Message snapshots contain a subset of message fields for forwarded messages +public record MessageSnapshot( + string Content, + IReadOnlyList Attachments, + IReadOnlyList Embeds, + IReadOnlyList Stickers, + DateTimeOffset Timestamp, + DateTimeOffset? EditedTimestamp +) +{ + public static MessageSnapshot Parse(JsonElement json) + { + // The message snapshot has a "message" property containing the actual message data + var messageJson = json.GetPropertyOrNull("message") ?? json; + + var content = messageJson.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""; + + var attachments = + messageJson + .GetPropertyOrNull("attachments") + ?.EnumerateArrayOrNull() + ?.Select(Attachment.Parse) + .ToArray() ?? []; + + var embeds = + messageJson + .GetPropertyOrNull("embeds") + ?.EnumerateArrayOrNull() + ?.Select(Embed.Parse) + .ToArray() ?? []; + + var stickers = + messageJson + .GetPropertyOrNull("sticker_items") + ?.EnumerateArrayOrNull() + ?.Select(Sticker.Parse) + .ToArray() ?? []; + + var timestamp = + messageJson.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull() + ?? DateTimeOffset.MinValue; + + var editedTimestamp = messageJson + .GetPropertyOrNull("edited_timestamp") + ?.GetDateTimeOffsetOrNull(); + + return new MessageSnapshot( + content, + attachments, + embeds, + stickers, + timestamp, + editedTimestamp + ); + } +} diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index fd99bacb..5b959da7 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -530,6 +530,70 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteString("messageId", message.Reference.MessageId?.ToString()); _writer.WriteString("channelId", message.Reference.ChannelId?.ToString()); _writer.WriteString("guildId", message.Reference.GuildId?.ToString()); + _writer.WriteString("type", message.Reference.Kind.ToString()); + _writer.WriteEndObject(); + } + + // Forwarded message + if (message.ForwardedMessage is not null) + { + _writer.WriteStartObject("forwardedMessage"); + + _writer.WriteString( + "content", + await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken) + ); + + _writer.WriteString( + "timestamp", + message.ForwardedMessage.Timestamp != DateTimeOffset.MinValue + ? Context.NormalizeDate(message.ForwardedMessage.Timestamp) + : null + ); + + _writer.WriteString( + "timestampEdited", + message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate) + ); + + // Forwarded attachments + _writer.WriteStartArray("attachments"); + foreach (var attachment in message.ForwardedMessage.Attachments) + { + _writer.WriteStartObject(); + _writer.WriteString("id", attachment.Id.ToString()); + _writer.WriteString( + "url", + await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken) + ); + _writer.WriteString("fileName", attachment.FileName); + _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); + _writer.WriteEndObject(); + } + _writer.WriteEndArray(); + + // Forwarded embeds + _writer.WriteStartArray("embeds"); + foreach (var embed in message.ForwardedMessage.Embeds) + await WriteEmbedAsync(embed, cancellationToken); + _writer.WriteEndArray(); + + // Forwarded stickers + _writer.WriteStartArray("stickers"); + foreach (var sticker in message.ForwardedMessage.Stickers) + { + _writer.WriteStartObject(); + _writer.WriteString("id", sticker.Id.ToString()); + _writer.WriteString("name", sticker.Name); + _writer.WriteString("format", sticker.Format.ToString()); + _writer.WriteString( + "sourceUrl", + await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken) + ); + _writer.WriteEndObject(); + } + _writer.WriteEndArray(); + _writer.WriteEndObject(); } diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 641c2ada..44d521d9 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -262,6 +262,96 @@ } + @* Forwarded message content *@ + @if (message.IsForwarded && message.ForwardedMessage is not null) + { + var fwd = message.ForwardedMessage; +
+
+ + + + Forwarded +
+ + @* Forwarded text content *@ + @if (!string.IsNullOrWhiteSpace(fwd.Content)) + { +
+ @Html.Raw(await FormatMarkdownAsync(fwd.Content)) +
+ } + + @* Forwarded attachments *@ + @if (fwd.Attachments.Any()) + { +
+ @foreach (var attachment in fwd.Attachments) + { + @if (attachment.IsImage) + { + + @(attachment.Description ?? + + } + else if (attachment.IsVideo) + { + + } + else if (attachment.IsAudio) + { + + } + else + { +
+ + + + +
+ @attachment.FileSize +
+
+ } + } +
+ } + + @* Forwarded stickers *@ + @foreach (var sticker in fwd.Stickers) + { +
+ @if (sticker.IsImage) + { + Sticker + } + else if (sticker.Format == StickerFormat.Lottie) + { +
+ } +
+ } + + @* Forwarded timestamp *@ +
+ Originally sent: @FormatDate(fwd.Timestamp) + @if (fwd.EditedTimestamp is not null) + { + (edited) + } +
+
+ } + @* Attachments *@ @foreach (var attachment in message.Attachments) { diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs index 85b46a64..ca5b6fb7 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -224,6 +225,34 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context) await _writer.WriteLineAsync(); } + private async ValueTask WriteForwardedMessageAsync( + MessageSnapshot forwardedMessage, + CancellationToken cancellationToken = default + ) + { + await _writer.WriteLineAsync("{Forwarded Message}"); + + if (!string.IsNullOrWhiteSpace(forwardedMessage.Content)) + { + await _writer.WriteLineAsync( + await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken) + ); + } + + if (forwardedMessage.Timestamp != DateTimeOffset.MinValue) + { + await _writer.WriteLineAsync( + $"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}" + ); + } + + await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken); + await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken); + await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken); + + await _writer.WriteLineAsync(); + } + public override async ValueTask WriteMessageAsync( Message message, CancellationToken cancellationToken = default @@ -248,6 +277,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context) await _writer.WriteLineAsync(); + // Forwarded message content + if (message.ForwardedMessage is not null) + { + await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken); + } + // Attachments, embeds, reactions, etc. await WriteAttachmentsAsync(message.Attachments, cancellationToken); await WriteEmbedsAsync(message.Embeds, cancellationToken); diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 792612c7..3b9cad68 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -304,6 +304,52 @@ unicode-bidi: bidi-override; } + .chatlog__forwarded { + display: flex; + flex-direction: column; + margin-bottom: 0.15rem; + padding: 0.5rem; + border-left: 4px solid @Themed("#4f545c", "#c7ccd1"); + border-radius: 4px; + background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)"); + } + + .chatlog__forwarded-header { + display: flex; + align-items: center; + margin-bottom: 0.25rem; + color: @Themed("#b5b6b8", "#5f5f60"); + font-size: 0.75rem; + font-weight: 600; + } + + .chatlog__forwarded-icon { + width: 16px; + height: 16px; + margin-right: 0.25rem; + } + + .chatlog__forwarded-content { + color: @Themed("#dcddde", "#2e3338"); + font-size: 0.95rem; + } + + .chatlog__forwarded-attachments { + margin-top: 0.3rem; + } + + .chatlog__forwarded-attachment { + max-width: 300px; + max-height: 200px; + border-radius: 3px; + } + + .chatlog__forwarded-timestamp { + margin-top: 0.25rem; + color: @Themed("#a3a6aa", "#5e6772"); + font-size: 0.75rem; + } + .chatlog__system-notification-icon { width: 18px; height: 18px; @@ -999,6 +1045,9 @@ + + + diff --git a/DiscordChatExporter.Gui/Framework/DialogManager.cs b/DiscordChatExporter.Gui/Framework/DialogManager.cs index 0f6a8f86..88e3e371 100644 --- a/DiscordChatExporter.Gui/Framework/DialogManager.cs +++ b/DiscordChatExporter.Gui/Framework/DialogManager.cs @@ -38,6 +38,10 @@ public class DialogManager : IDisposable } ); + // Yield to allow DialogHost to reset its state + // before another dialog can be shown + await Task.Yield(); + return dialog.DialogResult; } }