Compare commits

...

32 Commits

Author SHA1 Message Date
Oleksii Holub f7f6ac9494 Update version 2022-04-10 21:43:52 +03:00
Oleksii Holub 481958f4d3 Update Readme.md 2022-04-09 21:58:41 +03:00
Oleksii Holub 5e3502cbc5 Update Readme.md 2022-04-09 21:57:34 +03:00
Oleksii Holub 3b03c30865 Update screenshots in readme 2022-04-09 21:04:03 +03:00
Oleksii Holub c9e16a7f74 Spaces in css rules 2022-04-09 20:37:12 +03:00
Oleksii Holub f9b6793d47 Update margins in HTML embeds 2022-04-09 20:30:17 +03:00
Oleksii Holub cfbd267d3a Fix embed styles in HTML 2022-04-09 20:25:13 +03:00
Oleksii Holub 2b6f01bf6f Fix font sizes in HTML template 2022-04-09 20:08:37 +03:00
Oleksii Holub a44d43eefc Use a lock when opening dialogs 2022-04-09 03:17:30 +03:00
Oleksii Holub 0e138bac7d Improve top-level spacing in html template 2022-04-09 03:05:54 +03:00
Oleksii Holub 71876febf8 Use attachment description as alt text in html
Closes #805
2022-04-09 02:52:57 +03:00
Oleksii Holub 489c786c1e Pass token without validation
Fixes #828
2022-04-09 02:46:40 +03:00
Oleksii Holub eb0c61e173 Try to fix remaining test 2022-04-09 02:35:07 +03:00
Oleksii Holub 9341c21963 Add clickable links to usage guide in GUI 2022-04-09 02:31:52 +03:00
Oleksii Holub 704c483074 Update nuget packages 2022-04-09 02:22:32 +03:00
Oleksii Holub 1f0e667085 Fix missing awaits in preamble template 2022-04-09 02:21:49 +03:00
Oleksii Holub 0b72b73d1d Fix more tests 2022-04-09 02:13:58 +03:00
Oleksii Holub 32dd1689d0 Fix tests 2022-04-09 02:07:51 +03:00
Oleksii Holub 2e4d19671a Append channel name for export errors in GUI 2022-04-09 02:01:19 +03:00
Oleksii Holub 5b4d88ca26 Update HTML layout and styles 2022-04-09 01:58:13 +03:00
Chris Helder 65cc3954b6 Use 512x512 user profile pictures (#826) 2022-04-08 00:39:07 +03:00
Oleksii Holub ffe5544f9c Dowload fonts in HTML export when using download media 2022-04-07 20:26:12 +03:00
Oleksii Holub bf1ad07a9c Use coverlet collector 2022-04-06 21:17:47 +00:00
Oleksii Holub 338566c1d6 More cleanup 2022-04-06 19:43:10 +03:00
Oleksii Holub 91ea7a443c More cleanup 2022-04-06 19:12:27 +03:00
Oleksii Holub aea96d5eba Cleanup 2022-04-06 19:00:19 +03:00
Oleksii Holub e29f08264c Refactor portions of RootViewModel into DashboardViewModel 2022-04-04 01:34:46 +03:00
Oleksii Holub ad84ecf6a4 Fix hyperlink style 2022-04-02 01:11:13 +03:00
Oleksii Holub ae57a0ff7c Update readme 2022-04-01 02:27:01 +03:00
Oleksii Holub 1daff4178d Cleanup GUI 2022-04-01 00:10:17 +03:00
Oleksii Holub 3a2b119618 Update Readme.md 2022-03-22 11:02:14 -07:00
Oleksii Holub 5f27426f44 Update Readme.md 2022-03-21 10:24:44 -07:00
46 changed files with 1489 additions and 1305 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Build & test - name: Build & test
# Tests need access to secrets, so we can't run them against PRs because of limited trust # Tests need access to secrets, so we can't run them against PRs because of limited trust
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}
run: dotnet test --configuration Release --logger GitHubActions run: dotnet test --configuration Release --logger GitHubActions --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
env: env:
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
DISCORD_TOKEN_BOT: true DISCORD_TOKEN_BOT: true
@@ -45,4 +45,4 @@ jobs:
uses: actions/upload-artifact@v1 uses: actions/upload-artifact@v1
with: with:
name: DiscordChatExporter name: DiscordChatExporter
path: DiscordChatExporter.Gui/bin/Publish/ path: DiscordChatExporter.Gui/bin/Publish/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+11
View File
@@ -1,3 +1,14 @@
### v2.34 (10-Apr-2022)
- [HTML] Reworked layout to be more consistent with Discord.
- [HTML] Changed user profile picture dimensions from `128x128` to `512x512`. (Thanks [@Chris Helder](https://github.com/TheDude53))
- [HTML] Improved image scaling for Chromium-based browsers. This should result in higher quality images across the entire export.
- [HTML] Hovering over messages will now show short timestamps on the left, indicating the time that the individual messages of the group were sent.
- [HTML] Using "download media" option will now also download scripts, styles, fonts, and other similar resources used by the export.
- [HTML] Image attachments will now use the provided description as alt text.
- [HTML] Fixed a few layout inconsistencies related to embeds.
- [GUI] Added a few clickable links to the usage guide to make the process of locating tokens easier.
### v2.33.2 (07-Mar-2022) ### v2.33.2 (07-Mar-2022)
- Actually fixed it this time. - Actually fixed it this time.
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2.33.2</Version> <Version>2.34</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -1,28 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<NoWarn>$(NoWarn);xUnit1013</NoWarn> <NoWarn>$(NoWarn);xUnit1013</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" /> <None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="0.16.1" /> <PackageReference Include="AngleSharp" Version="0.16.1" />
<PackageReference Include="FluentAssertions" Version="6.5.1" /> <PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="coverlet.msbuild" Version="3.1.2" PrivateAssets="all" />
<PackageReference Include="System.Reactive" Version="5.0.0" /> <PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -42,7 +42,7 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
Snowflake.Parse("885654862656843786") Snowflake.Parse("885654862656843786")
); );
var imageUrl = message.QuerySelector("img")?.GetAttribute("src"); var imageUrl = message.QuerySelector(".chatlog__attachment img")?.GetAttribute("src");
// Assert // Assert
message.Text().Should().Contain("Image attachment"); message.Text().Should().Contain("Image attachment");
@@ -20,7 +20,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
); );
// Assert // Assert
message.Text().Trim().Should().Be("User mention: @Tyrrrz"); message.Text().Trim().Should().Contain("User mention: @Tyrrrz");
message.InnerHtml.Should().Contain("Tyrrrz#5447"); message.InnerHtml.Should().Contain("Tyrrrz#5447");
} }
@@ -34,7 +34,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
); );
// Assert // Assert
message.Text().Trim().Should().Be("Text channel mention: #mention-tests"); message.Text().Trim().Should().Contain("Text channel mention: #mention-tests");
} }
[Fact] [Fact]
@@ -47,7 +47,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
); );
// Assert // Assert
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc"); message.Text().Trim().Should().Contain("Voice channel mention: 🔊chaos-vc");
} }
[Fact] [Fact]
@@ -60,6 +60,6 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
); );
// Assert // Assert
message.Text().Trim().Should().Be("Role mention: @Role 1"); message.Text().Trim().Should().Contain("Role mention: @Role 1");
} }
} }
@@ -20,8 +20,8 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
); );
// Assert // Assert
message.Text().Trim().Should().Be("reply to original"); message.Text().Trim().Should().Contain("reply to original");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original"); message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Contain("original");
} }
[Fact] [Fact]
@@ -34,13 +34,13 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
); );
// Assert // Assert
message.Text().Trim().Should().Be("reply to deleted"); message.Text().Trim().Should().Contain("reply to deleted");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should() message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
.Be("Original message was deleted or could not be loaded."); .Contain("Original message was deleted or could not be loaded.");
} }
[Fact] [Fact]
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly() public async Task Reply_to_an_empty_message_with_attachment_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -49,8 +49,8 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
); );
// Assert // Assert
message.Text().Trim().Should().Be("reply to attachment"); message.Text().Trim().Should().Contain("reply to attachment");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should() message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
.Be("Click to see attachment 🖼️"); .Contain("Click to see attachment");
} }
} }
@@ -59,6 +59,6 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Assert // Assert
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*") Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
.Should() .Should()
.HaveCount(3); .HaveCount(8);
} }
} }
@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.2.2" /> <PackageReference Include="CliFx" Version="2.2.2" />
<PackageReference Include="Spectre.Console" Version="0.43.0" /> <PackageReference Include="Spectre.Console" Version="0.44.0" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
</ItemGroup> </ItemGroup>
@@ -13,6 +13,7 @@ public partial record Attachment(
Snowflake Id, Snowflake Id,
string Url, string Url,
string FileName, string FileName,
string? Description,
int? Width, int? Width,
int? Height, int? Height,
FileSize FileSize) : IHasId FileSize FileSize) : IHasId
@@ -37,8 +38,9 @@ public partial record Attachment
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
var fileName = json.GetProperty("filename").GetNonNullString(); var fileName = json.GetProperty("filename").GetNonNullString();
var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull();
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes); var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
return new Attachment(id, url, fileName, width, height, fileSize); return new Attachment(id, url, fileName, description, width, height, fileSize);
} }
} }
@@ -30,7 +30,7 @@ public partial record User
? "gif" ? "gif"
: "png"; : "png";
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128"; return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512";
} }
public static User Parse(JsonElement json) public static User Parse(JsonElement json)
@@ -47,4 +47,4 @@ public partial record User
return new User(id, isBot, discriminator, name, avatarUrl); return new User(id, isBot, discriminator, name, avatarUrl);
} }
} }
@@ -34,9 +34,12 @@ public class DiscordClient
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = isBot // Don't validate because token can have invalid characters
? new AuthenticationHeaderValue("Bot", _token) // https://github.com/Tyrrrz/DiscordChatExporter/issues/828
: new AuthenticationHeaderValue(_token); request.Headers.TryAddWithoutValidation(
"Authorization",
isBot ? $"Bot {_token}" : _token
);
return await Http.Client.SendAsync( return await Http.Client.SendAsync(
request, request,
@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" /> <PackageReference Include="MiniRazor.CodeGen" Version="2.2.1" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
</ItemGroup> </ItemGroup>
@@ -1,5 +1,4 @@
using System; using System.IO;
using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
@@ -60,8 +59,8 @@ public partial record ExportRequest
"%C" => channel.Name, "%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0", "%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0", "%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"), "%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"), "%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%%" => "%", "%%" => "%",
_ => m.Value _ => m.Value
}) })
@@ -93,7 +92,7 @@ public partial record ExportRequest
// Date range // Date range
if (after is not null || before is not null) if (after is not null || before is not null)
{ {
buffer.Append(" ("); buffer.Append(' ').Append('(');
// Both 'after' and 'before' are set // Both 'after' and 'before' are set
if (after is not null && before is not null) if (after is not null && before is not null)
@@ -111,11 +110,11 @@ public partial record ExportRequest
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}"); buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
} }
buffer.Append(")"); buffer.Append(')');
} }
// File extension // File extension
buffer.Append($".{format.GetFileExtension()}"); buffer.Append('.').Append(format.GetFileExtension());
return PathEx.EscapeFileName(buffer.ToString()); return PathEx.EscapeFileName(buffer.ToString());
} }
@@ -103,7 +103,7 @@ internal partial class MediaDownloader
// Otherwise, use the original file name but inject the hash in the middle // Otherwise, use the original file name but inject the hash in the middle
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var fileExtension = Path.GetExtension(fileName); var fileExtension = Path.GetExtension(fileName);
// Probably not a file extension, just a dot in a long file name // Probably not a file extension, just a dot in a long file name
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708 // https://github.com/Tyrrrz/DiscordChatExporter/issues/708
if (fileExtension.Length > 41) if (fileExtension.Length > 41)
@@ -114,4 +114,4 @@ internal partial class MediaDownloader
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension); return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
} }
} }
@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
// Used for grouping contiguous messages in HTML export
internal partial class MessageGroup
{
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageReference? Reference { get; }
public Message? ReferencedMessage {get; }
public MessageGroup(
User author,
DateTimeOffset timestamp,
MessageReference? reference,
Message? referencedMessage,
IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Reference = reference;
ReferencedMessage = referencedMessage;
Messages = messages;
}
}
internal partial class MessageGroup
{
public static bool CanJoin(Message message1, Message message2) =>
// Must be from the same author
message1.Author.Id == message2.Author.Id &&
// Author's name must not have changed between messages
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
// Duration between messages must be 7 minutes or less
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
// Other message must not be a reply
message2.Reference is null;
public static MessageGroup Join(IReadOnlyList<Message> messages)
{
var first = messages.First();
return new MessageGroup(
first.Author,
first.Timestamp,
first.Reference,
first.ReferencedMessage,
messages
);
}
}
@@ -3,126 +3,144 @@
@using System.Threading.Tasks @using System.Threading.Tasks
@using DiscordChatExporter.Core.Discord.Data @using DiscordChatExporter.Core.Discord.Data
@using DiscordChatExporter.Core.Exporting.Writers.Html; @using DiscordChatExporter.Core.Exporting.Writers.Html;
@using DiscordChatExporter.Core.Utils.Extensions
@namespace DiscordChatExporter.Core.Exporting.Writers.Html @namespace DiscordChatExporter.Core.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext> @inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{ @{
var firstMessage = Model.Messages.First();
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown); string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false); string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url); var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id); var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id); var userNick = firstMessage.Author.IsBot
? firstMessage.Author.Name
: userMember?.Nick ?? firstMessage.Author.Name;
var userColorStyle = userColor is not null var referencedUserMember = firstMessage.ReferencedMessage is not null
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})" ? Model.ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id)
: null; : null;
var userNick = Model.MessageGroup.Author.IsBot var referencedUserColor = firstMessage.ReferencedMessage is not null
? Model.MessageGroup.Author.Name ? Model.ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id)
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
: null; : null;
var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null var referencedUserNick = firstMessage.ReferencedMessage is not null
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id) ? firstMessage.ReferencedMessage.Author.IsBot
: null; ? firstMessage.ReferencedMessage.Author.Name
: referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name
var referencedUserColorStyle = referencedUserColor is not null
? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})"
: null;
var referencedUserNick = Model.MessageGroup.ReferencedMessage is not null
? Model.MessageGroup.ReferencedMessage.Author.IsBot
? Model.MessageGroup.ReferencedMessage.Author.Name
: referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name
: null; : null;
} }
<div class="chatlog__message-group"> <div class="chatlog__message-group">
@{/* Referenced message */} @for (var i = 0; i < Model.Messages.Count; i++)
@if (Model.MessageGroup.Reference is not null) {
{ var isFirst = i == 0;
<div class="chatlog__reference-symbol"></div> var message = Model.Messages[i];
<div class="chatlog__reference">
@if (Model.MessageGroup.ReferencedMessage is not null)
{
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
<div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
{
@Raw(FormatEmbedMarkdown(Model.MessageGroup.ReferencedMessage.Content))
}
else if (Model.MessageGroup.ReferencedMessage.Attachments.Any() || Model.MessageGroup.ReferencedMessage.Embeds.Any())
{
<em>Click to see attachment</em> <span>🖼️</span>
}
else
{
<em>Click to see original message</em>
}
</span>
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null) <div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
{ <div class="chatlog__message">
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span> @{/* Left side */}
} <div class="chatlog__message-aside">
</div> @if (isFirst)
}
else
{
<span class="chatlog__reference-unknown">
Original message was deleted or could not be loaded.
</span>
}
</div>
}
@{/* Avatar */}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar" loading="lazy">
</div>
<div class="chatlog__messages">
@{/* Author name */}
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
@{/* Bot tag */}
@if (Model.MessageGroup.Author.IsBot)
{
<span class="chatlog__bot-tag">BOT</span>
}
@{/* Message timestamp */}
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
@{/* Messages in a group */}
@foreach (var message in Model.MessageGroup.Messages)
{
<div class="chatlog__message @(message.IsPinned ? "chatlog__message--pinned" : null)" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
{ {
<div class="chatlog__content"> // Reference symbol
<div class="markdown"> if (message.Reference is not null)
@{/* Message content */} {
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span> <div class="chatlog__reference-symbol"></div>
}
@{/* Edit timestamp */} // Avatar
@if (message.EditedTimestamp is not null) <img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
}
else
{
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
}
</div>
@{/* Right side */}
<div class="chatlog__message-primary">
@if (isFirst)
{
// Reference
if (message.Reference is not null)
{
<div class="chatlog__reference">
@if (message.ReferencedMessage is not null)
{ {
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span> <img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
<div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content))
{
@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))
}
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
{
<em>Click to see attachment</em>
<span>🖼️</span>
}
else
{
<em>Click to see original message</em>
}
</span>
@if (message.ReferencedMessage.EditedTimestamp is not null)
{
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
else
{
<div class="chatlog__reference-unknown">
Original message was deleted or could not be loaded.
</div>
} }
</div> </div>
}
// Header
<div class="chatlog__header">
@{/* Author name */}
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
@{/* Bot label */}
@if (message.Author.IsBot)
{
<span class="chatlog__bot-label">BOT</span>
}
@{/* Timestamp */}
<span class="chatlog__timestamp">@FormatDate(message.Timestamp)</span>
</div>
}
@{/* Content */}
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
{
<div class="chatlog__content chatlog__markdown">
@{/* Text */}
<span class="chatlog__markdown-preserve">@Raw(FormatMarkdown(message.Content))</span>
@{/* Edited timestamp */}
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
}
</div> </div>
} }
@@ -140,26 +158,26 @@
@if (attachment.IsImage) @if (attachment.IsImage)
{ {
<a href="@await ResolveUrlAsync(attachment.Url)"> <a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")" loading="lazy"> <img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a> </a>
} }
else if (attachment.IsVideo) else if (attachment.IsVideo)
{ {
<video class="chatlog__attachment-media" controls> <video class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")"> <source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video> </video>
} }
else if (attachment.IsAudio) else if (attachment.IsAudio)
{ {
<audio class="chatlog__attachment-media" controls> <audio class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")"> <source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio> </audio>
} }
else else
{ {
<div class="chatlog__attachment-generic"> <div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon"> <svg class="chatlog__attachment-generic-icon">
<use href="#icon-attachment" /> <use href="#attachment-icon"/>
</svg> </svg>
<div class="chatlog__attachment-generic-name"> <div class="chatlog__attachment-generic-name">
<a href="@await ResolveUrlAsync(attachment.Url)"> <a href="@await ResolveUrlAsync(attachment.Url)">
@@ -202,7 +220,7 @@
@{/* Color pill */} @{/* Color pill */}
@if (embed.Color is not null) @if (embed.Color is not null)
{ {
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div> <div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
} }
else else
{ {
@@ -215,7 +233,7 @@
@{/* Embed author */} @{/* Embed author */}
@if (embed.Author is not null) @if (embed.Author is not null)
{ {
<div class="chatlog__embed-author"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> <img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
@@ -223,16 +241,16 @@
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{ {
<span class="chatlog__embed-author-name"> if (!string.IsNullOrWhiteSpace(embed.Author.Url))
@if (!string.IsNullOrWhiteSpace(embed.Author.Url)) {
{ <a class="chatlog__embed-author-link" href="@embed.Author.Url">
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a> <div class="chatlog__embed-author">@embed.Author.Name</div>
} </a>
else }
{ else
@embed.Author.Name {
} <div class="chatlog__embed-author">@embed.Author.Name</div>
</span> }
} }
</div> </div>
} }
@@ -244,12 +262,12 @@
@if (!string.IsNullOrWhiteSpace(embed.Url)) @if (!string.IsNullOrWhiteSpace(embed.Url))
{ {
<a class="chatlog__embed-title-link" href="@embed.Url"> <a class="chatlog__embed-title-link" href="@embed.Url">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a> </a>
} }
else else
{ {
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
} }
</div> </div>
} }
@@ -270,7 +288,7 @@
@{/* Color pill */} @{/* Color pill */}
@if (embed.Color is not null) @if (embed.Color is not null)
{ {
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div> <div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
} }
else else
{ {
@@ -283,7 +301,7 @@
@{/* Embed author */} @{/* Embed author */}
@if (embed.Author is not null) @if (embed.Author is not null)
{ {
<div class="chatlog__embed-author"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> <img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
@@ -291,16 +309,16 @@
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{ {
<span class="chatlog__embed-author-name"> if (!string.IsNullOrWhiteSpace(embed.Author.Url))
@if (!string.IsNullOrWhiteSpace(embed.Author.Url)) {
{ <a class="chatlog__embed-author-link" href="@embed.Author.Url">
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a> <div class="chatlog__embed-author">@embed.Author.Name</div>
} </a>
else }
{ else
@embed.Author.Name {
} <div class="chatlog__embed-author">@embed.Author.Name</div>
</span> }
} }
</div> </div>
} }
@@ -312,12 +330,12 @@
@if (!string.IsNullOrWhiteSpace(embed.Url)) @if (!string.IsNullOrWhiteSpace(embed.Url))
{ {
<a class="chatlog__embed-title-link" href="@embed.Url"> <a class="chatlog__embed-title-link" href="@embed.Url">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a> </a>
} }
else else
{ {
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
} }
</div> </div>
} }
@@ -326,7 +344,7 @@
@if (!string.IsNullOrWhiteSpace(embed.Description)) @if (!string.IsNullOrWhiteSpace(embed.Description))
{ {
<div class="chatlog__embed-description"> <div class="chatlog__embed-description">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div> </div>
} }
@@ -340,14 +358,14 @@
@if (!string.IsNullOrWhiteSpace(field.Name)) @if (!string.IsNullOrWhiteSpace(field.Name))
{ {
<div class="chatlog__embed-field-name"> <div class="chatlog__embed-field-name">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(field.Name))</div>
</div> </div>
} }
@if (!string.IsNullOrWhiteSpace(field.Value)) @if (!string.IsNullOrWhiteSpace(field.Value))
{ {
<div class="chatlog__embed-field-value"> <div class="chatlog__embed-field-value">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div> <div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div> </div>
} }
</div> </div>
@@ -418,11 +436,11 @@
<div class="chatlog__sticker" title="@sticker.Name"> <div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated) @if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
{ {
<img class="chatlog__sticker--media" src="@(await ResolveUrlAsync(sticker.SourceUrl))" alt="Sticker"> <img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
} }
else if (sticker.Format == StickerFormat.Lottie) else if (sticker.Format == StickerFormat.Lottie)
{ {
<div class="chatlog__sticker--media" data-source="@(await ResolveUrlAsync(sticker.SourceUrl))"></div> <div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div>
} }
</div> </div>
} }
@@ -434,13 +452,14 @@
@foreach (var reaction in message.Reactions) @foreach (var reaction in message.Reactions)
{ {
<div class="chatlog__reaction" title="@reaction.Emoji.Code"> <div class="chatlog__reaction" title="@reaction.Emoji.Code">
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy"> <img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
<span class="chatlog__reaction-count">@reaction.Count</span> <span class="chatlog__reaction-count">@reaction.Count</span>
</div> </div>
} }
</div> </div>
} }
</div> </div>
} </div>
</div> </div>
}
</div> </div>
@@ -1,4 +1,6 @@
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; using System.Collections.Generic;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Core.Exporting.Writers.Html; namespace DiscordChatExporter.Core.Exporting.Writers.Html;
@@ -6,12 +8,12 @@ internal class MessageGroupTemplateContext
{ {
public ExportContext ExportContext { get; } public ExportContext ExportContext { get; }
public MessageGroup MessageGroup { get; } public IReadOnlyList<Message> Messages { get; }
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup) public MessageGroupTemplateContext(ExportContext exportContext, IReadOnlyList<Message> messages)
{ {
ExportContext = exportContext; ExportContext = exportContext;
MessageGroup = messageGroup; Messages = messages;
} }
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) => public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
@@ -11,10 +11,13 @@
? darkVariant ? darkVariant
: lightVariant; : lightVariant;
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); string GetFontUrl(int weight) =>
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken); ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown); string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
} }
@@ -29,39 +32,41 @@
@{/* Styling */} @{/* Styling */}
<style> <style>
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(300)));
font-family: Whitney; font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
font-weight: 300; font-weight: 300;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(400)));
font-family: Whitney; font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
font-weight: 400; font-weight: 400;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(500)));
font-family: Whitney; font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
font-weight: 500; font-weight: 500;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(600)));
font-family: Whitney; font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
font-weight: 600; font-weight: 600;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(700)));
font-family: Whitney; font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
font-weight: 700; font-weight: 700;
} }
body { html, body {
margin: 0;
padding: 0;
background-color: @Themed("#36393e", "#ffffff"); background-color: @Themed("#36393e", "#ffffff");
color: @Themed("#dcddde", "#23262a"); color: @Themed("#dcddde", "#23262a");
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px; font-size: 17px;
font-weight: @Themed("400", "500"); font-weight: @Themed("400", "500");
} }
@@ -77,114 +82,15 @@
img { img {
object-fit: contain; object-fit: contain;
} image-rendering: high-quality;
image-rendering: -webkit-optimize-contrast;
.markdown {
max-width: 100%;
line-height: 1.3;
overflow-wrap: break-word;
}
.preserve-whitespace {
white-space: pre-wrap;
}
.spoiler-text {
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
border-radius: 3px;
}
.spoiler-text--hidden {
cursor: pointer;
background-color: @Themed("#202225", "#b9bbbe");
color: rgba(0, 0, 0, 0);
}
.spoiler-text--hidden:hover {
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
}
.spoiler-text--hidden::selection {
color: rgba(0, 0, 0, 0);
}
.quote {
display: flex;
margin: 0.05em 0;
}
.quote-border {
margin-right: 0.5em;
border: 2px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 3px;
}
.pre {
background-color: @Themed("#2f3136", "#f9f9f9");
font-family: "Consolas", "Courier New", Courier, monospace;
}
.pre--multiline {
margin-top: 0.25em;
padding: 0.5em;
border: 2px solid @Themed("#282b30", "#f3f3f3");
border-radius: 5px;
color: @Themed("#b9bbbe", "#657b83");
}
@{/* Override Highlight.js styles with a higher specificity selector */}
.pre--multiline.hljs {
background-color: @Themed("#2f3136", "#f9f9f9");
color: @Themed("#b9bbbe", "#657b83");
}
.pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 0.85em;
}
.mention {
border-radius: 3px;
padding: 0 2px;
color: @Themed("#dee0fc", "#505cdc");
background-color: @Themed("rgba(88, 101, 242, .3)", "rgba(88, 101, 242, .15)");
font-weight: 500;
}
.mention:hover {
background-color: #5865f2;
color: #ffffff
}
.timestamp {
border-radius: 3px;
padding: 0 2px;
background-color: @Themed("rgba(255, 255, 255, 0.06)", "rgba(6, 6, 7, 0.08)");
}
.emoji {
width: 1.325em;
height: 1.325em;
margin: 0 0.06em;
vertical-align: -0.4em;
}
.emoji--small {
width: 1em;
height: 1em;
}
.emoji--large {
width: 2.8em;
height: 2.8em;
} }
.preamble { .preamble {
display: grid; display: grid;
margin: 0 0.3em 0.6em 0.3em;
max-width: 100%;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
max-width: 100%;
padding: 1rem;
} }
.preamble__guild-icon-container { .preamble__guild-icon-container {
@@ -198,59 +104,114 @@
.preamble__entries-container { .preamble__entries-container {
grid-column: 2; grid-column: 2;
margin-left: 0.6em; margin-left: 1rem;
} }
.preamble__entry { .preamble__entry {
font-size: 1.4em; margin-bottom: 0.15rem;
color: @Themed("#ffffff", "#2f3136"); color: @Themed("#ffffff", "#2f3136");
font-size: 1.4rem;
} }
.preamble__entry--small { .preamble__entry--small {
font-size: 1em; font-size: 1rem;
} }
.chatlog { .chatlog {
max-width: 100%; padding: 1rem 0;
width: 100%;
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
border-bottom: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
} }
.chatlog__message-group { .chatlog__message-group {
margin-bottom: 1rem;
}
.chatlog__message-container {
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__message-container--highlighted {
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
}
.chatlog__message-container--pinned {
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
}
.chatlog__message {
display: grid; display: grid;
margin: 0 0.6em;
padding: 0.9em 0;
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
padding: 0.15rem 0;
direction: ltr;
unicode-bidi: bidi-override;
}
.chatlog__message:hover {
background-color: @Themed("#32353b", "#fafafa");
}
.chatlog__message:hover .chatlog__short-timestamp {
display: block;
}
.chatlog__message-aside {
grid-column: 1;
width: 72px;
padding: 0.15rem 0.15rem 0 0.15rem;
text-align: center;
} }
.chatlog__reference-symbol { .chatlog__reference-symbol {
grid-column: 1; height: 10px;
margin: 8px 4px 4px 18px; margin: 6px 4px 4px 36px;
border-left: 2px solid @Themed("#4f545c", "#c7ccd1"); border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
border-top: 2px solid @Themed("#4f545c", "#c7ccd1"); border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 8px 0 0 0; border-radius: 8px 0 0 0;
} }
.chatlog__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.chatlog__short-timestamp {
display: none;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr;
unicode-bidi: bidi-override;
}
.chatlog__message-primary {
grid-column: 2;
min-width: 0;
}
.chatlog__reference { .chatlog__reference {
display: flex; display: flex;
grid-column: 2; margin-bottom: 0.15rem;
margin-bottom: 0.25em; align-items: center;
font-size: 0.875em; color: @Themed("#b5b6b8", "#5f5f60");
font-size: 0.875rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
align-items: center;
color: @Themed("#b5b6b8", "#5f5f60");
} }
.chatlog__reference-avatar { .chatlog__reference-avatar {
border-radius: 50%;
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-right: 0.25em; margin-right: 0.25rem;
border-radius: 50%;
} }
.chatlog__reference-name { .chatlog__reference-author {
margin-right: 0.3em; margin-right: 0.3rem;
font-weight: 600; font-weight: 600;
} }
@@ -259,13 +220,8 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.chatlog__reference-content a:hover {
text-decoration: none;
}
.chatlog__reference-link { .chatlog__reference-link {
cursor: pointer; cursor: pointer;
color: @Themed("#b5b6b8", "#5f5f60");
} }
.chatlog__reference-link * { .chatlog__reference-link * {
@@ -273,82 +229,75 @@
pointer-events: none; pointer-events: none;
} }
.chatlog__reference-link .hljs {
display: inline;
}
.chatlog__reference-link:hover { .chatlog__reference-link:hover {
color: @Themed("#ffffff", "#2f3136"); color: @Themed("#ffffff", "#2f3136");
} }
.chatlog__reference-link:hover *:not(.spoiler-text) { .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
color: @Themed("#ffffff", "#2f3136"); color: inherit;
} }
.chatlog__reference-edited-timestamp { .chatlog__reference-edited-timestamp {
margin-left: 0.25em; margin-left: 0.25rem;
font-size: 0.8em; color: @Themed("#a3a6aa", "#5e6772");
unicode-bidi: bidi-override; font-size: 0.75rem;
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d"); font-weight: 500;
}
.chatlog__author-avatar-container {
grid-column: 1;
width: 40px;
height: 40px;
margin-right: 16px;
}
.chatlog__author-avatar {
border-radius: 50%;
width: 40px;
height: 40px;
}
.chatlog__messages {
grid-column: 2;
min-width: 50%;
direction: ltr; direction: ltr;
unicode-bidi: bidi-override;
} }
.chatlog__author-name { .chatlog__header {
margin-bottom: 0.1rem;
}
.chatlog__author {
font-weight: @Themed("500", "600"); font-weight: @Themed("500", "600");
color: @Themed("#ffffff", "#2f3136"); color: @Themed("#ffffff", "#2f3136");
} }
.chatlog__bot-label {
position: relative;
top: -0.1rem;
margin-left: 0.3rem;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background-color: #5865F2;
color: #ffffff;
font-size: 0.625rem;
font-weight: 500;
line-height: 1.3;
}
.chatlog__timestamp { .chatlog__timestamp {
margin-left: 0.3em; margin-left: 0.3rem;
font-size: 0.75em; color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr; direction: ltr;
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__message {
padding: 0.1em 0.3em;
margin: 0 -0.3em;
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__message--highlighted {
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
}
.chatlog__message--pinned {
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
} }
.chatlog__content { .chatlog__content {
font-size: 0.95em; padding-right: 1rem;
font-size: 0.95rem;
word-wrap: break-word; word-wrap: break-word;
} }
.chatlog__edited-timestamp { .chatlog__edited-timestamp {
margin-left: 0.15em; margin-left: 0.15rem;
font-size: 0.8em; color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
} }
.chatlog__attachment { .chatlog__attachment {
display: inline-block;
position: relative; position: relative;
margin-top: 0.3em; width: fit-content;
margin-top: 0.3rem;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
} }
@@ -364,18 +313,18 @@
.chatlog__attachment-spoiler-caption { .chatlog__attachment-spoiler-caption {
display: none; display: none;
z-index: 999;
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); z-index: 999;
padding: 0.4em 0.8em; padding: 0.4rem 0.8rem;
background-color: rgba(0, 0, 0, 0.9);
border-radius: 20px; border-radius: 20px;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.9);
color: #dcddde; color: #dcddde;
font-size: 0.9em; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05rem;
} }
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption { .chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
@@ -387,9 +336,9 @@
} }
.chatlog__attachment-media { .chatlog__attachment-media {
vertical-align: top;
max-width: 45vw; max-width: 45vw;
max-height: 500px; max-height: 500px;
vertical-align: top;
border-radius: 3px; border-radius: 3px;
} }
@@ -398,13 +347,13 @@
} }
.chatlog__attachment-generic { .chatlog__attachment-generic {
width: 100%;
max-width: 520px; max-width: 520px;
width: 100%;
height: 40px; height: 40px;
padding: 10px; padding: 10px;
background-color: @Themed("#2f3136", "#f2f3f5");
border: 1px solid @Themed("#292b2f", "#ebedef"); border: 1px solid @Themed("#292b2f", "#ebedef");
border-radius: 3px; border-radius: 3px;
background-color: @Themed("#2f3136", "#f2f3f5");
overflow: hidden; overflow: hidden;
} }
@@ -430,19 +379,15 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.chatlog__edited-timestamp {
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__embed { .chatlog__embed {
display: flex; display: flex;
margin-top: 0.3em; margin-top: 0.3rem;
max-width: 520px; max-width: 520px;
} }
.chatlog__embed-color-pill { .chatlog__embed-color-pill {
flex-shrink: 0; flex-shrink: 0;
width: 0.25em; width: 0.25rem;
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
} }
@@ -454,11 +399,11 @@
.chatlog__embed-content-container { .chatlog__embed-content-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.5em 0.6em; padding: 0.5rem 0.6rem;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)"); border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
border-top-right-radius: 3px; border-top-right-radius: 3px;
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
} }
.chatlog__embed-content { .chatlog__embed-content {
@@ -470,85 +415,85 @@
flex: 1; flex: 1;
} }
.chatlog__embed-author { .chatlog__embed-author-container {
display: flex; display: flex;
margin-bottom: 0.3em; margin-bottom: 0.5rem;
align-items: center; align-items: center;
} }
.chatlog__embed-author-icon { .chatlog__embed-author-icon {
margin-right: 0.5em;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-right: 0.5rem;
border-radius: 50%; border-radius: 50%;
} }
.chatlog__embed-author-name { .chatlog__embed-author {
font-size: 0.875em; color: @Themed("#ffffff", "#4f545c");
font-size: 0.875rem;
font-weight: 600; font-weight: 600;
direction: ltr; direction: ltr;
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
color: @Themed("#ffffff", "#4f545c")
} }
.chatlog__embed-author-name-link { .chatlog__embed-author-link {
color: @Themed("#ffffff", "#4f545c"); color: @Themed("#ffffff", "#4f545c");
} }
.chatlog__embed-title { .chatlog__embed-title {
margin-bottom: 0.2em; margin-bottom: 0.5rem;
font-size: 0.875em;
font-weight: 600;
color: @Themed("#ffffff", "#4f545c"); color: @Themed("#ffffff", "#4f545c");
font-size: 0.875rem;
font-weight: 600;
} }
.chatlog__embed-description { .chatlog__embed-description {
font-weight: 500;
font-size: 0.85em;
color: @Themed("#dcddde", "#2e3338"); color: @Themed("#dcddde", "#2e3338");
font-weight: 500;
font-size: 0.85rem;
} }
.chatlog__embed-fields { .chatlog__embed-fields {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 0.5em; gap: 0 0.5rem;
} }
.chatlog__embed-field { .chatlog__embed-field {
flex: 0; flex: 0;
min-width: 100%; min-width: 100%;
max-width: 506px; max-width: 506px;
padding-top: 0.6em; padding-top: 0.6rem;
font-size: 0.875em; font-size: 0.875rem;
} }
.chatlog__embed-field--inline { .chatlog__embed-field--inline {
flex: 1; flex: 1;
flex-basis: auto; flex-basis: auto;
min-width: 150px; min-width: 50px;
} }
.chatlog__embed-field-name { .chatlog__embed-field-name {
margin-bottom: 0.2em; margin-bottom: 0.2rem;
font-weight: 600;
color: @Themed("#ffffff", "#36393e"); color: @Themed("#ffffff", "#36393e");
font-weight: 600;
} }
.chatlog__embed-field-value { .chatlog__embed-field-value {
font-weight: 500;
color: @Themed("#dcddde", "#2e3338"); color: @Themed("#dcddde", "#2e3338");
font-weight: 500;
} }
.chatlog__embed-thumbnail { .chatlog__embed-thumbnail {
flex: 0; flex: 0;
margin-left: 1.2em;
max-width: 80px; max-width: 80px;
max-height: 80px; max-height: 80px;
margin-left: 1.2rem;
border-radius: 3px; border-radius: 3px;
} }
.chatlog__embed-image-container { .chatlog__embed-image-container {
margin-top: 0.6em; margin-top: 0.6rem;
} }
.chatlog__embed-image { .chatlog__embed-image {
@@ -558,28 +503,28 @@
} }
.chatlog__embed-footer { .chatlog__embed-footer {
margin-top: 0.6em; margin-top: 0.6rem;
color: @Themed("#dcddde", "#2e3338"); color: @Themed("#dcddde", "#2e3338");
} }
.chatlog__embed-footer-icon { .chatlog__embed-footer-icon {
margin-right: 0.2em;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-right: 0.2rem;
border-radius: 50%; border-radius: 50%;
vertical-align: middle; vertical-align: middle;
} }
.chatlog__embed-footer-text { .chatlog__embed-footer-text {
vertical-align: middle; vertical-align: middle;
font-size: 0.75em; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
} }
.chatlog__embed-plainimage { .chatlog__embed-plainimage {
vertical-align: top;
max-width: 45vw; max-width: 45vw;
max-height: 500px; max-height: 500px;
vertical-align: top;
border-radius: 3px; border-radius: 3px;
} }
@@ -588,7 +533,7 @@
} }
.chatlog__embed-youtube-container { .chatlog__embed-youtube-container {
margin-top: 0.6em; margin-top: 0.6rem;
} }
.chatlog__embed-youtube { .chatlog__embed-youtube {
@@ -612,51 +557,132 @@
.chatlog__reaction { .chatlog__reaction {
display: flex; display: flex;
align-items: center; margin: 0.35rem 0.1rem 0.1rem 0;
margin: 0.35em 0.1em 0.1em 0; padding: 0.125rem 0.375rem;
padding: .125rem .375rem; border: 1px solid transparent;
background-color: @Themed("#2f3136", "#f2f3f5");
border-radius: 8px; border-radius: 8px;
border-color: transparent; background-color: @Themed("#2f3136", "#f2f3f5");
border-width: 1px; align-items: center;
border-style: solid;
} }
.chatlog__reaction:hover { .chatlog__reaction:hover {
border-color: @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)"); border: 1px solid @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
border-width: 1px;
border-style: solid;
background-color: @Themed("transparent", "white"); background-color: @Themed("transparent", "white");
} }
.chatlog__reaction-count { .chatlog__reaction-count {
min-width: 9px; min-width: 9px;
margin-left: 0.35em; margin-left: 0.35rem;
font-size: 0.875em;
color: @Themed("#b9bbbe", "#4f5660"); color: @Themed("#b9bbbe", "#4f5660");
font-size: 0.875rem;
} }
.chatlog__reaction:hover .chatlog__reaction-count { .chatlog__reaction:hover .chatlog__reaction-count {
color: @Themed("#dcddde", "#2e3338"); color: @Themed("#dcddde", "#2e3338");
} }
.chatlog__bot-tag { .chatlog__markdown {
position: relative; max-width: 100%;
top: -.1em;
margin-left: 0.3em;
padding: 0.05em 0.3em;
border-radius: 3px;
line-height: 1.3; line-height: 1.3;
background-color: #5865F2; overflow-wrap: break-word;
color: #ffffff; }
font-size: 0.625em;
.chatlog__markdown-preserve {
white-space: pre-wrap;
}
.chatlog__markdown-spoiler {
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
border-radius: 3px;
}
.chatlog__markdown-spoiler--hidden {
cursor: pointer;
background-color: @Themed("#202225", "#b9bbbe");
color: rgba(0, 0, 0, 0);
}
.chatlog__markdown-spoiler--hidden:hover {
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
}
.chatlog__markdown-spoiler--hidden::selection {
color: rgba(0, 0, 0, 0);
}
.chatlog__markdown-quote {
display: flex;
margin: 0.05rem 0;
}
.chatlog__markdown-quote-border {
margin-right: 0.5rem;
border: 2px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 3px;
}
.chatlog__markdown-pre {
background-color: @Themed("#2f3136", "#f9f9f9");
font-family: "Consolas", "Courier New", Courier, monospace;
}
.chatlog__markdown-pre--multiline {
margin-top: 0.25rem;
padding: 0.5rem;
border: 2px solid @Themed("#282b30", "#f3f3f3");
border-radius: 5px;
color: @Themed("#b9bbbe", "#657b83");
}
.chatlog__markdown-pre--multiline.hljs {
background-color: inherit;
color: inherit;
}
.chatlog__markdown-pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 0.85rem;
}
.chatlog__markdown-mention {
border-radius: 3px;
padding: 0 2px;
background-color: @Themed("rgba(88, 101, 242, .3)", "rgba(88, 101, 242, .15)");
color: @Themed("#dee0fc", "#505cdc");
font-weight: 500; font-weight: 500;
} }
.chatlog__markdown-mention:hover {
background-color: #5865f2;
color: #ffffff
}
.chatlog__markdown-timestamp {
border-radius: 3px;
padding: 0 2px;
color: @Themed("#a3a6aa", "#5e6772");
}
.chatlog__emoji {
width: 1.325rem;
height: 1.325rem;
margin: 0 0.06rem;
vertical-align: -0.4rem;
}
.chatlog__emoji--small {
width: 1rem;
height: 1rem;
}
.chatlog__emoji--large {
width: 2.8rem;
height: 2.8rem;
}
.postamble { .postamble {
margin: 1.4em 0.3em 0.6em 0.3em; padding: 1.25rem;
padding: 1em;
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
} }
.postamble__entry { .postamble__entry {
@@ -665,16 +691,16 @@
</style> </style>
@{/* Syntax highlighting */} @{/* Syntax highlighting */}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css"> <link rel="stylesheet" href="@await ResolveUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{Model.ThemeName.ToLowerInvariant()}.min.css")">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script> <script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(e => hljs.highlightBlock(e)); document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
}); });
</script> </script>
@{/* Lottie animation support */} @{/* Lottie animation support */}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js"></script> <script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => { document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
@@ -698,43 +724,46 @@
@{/* Scripts */} @{/* Scripts */}
<script> <script>
function scrollToMessage(event, id) { function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id); var element = document.getElementById('chatlog__message-container-' + id);
if (!element)
return;
if (element) { event.preventDefault();
event.preventDefault(); element.classList.add('chatlog__message-container--highlighted');
element.classList.add('chatlog__message--highlighted'); window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.scrollTo({ window.setTimeout(function() {
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2), element.classList.remove('chatlog__message-container--highlighted');
behavior: 'smooth' }, 2000);
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
} }
function showSpoiler(event, element) { function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler-text--hidden')) { if (!element)
event.preventDefault(); return;
element.classList.remove('spoiler-text--hidden');
} if (element.classList.contains('chatlog__attachment--hidden')) {
if (element && element.classList.contains('chatlog__attachment--hidden')) {
event.preventDefault(); event.preventDefault();
element.classList.remove('chatlog__attachment--hidden'); element.classList.remove('chatlog__attachment--hidden');
} }
if (element.classList.contains('chatlog__markdown-spoiler--hidden')) {
event.preventDefault();
element.classList.remove('chatlog__markdown-spoiler--hidden');
}
} }
</script> </script>
@{/* Icons */} @{/* Icons */}
<svg style="display: none"> <svg style="display: none">
<symbol id="icon-attachment" viewBox="0 0 720 960"> <symbol id="attachment-icon" viewBox="0 0 720 960">
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" /> <path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/>
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" /> <path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/>
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" /> <path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/>
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" /> <path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/>
</symbol> </symbol>
</svg> </svg>
</head> </head>
@@ -756,18 +785,18 @@
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null) @if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
{ {
<div class="preamble__entry preamble__entry--small"> <div class="preamble__entry preamble__entry--small">
@if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null) @if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null)
{ {
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") @($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
} }
else if (Model.ExportContext.Request.After is not null) else if (Model.ExportContext.Request.After is not null)
{ {
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}") @($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
} }
else if (Model.ExportContext.Request.Before is not null) else if (Model.ExportContext.Request.Before is not null)
{ {
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") @($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
} }
</div> </div>
} }
</div> </div>
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -13,7 +14,7 @@ internal class HtmlMessageWriter : MessageWriter
private readonly TextWriter _writer; private readonly TextWriter _writer;
private readonly string _themeName; private readonly string _themeName;
private readonly List<Message> _messageGroupBuffer = new(); private readonly List<Message> _messageGroup = new();
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName) public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
: base(stream, context) : base(stream, context)
@@ -22,6 +23,23 @@ internal class HtmlMessageWriter : MessageWriter
_themeName = themeName; _themeName = themeName;
} }
private bool CanJoinGroup(Message message)
{
var lastMessage = _messageGroup.LastOrDefault();
if (lastMessage is null)
return true;
return
// Must be from the same author
lastMessage.Author.Id == message.Author.Id &&
// Author's name must not have changed between messages
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) &&
// Duration between messages must be 7 minutes or less
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 &&
// Other message must not be a reply
message.Reference is null;
}
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
{ {
var templateContext = new PreambleTemplateContext(Context, _themeName); var templateContext = new PreambleTemplateContext(Context, _themeName);
@@ -34,10 +52,10 @@ internal class HtmlMessageWriter : MessageWriter
} }
private async ValueTask WriteMessageGroupAsync( private async ValueTask WriteMessageGroupAsync(
MessageGroup messageGroup, IReadOnlyList<Message> messages,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var templateContext = new MessageGroupTemplateContext(Context, messageGroup); var templateContext = new MessageGroupTemplateContext(Context, messages);
// We are not writing directly to output because Razor // We are not writing directly to output because Razor
// does not actually do asynchronous writes to stream. // does not actually do asynchronous writes to stream.
@@ -52,31 +70,26 @@ internal class HtmlMessageWriter : MessageWriter
{ {
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
// If message group is empty or the given message can be grouped, buffer the given message // If the message can be grouped, buffer it for now
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message)) if (CanJoinGroup( message))
{ {
_messageGroupBuffer.Add(message); _messageGroup.Add(message);
} }
// Otherwise, flush the group and render messages // Otherwise, flush the group and render messages
else else
{ {
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken); await WriteMessageGroupAsync(_messageGroup, cancellationToken);
_messageGroupBuffer.Clear(); _messageGroup.Clear();
_messageGroupBuffer.Add(message); _messageGroup.Add(message);
} }
} }
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
{ {
// Flush current message group // Flush current message group
if (_messageGroupBuffer.Any()) if (_messageGroup.Any())
{ await WriteMessageGroupAsync(_messageGroup, cancellationToken);
await WriteMessageGroupAsync(
MessageGroup.Join(_messageGroupBuffer),
cancellationToken
);
}
var templateContext = new PostambleTemplateContext(Context, MessagesWritten); var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
@@ -33,15 +33,37 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
{ {
var (tagOpen, tagClose) = formatting.Kind switch var (tagOpen, tagClose) = formatting.Kind switch
{ {
FormattingKind.Bold => ("<strong>", "</strong>"), FormattingKind.Bold => (
FormattingKind.Italic => ("<em>", "</em>"), "<strong>",
FormattingKind.Underline => ("<u>", "</u>"), "</strong>"
FormattingKind.Strikethrough => ("<s>", "</s>"), ),
FormattingKind.Italic => (
"<em>",
"</em>"
),
FormattingKind.Underline => (
"<u>",
"</u>"
),
FormattingKind.Strikethrough => (
"<s>",
"</s>"
),
FormattingKind.Spoiler => ( FormattingKind.Spoiler => (
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"), "<span class=\"chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden\" onclick=\"showSpoiler(event, this)\">",
"</span>"
),
FormattingKind.Quote => ( FormattingKind.Quote => (
"<div class=\"quote\"><div class=\"quote-border\"></div><div class=\"quote-content\">", "</div></div>"), "<div class=\"chatlog__markdown-quote\"><div class=\"chatlog__markdown-quote-border\"></div><div class=\"chatlog__markdown-quote-content\">",
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind)) "</div></div>"
),
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
}; };
_buffer.Append(tagOpen); _buffer.Append(tagOpen);
@@ -54,7 +76,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
{ {
_buffer _buffer
.Append("<span class=\"pre pre--inline\">") .Append("<span class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
.Append(HtmlEncode(inlineCodeBlock.Code)) .Append(HtmlEncode(inlineCodeBlock.Code))
.Append("</span>"); .Append("</span>");
@@ -68,7 +90,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: "nohighlight"; : "nohighlight";
_buffer _buffer
.Append($"<div class=\"pre pre--multiline {highlightCssClass}\">") .Append($"<div class=\"chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightCssClass}\">")
.Append(HtmlEncode(multiLineCodeBlock.Code)) .Append(HtmlEncode(multiLineCodeBlock.Code))
.Append("</div>"); .Append("</div>");
@@ -98,10 +120,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitEmoji(EmojiNode emoji) protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "emoji--large" : ""; var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_buffer _buffer
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">"); .Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
return base.VisitEmoji(emoji); return base.VisitEmoji(emoji);
} }
@@ -111,14 +133,14 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
_buffer _buffer
.Append("<span class=\"mention\">") .Append("<span class=\"chatlog__markdown-mention\">")
.Append("@everyone") .Append("@everyone")
.Append("</span>"); .Append("</span>");
} }
else if (mention.Kind == MentionKind.Here) else if (mention.Kind == MentionKind.Here)
{ {
_buffer _buffer
.Append("<span class=\"mention\">") .Append("<span class=\"chatlog__markdown-mention\">")
.Append("@here") .Append("@here")
.Append("</span>"); .Append("</span>");
} }
@@ -129,7 +151,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var nick = member?.Nick ?? "Unknown"; var nick = member?.Nick ?? "Unknown";
_buffer _buffer
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">") .Append($"<span class=\"chatlog__markdown-mention\" title=\"{HtmlEncode(fullName)}\">")
.Append('@').Append(HtmlEncode(nick)) .Append('@').Append(HtmlEncode(nick))
.Append("</span>"); .Append("</span>");
} }
@@ -140,7 +162,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer _buffer
.Append("<span class=\"mention\">") .Append("<span class=\"chatlog__markdown-mention\">")
.Append(symbol).Append(HtmlEncode(name)) .Append(symbol).Append(HtmlEncode(name))
.Append("</span>"); .Append("</span>");
} }
@@ -151,11 +173,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var color = role?.Color; var color = role?.Color;
var style = color is not null var style = color is not null
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.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);"
: ""; : "";
_buffer _buffer
.Append($"<span class=\"mention\" style=\"{style}\">") .Append($"<span class=\"chatlog__markdown-mention\" style=\"{style}\">")
.Append('@').Append(HtmlEncode(name)) .Append('@').Append(HtmlEncode(name))
.Append("</span>"); .Append("</span>");
} }
@@ -175,7 +198,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: "Invalid date"; : "Invalid date";
_buffer _buffer
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">") .Append($"<span class=\"chatlog__markdown-timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append(HtmlEncode(dateString)) .Append(HtmlEncode(dateString))
.Append("</span>"); .Append("</span>");
@@ -77,8 +77,9 @@ internal static partial class MarkdownParser
// Capture any character until the end of the line // Capture any character until the end of the line
// Opening 'greater than' character must be followed by whitespace // Opening 'greater than' character must be followed by whitespace
// Text content is optional
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("^>\\s(.+\n?)", DefaultRegexOptions), new Regex("^>\\s(.*\n?)", DefaultRegexOptions),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
); );
@@ -86,7 +87,7 @@ internal static partial class MarkdownParser
// This one is tricky as it ends up producing multiple separate captures which need to be joined // This one is tricky as it ends up producing multiple separate captures which need to be joined
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher = private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownNode>(
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions), new Regex("(?:^>\\s(.*\n?)){2,}", DefaultRegexOptions),
(_, m) => (_, m) =>
{ {
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value)); var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
+27
View File
@@ -47,6 +47,29 @@
<Setter Property="Minimum" Value="0" /> <Setter Property="Minimum" Value="0" />
</Style> </Style>
<Style TargetType="{x:Type Hyperlink}">
<Setter Property="TextDecorations" Value="Underline" />
<Setter Property="FontWeight" Value="Regular" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Cursor" Value="Hand" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsEnabled" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" /> <Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" /> <Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
@@ -90,6 +113,10 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
</Style>
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) --> <!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}"> <Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template"> <Setter Property="Template">
@@ -9,11 +9,16 @@ namespace DiscordChatExporter.Gui.Behaviors;
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox> public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
{ {
public static readonly DependencyProperty SelectedItemsProperty = public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
DependencyProperty.Register(nameof(SelectedItems), typeof(IList), nameof(SelectedItems),
typeof(MultiSelectionListBoxBehavior<T>), typeof(IList),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, typeof(MultiSelectionListBoxBehavior<T>),
OnSelectedItemsChanged)); new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged
)
);
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{ {
@@ -9,19 +9,13 @@ public class DateTimeOffsetToDateTimeConverter : IValueConverter
{ {
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new(); public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
{ value is DateTimeOffset dateTimeOffsetValue
if (value is DateTimeOffset dateTimeOffsetValue) ? dateTimeOffsetValue.DateTime
return dateTimeOffsetValue.DateTime; : default(DateTime?);
return default(DateTime?); public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
} value is DateTime dateTimeValue
? new DateTimeOffset(dateTimeValue)
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) : default(DateTimeOffset?);
{
if (value is DateTime dateTimeValue)
return new DateTimeOffset(dateTimeValue);
return default(DateTimeOffset?);
}
} }
@@ -10,13 +10,10 @@ public class ExportFormatToStringConverter : IValueConverter
{ {
public static ExportFormatToStringConverter Instance { get; } = new(); public static ExportFormatToStringConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
{ value is ExportFormat exportFormatValue
if (value is ExportFormat exportFormatValue) ? exportFormatValue.GetDisplayName()
return exportFormatValue.GetDisplayName(); : default;
return default(string?);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException(); throw new NotSupportedException();
@@ -9,19 +9,9 @@ public class InverseBoolConverter : IValueConverter
{ {
public static InverseBoolConverter Instance { get; } = new(); public static InverseBoolConverter Instance { get; } = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
{ value is false;
if (value is bool boolValue)
return !boolValue;
return default(bool); public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
} value is false;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
return !boolValue;
return default(bool);
}
} }
@@ -9,19 +9,13 @@ public class TimeSpanToDateTimeConverter : IValueConverter
{ {
public static TimeSpanToDateTimeConverter Instance { get; } = new(); public static TimeSpanToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
{ value is TimeSpan timeSpanValue
if (value is TimeSpan timeSpanValue) ? DateTime.Today.Add(timeSpanValue)
return DateTime.Today.Add(timeSpanValue); : default(DateTime?);
return default(DateTime?); public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
} value is DateTime dateTimeValue
? dateTimeValue.TimeOfDay
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) : default(TimeSpan?);
{
if (value is DateTime dateTimeValue)
return dateTimeValue.TimeOfDay;
return default(TimeSpan?);
}
} }
@@ -14,8 +14,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="MaterialDesignColors" Version="2.0.4" /> <PackageReference Include="MaterialDesignColors" Version="2.0.5" />
<PackageReference Include="MaterialDesignThemes" Version="4.3.0" /> <PackageReference Include="MaterialDesignThemes" Version="4.4.0" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" /> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" /> <PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" /> <PackageReference Include="Onova" Version="2.6.2" />
+3 -1
View File
@@ -11,6 +11,8 @@ internal static class ProcessEx
UseShellExecute = true UseShellExecute = true
}; };
using (Process.Start(startInfo)) {} using (Process.Start(startInfo))
{
}
} }
} }
@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Messages;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress;
using Gress.Completable;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Components;
public class DashboardViewModel : PropertyChangedBase
{
private readonly IViewModelFactory _viewModelFactory;
private readonly IEventAggregator _eventAggregator;
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly AutoResetProgressMuxer _progressMuxer;
private DiscordClient? _discord;
public bool IsBusy { get; private set; }
public ProgressContainer<Percentage> Progress { get; } = new();
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
public string? Token { get; set; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray();
public Guild? SelectedGuild { get; set; }
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
? GuildChannelMap?[SelectedGuild]
: null;
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
public DashboardViewModel(
IViewModelFactory viewModelFactory,
IEventAggregator eventAggregator,
DialogManager dialogManager,
SettingsService settingsService)
{
_viewModelFactory = viewModelFactory;
_eventAggregator = eventAggregator;
_dialogManager = dialogManager;
_settingsService = settingsService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
}
public void OnViewLoaded()
{
if (_settingsService.LastToken is not null)
{
Token = _settingsService.LastToken;
}
}
public async void ShowSettings()
{
var dialog = _viewModelFactory.CreateSettingsViewModel();
await _dialogManager.ShowDialogAsync(dialog);
}
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels()
{
IsBusy = true;
var progress = _progressMuxer.CreateInput();
try
{
var token = Token?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(token))
return;
_settingsService.LastToken = token;
var discord = new DiscordClient(token);
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync())
{
var channels = await discord.GetGuildChannelsAsync(guild.Id);
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
}
_discord = discord;
GuildChannelMap = guildChannelMap;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
_eventAggregator.Publish(
new NotificationMessage(ex.Message.TrimEnd('.'))
);
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Error pulling guilds and channels",
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
progress.ReportCompletion();
IsBusy = false;
}
}
public bool CanExportChannels =>
!IsBusy &&
_discord is not null &&
SelectedGuild is not null &&
SelectedChannels?.Any() is true;
public async void ExportChannels()
{
IsBusy = true;
try
{
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
var exporter = new ChannelExporter(_discord);
var progresses = Enumerable
.Range(0, dialog.Channels!.Count)
.Select(_ => _progressMuxer.CreateInput())
.ToArray();
var successfulExportCount = 0;
await Parallel.ForEachAsync(
dialog.Channels.Zip(progresses),
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
},
async (tuple, cancellationToken) =>
{
var (channel, progress) = tuple;
try
{
var request = new ExportRequest(
dialog.Guild!,
channel,
dialog.OutputPath!,
dialog.SelectedFormat,
dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit,
dialog.MessageFilter,
dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
_settingsService.DateFormat
);
await exporter.ExportChannelAsync(request, progress, cancellationToken);
Interlocked.Increment(ref successfulExportCount);
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
_eventAggregator.Publish(
new NotificationMessage(ex.Message.TrimEnd('.') + $" ({channel.Name})")
);
}
finally
{
progress.ReportCompletion();
}
}
);
// Notify of overall completion
if (successfulExportCount > 0)
{
_eventAggregator.Publish(
new NotificationMessage($"Successfully exported {successfulExportCount} channel(s)")
);
}
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Error exporting channel(s)",
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
IsBusy = false;
}
}
public void OpenDiscord() =>
ProcessEx.StartShellExecute("https://discord.com/app");
public void OpenDiscordDeveloperPortal() =>
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
}
@@ -21,12 +21,11 @@ public class ExportSetupViewModel : DialogScreen
public IReadOnlyList<Channel>? Channels { get; set; } public IReadOnlyList<Channel>? Channels { get; set; }
public bool IsSingleChannel => Channels is null || Channels.Count == 1; public bool IsSingleChannel => Channels?.Count == 1;
public string? OutputPath { get; set; } public string? OutputPath { get; set; }
public IReadOnlyList<ExportFormat> AvailableFormats => public IReadOnlyList<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat { get; set; } public ExportFormat SelectedFormat { get; set; }
@@ -75,7 +74,7 @@ public class ExportSetupViewModel : DialogScreen
MessageFilterValue = _settingsService.LastMessageFilterValue; MessageFilterValue = _settingsService.LastMessageFilterValue;
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia; ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
// Show the "advanced options" by default if any // Show the "advanced options" section by default if any
// of the advanced options are set to non-default values. // of the advanced options are set to non-default values.
IsAdvancedSectionDisplayed = IsAdvancedSectionDisplayed =
After != default || After != default ||
@@ -96,12 +95,11 @@ public class ExportSetupViewModel : DialogScreen
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia; _settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// If single channel - prompt file path // If single channel - prompt file path
if (Channels is not null && IsSingleChannel) if (IsSingleChannel)
{ {
var channel = Channels.Single();
var defaultFileName = ExportRequest.GetDefaultOutputFileName( var defaultFileName = ExportRequest.GetDefaultOutputFileName(
Guild!, Guild!,
channel, Channels!.Single(),
SelectedFormat, SelectedFormat,
After?.Pipe(Snowflake.FromDate), After?.Pipe(Snowflake.FromDate),
Before?.Pipe(Snowflake.FromDate) Before?.Pipe(Snowflake.FromDate)
@@ -7,20 +7,41 @@ public class MessageBoxViewModel : DialogScreen
public string? Title { get; set; } public string? Title { get; set; }
public string? Message { get; set; } public string? Message { get; set; }
public bool IsOkButtonVisible { get; set; } = true;
public string? OkButtonText { get; set; }
public bool IsCancelButtonVisible { get; set; }
public string? CancelButtonText { get; set; }
public int ButtonsCount =>
(IsOkButtonVisible ? 1 : 0) +
(IsCancelButtonVisible ? 1 : 0);
} }
public static class MessageBoxViewModelExtensions public static class MessageBoxViewModelExtensions
{ {
public static MessageBoxViewModel CreateMessageBoxViewModel( public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory, this IViewModelFactory factory,
string title, string title, string message,
string message) string? okButtonText, string? cancelButtonText)
{ {
var viewModel = factory.CreateMessageBoxViewModel(); var viewModel = factory.CreateMessageBoxViewModel();
viewModel.Title = title; viewModel.Title = title;
viewModel.Message = message; viewModel.Message = message;
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
viewModel.OkButtonText = okButtonText;
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
viewModel.CancelButtonText = cancelButtonText;
return viewModel; return viewModel;
} }
public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory,
string title, string message) =>
factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
using Microsoft.Win32; using Microsoft.Win32;
@@ -8,9 +9,10 @@ using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework; namespace DiscordChatExporter.Gui.ViewModels.Framework;
public class DialogManager public class DialogManager : IDisposable
{ {
private readonly IViewManager _viewManager; private readonly IViewManager _viewManager;
private readonly SemaphoreSlim _dialogLock = new(1, 1);
public DialogManager(IViewManager viewManager) public DialogManager(IViewManager viewManager)
{ {
@@ -21,20 +23,26 @@ public class DialogManager
{ {
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs) void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
{ {
void OnScreenClosed(object? o, EventArgs closeArgs) void OnScreenClosed(object? closeSender, EventArgs args)
{ {
openArgs.Session.Close(); openArgs.Session.Close();
dialogScreen.Closed -= OnScreenClosed; dialogScreen.Closed -= OnScreenClosed;
} }
dialogScreen.Closed += OnScreenClosed; dialogScreen.Closed += OnScreenClosed;
} }
await DialogHost.Show(view, OnDialogOpened); await _dialogLock.WaitAsync();
try
return dialogScreen.DialogResult; {
await DialogHost.Show(view, OnDialogOpened);
return dialogScreen.DialogResult;
}
finally
{
_dialogLock.Release();
}
} }
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "") public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
@@ -59,4 +67,9 @@ public class DialogManager
return dialog.ShowDialog() == true ? dialog.SelectedPath : null; return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
} }
public void Dispose()
{
_dialogLock.Dispose();
}
} }
@@ -1,10 +1,13 @@
using DiscordChatExporter.Gui.ViewModels.Dialogs; using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework; namespace DiscordChatExporter.Gui.ViewModels.Framework;
// Used to instantiate new view models while making use of dependency injection // Used to instantiate new view models while making use of dependency injection
public interface IViewModelFactory public interface IViewModelFactory
{ {
DashboardViewModel CreateDashboardViewModel();
ExportSetupViewModel CreateExportSetupViewModel(); ExportSetupViewModel CreateExportSetupViewModel();
MessageBoxViewModel CreateMessageBoxViewModel(); MessageBoxViewModel CreateMessageBoxViewModel();
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Gui.ViewModels.Messages;
public class NotificationMessage
{
public string Text { get; }
public NotificationMessage(string text) => Text = text;
}
@@ -1,59 +1,30 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils; using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs; using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Messages;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress;
using Gress.Completable;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
using Stylet; using Stylet;
namespace DiscordChatExporter.Gui.ViewModels; namespace DiscordChatExporter.Gui.ViewModels;
public class RootViewModel : Screen public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
{ {
private readonly IViewModelFactory _viewModelFactory; private readonly IViewModelFactory _viewModelFactory;
private readonly DialogManager _dialogManager; private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
private readonly UpdateService _updateService; private readonly UpdateService _updateService;
private readonly AutoResetProgressMuxer _progressMuxer;
private DiscordClient? _discord;
public bool IsBusy { get; private set; }
public ProgressContainer<Percentage> Progress { get; } = new();
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5)); public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
public string? Token { get; set; } public DashboardViewModel Dashboard { get; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray();
public Guild? SelectedGuild { get; set; }
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
? GuildChannelMap?[SelectedGuild]
: null;
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
public RootViewModel( public RootViewModel(
IViewModelFactory viewModelFactory, IViewModelFactory viewModelFactory,
IEventAggregator eventAggregator,
DialogManager dialogManager, DialogManager dialogManager,
SettingsService settingsService, SettingsService settingsService,
UpdateService updateService) UpdateService updateService)
@@ -62,15 +33,31 @@ public class RootViewModel : Screen
_dialogManager = dialogManager; _dialogManager = dialogManager;
_settingsService = settingsService; _settingsService = settingsService;
_updateService = updateService; _updateService = updateService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset(); eventAggregator.Subscribe(this);
Dashboard = _viewModelFactory.CreateDashboardViewModel();
DisplayName = $"{App.Name} v{App.VersionString}"; DisplayName = $"{App.Name} v{App.VersionString}";
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
} }
private async Task ShowWarInUkraineMessageAsync()
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Ukraine is at war!", @"
My country, Ukraine, has been invaded by Russian military forces in an act of aggression that can only be described as genocide.
Be on the right side of history! Consider supporting Ukraine in its fight for freedom.
Press LEARN MORE to find ways that you can help.".Trim(),
"LEARN MORE", "CLOSE"
);
if (await _dialogManager.ShowDialogAsync(dialog) == true)
{
ProcessEx.StartShellExecute("https://tyrrrz.me");
}
}
private async ValueTask CheckForUpdatesAsync() private async ValueTask CheckForUpdatesAsync()
{ {
try try
@@ -97,18 +84,19 @@ public class RootViewModel : Screen
Notifications.Enqueue("Failed to perform application update"); Notifications.Enqueue("Failed to perform application update");
} }
} }
public async void OnViewFullyLoaded()
{
await ShowWarInUkraineMessageAsync();
await CheckForUpdatesAsync();
}
protected override async void OnViewLoaded() protected override void OnViewLoaded()
{ {
base.OnViewLoaded(); base.OnViewLoaded();
_settingsService.Load(); _settingsService.Load();
if (_settingsService.LastToken is not null)
{
Token = _settingsService.LastToken;
}
if (_settingsService.IsDarkModeEnabled) if (_settingsService.IsDarkModeEnabled)
{ {
App.SetDarkTheme(); App.SetDarkTheme();
@@ -117,21 +105,6 @@ public class RootViewModel : Screen
{ {
App.SetLightTheme(); App.SetLightTheme();
} }
// War in Ukraine message
Notifications.Enqueue(
"⚠ Ukraine is at war! Support my country in its fight for freedom",
"LEARN MORE", _ =>
{
ProcessEx.StartShellExecute("https://tyrrrz.me");
},
null,
true,
true,
TimeSpan.FromMinutes(1)
);
await CheckForUpdatesAsync();
} }
protected override void OnClose() protected override void OnClose()
@@ -142,149 +115,8 @@ public class RootViewModel : Screen
_updateService.FinalizeUpdate(false); _updateService.FinalizeUpdate(false);
} }
public async void ShowSettings() public void Handle(NotificationMessage message) =>
{ Notifications.Enqueue(message.Text);
var dialog = _viewModelFactory.CreateSettingsViewModel();
await _dialogManager.ShowDialogAsync(dialog);
}
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl); public void Dispose() => Notifications.Dispose();
public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels()
{
IsBusy = true;
var progress = _progressMuxer.CreateInput();
try
{
var token = Token?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(token))
return;
_settingsService.LastToken = token;
var discord = new DiscordClient(token);
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync())
{
var channels = await discord.GetGuildChannelsAsync(guild.Id);
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
}
_discord = discord;
GuildChannelMap = guildChannelMap;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
Notifications.Enqueue(ex.Message.TrimEnd('.'));
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Error pulling guilds and channels",
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
progress.ReportCompletion();
IsBusy = false;
}
}
public bool CanExportChannels =>
!IsBusy &&
_discord is not null &&
SelectedGuild is not null &&
SelectedChannels is not null &&
SelectedChannels.Any();
public async void ExportChannels()
{
IsBusy = true;
try
{
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
var exporter = new ChannelExporter(_discord);
var progresses = Enumerable
.Range(0, dialog.Channels!.Count)
.Select(_ => _progressMuxer.CreateInput())
.ToArray();
var successfulExportCount = 0;
await Parallel.ForEachAsync(
dialog.Channels.Zip(progresses),
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
},
async (tuple, cancellationToken) =>
{
var (channel, progress) = tuple;
try
{
var request = new ExportRequest(
dialog.Guild!,
channel,
dialog.OutputPath!,
dialog.SelectedFormat,
dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit,
dialog.MessageFilter,
dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
_settingsService.DateFormat
);
await exporter.ExportChannelAsync(request, progress, cancellationToken);
Interlocked.Increment(ref successfulExportCount);
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
Notifications.Enqueue(ex.Message.TrimEnd('.'));
}
finally
{
progress.ReportCompletion();
}
}
);
// Notify of overall completion
if (successfulExportCount > 0)
Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)");
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Error exporting channel(s)",
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
IsBusy = false;
}
}
} }
@@ -0,0 +1,368 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
d:DataContext="{d:DesignInstance Type=components:DashboardViewModel}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Loaded="{s:Action OnViewLoaded}"
mc:Ignorable="d">
<UserControl.Resources>
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category.Name" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token and pull data button -->
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Margin="12,12,0,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token icon -->
<materialDesign:PackIcon
Grid.Column="0"
Width="24"
Height="24"
Margin="8"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Password" />
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="0,6,6,8"
VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button -->
<Button
Grid.Column="2"
Margin="0,6,6,6"
Padding="4"
Command="{s:Action PopulateGuildsAndChannels}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Pull available guilds and channels (Enter)">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Settings button -->
<Button
Grid.Column="1"
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Settings">
<Button.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
</Button.Resources>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Settings" />
</Button>
</Grid>
<!-- Progress bar -->
<ProgressBar
Grid.Row="1"
Background="{DynamicResource MaterialDesignDarkBackground}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
<!-- Content -->
<Grid
Grid.Row="2"
Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="Light" />
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions -->
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="32,16"
FontSize="14"
LineHeight="20">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<LineBreak />
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<Hyperlink Command="{s:Action OpenDiscord}">
<Run Text="web browser" />
</Hyperlink>
<Run Text="and login" />
<LineBreak />
<Run Text="2. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
<Run Text="to toggle device toolbar" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. On the left, expand" />
<Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="and select" />
<Run FontWeight="SemiBold" Text="https://discord.com" />
<LineBreak />
<Run Text="6. Type" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="into the" />
<Run FontWeight="SemiBold" Text="Filter" />
<Run Text="box" />
<LineBreak />
<Run Text="7. If the token key does not appear, press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="key" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using a bot account" />
<LineBreak />
<Run Text="1. Open Discord" />
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
<Run Text="developer portal" />
</Hyperlink>
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
</TextBlock>
</ScrollViewer>
</Grid>
<!-- Guilds and channels -->
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,1,0">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource MaterialDesignDivider}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Stroke="{DynamicResource MaterialDesignDivider}"
StrokeThickness="1">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border Grid.Column="1">
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
SelectionMode="Extended"
TextSearch.TextPath="Model.Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<b:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</b:Interaction.Behaviors>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
<Expander
Margin="0"
Padding="0"
Background="Transparent"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,0,1"
Header="{Binding Name}"
IsExpanded="False">
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="-8" Background="Transparent">
<Grid.InputBindings>
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
</Grid.InputBindings>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Channel icon -->
<materialDesign:PackIcon
Grid.Column="0"
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<!-- Channel name -->
<TextBlock
Grid.Column="1"
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Name, Mode=OneWay}" />
<!-- Is selected checkmark -->
<materialDesign:PackIcon
Grid.Column="2"
Width="24"
Height="24"
Margin="8,0"
VerticalAlignment="Center"
Kind="Check"
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Grid>
<!-- Export button -->
<Button
Margin="32,24"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Command="{s:Action ExportChannels}"
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
Visibility="{Binding CanExportChannels, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<materialDesign:PackIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,9 @@
namespace DiscordChatExporter.Gui.Views.Components;
public partial class DashboardView
{
public DashboardView()
{
InitializeComponent();
}
}
@@ -1,10 +1,7 @@
<UserControl <UserControl
Style="{DynamicResource MaterialDesignRoot}"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
mc:Ignorable="d"
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView" x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
@@ -12,7 +9,10 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet" xmlns:s="https://github.com/canton7/Stylet"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils" xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
@@ -30,21 +30,21 @@
<!-- Guild icon --> <!-- Guild icon -->
<Ellipse <Ellipse
Grid.Column="0" Grid.Column="0"
Height="32" Width="32"
Width="32"> Height="32">
<Ellipse.Fill> <Ellipse.Fill>
<ImageBrush ImageSource="{Binding Guild.IconUrl}" /> <ImageBrush ImageSource="{Binding Guild.IconUrl}" />
</Ellipse.Fill> </Ellipse.Fill>
</Ellipse> </Ellipse>
<!-- Placeholder (for multiple channels) --> <!-- Channel count (for multiple channels) -->
<TextBlock <TextBlock
FontSize="19"
FontWeight="Light"
Grid.Column="1" Grid.Column="1"
Margin="8,0,0,0" Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<Run Text="{Binding Channels.Count, Mode=OneWay}" /> <Run Text="{Binding Channels.Count, Mode=OneWay}" />
<Run Text="channels selected" /> <Run Text="channels selected" />
@@ -52,12 +52,12 @@
<!-- Category and channel name (for single channel) --> <!-- Category and channel name (for single channel) -->
<TextBlock <TextBlock
FontSize="19"
FontWeight="Light"
Grid.Column="1" Grid.Column="1"
Margin="8,0,0,0" Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"> Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" /> <Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" />
<Run Text="/" /> <Run Text="/" />
@@ -69,21 +69,21 @@
</Grid> </Grid>
<Border <Border
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1"
Grid.Row="1" Grid.Row="1"
Padding="0,8"> Padding="0,8"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel> <StackPanel>
<!-- Format --> <!-- Format -->
<ComboBox <ComboBox
Margin="16,8"
materialDesign:HintAssist.Hint="Format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True" IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}" ItemsSource="{Binding AvailableFormats}"
Margin="16,8"
SelectedItem="{Binding SelectedFormat}" SelectedItem="{Binding SelectedFormat}"
Style="{DynamicResource MaterialDesignOutlinedComboBox}" Style="{DynamicResource MaterialDesignOutlinedComboBox}">
materialDesign:HintAssist.Hint="Format"
materialDesign:HintAssist.IsFloating="True">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" /> <TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
@@ -105,66 +105,66 @@
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<DatePicker <DatePicker
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Grid.Column="0"
Grid.Row="0" Grid.Row="0"
Grid.Column="0"
Margin="16,8,16,4" Margin="16,8,16,4"
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedDatePicker}" Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
ToolTip="Only include messages sent after this date" ToolTip="Only include messages sent after this date" />
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True" />
<DatePicker <DatePicker
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Grid.Column="1"
Grid.Row="0" Grid.Row="0"
Grid.Column="1"
Margin="16,8,16,4" Margin="16,8,16,4"
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedDatePicker}" Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
ToolTip="Only include messages sent before this date" ToolTip="Only include messages sent before this date" />
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True" />
<materialDesign:TimePicker <materialDesign:TimePicker
Grid.Column="0"
Grid.Row="1" Grid.Row="1"
Grid.Column="0"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}" Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsAfterDateSet}" IsEnabled="{Binding IsAfterDateSet}"
Margin="16,4,16,8"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}" SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}" Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
ToolTip="Only include messages sent after this time" ToolTip="Only include messages sent after this time" />
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True" />
<materialDesign:TimePicker <materialDesign:TimePicker
Grid.Column="1"
Grid.Row="1" Grid.Row="1"
Grid.Column="1"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}" Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsBeforeDateSet}" IsEnabled="{Binding IsBeforeDateSet}"
Margin="16,4,16,8"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}" SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}" Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
ToolTip="Only include messages sent before this time" ToolTip="Only include messages sent before this time" />
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True" />
</Grid> </Grid>
<!-- Partitioning --> <!-- Partitioning -->
<TextBox <TextBox
Margin="16,8" Margin="16,8"
materialDesign:HintAssist.Hint="Partition limit"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}" Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding PartitionLimitValue}" Text="{Binding PartitionLimitValue}"
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')" ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')" />
materialDesign:HintAssist.Hint="Partition limit"
materialDesign:HintAssist.IsFloating="True" />
<!-- Filtering --> <!-- Filtering -->
<TextBox <TextBox
Margin="16,8" Margin="16,8"
materialDesign:HintAssist.Hint="Message filter"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}" Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding MessageFilterValue}" Text="{Binding MessageFilterValue}"
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." />
materialDesign:HintAssist.Hint="Message filter"
materialDesign:HintAssist.IsFloating="True" />
<!-- Download media --> <!-- Download media -->
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)"> <Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
@@ -175,13 +175,13 @@
<TextBlock <TextBlock
Grid.Column="0" Grid.Column="0"
Text="Download media" VerticalAlignment="Center"
VerticalAlignment="Center" /> Text="Download media" />
<ToggleButton <ToggleButton
Grid.Column="1" Grid.Column="1"
HorizontalAlignment="Right" HorizontalAlignment="Right"
IsChecked="{Binding ShouldDownloadMedia}" VerticalAlignment="Center"
VerticalAlignment="Center" /> IsChecked="{Binding ShouldDownloadMedia}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -198,8 +198,8 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button <Button
Command="{s:Action ToggleAdvancedSection}"
Grid.Column="0" Grid.Column="0"
Command="{s:Action ToggleAdvancedSection}"
IsDefault="True" IsDefault="True"
ToolTip="Toggle advanced options"> ToolTip="Toggle advanced options">
<Button.Style> <Button.Style>
@@ -217,17 +217,17 @@
</Button> </Button>
<Button <Button
Grid.Column="2"
Command="{s:Action Confirm}" Command="{s:Action Confirm}"
Content="EXPORT" Content="EXPORT"
Grid.Column="2"
IsDefault="True" IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" /> Style="{DynamicResource MaterialDesignOutlinedButton}" />
<Button <Button
Grid.Column="3"
Margin="8,0,0,0"
Command="{s:Action Close}" Command="{s:Action Close}"
Content="CANCEL" Content="CANCEL"
Grid.Column="3"
IsCancel="True" IsCancel="True"
Margin="8,0,0,0"
Style="{DynamicResource MaterialDesignOutlinedButton}" /> Style="{DynamicResource MaterialDesignOutlinedButton}" />
</Grid> </Grid>
</Grid> </Grid>
@@ -6,7 +6,8 @@
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet" xmlns:s="https://github.com/canton7/Stylet"
MinWidth="500" xmlns:system="clr-namespace:System;assembly=System.Runtime"
Width="500"
d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}" d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}"
Style="{DynamicResource MaterialDesignRoot}" Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d"> mc:Ignorable="d">
@@ -41,15 +42,32 @@
</ScrollViewer> </ScrollViewer>
</Border> </Border>
<!-- Close --> <UniformGrid
<Button
Grid.Row="2" Grid.Row="2"
Margin="16" Margin="16"
HorizontalAlignment="Stretch" HorizontalAlignment="Right"
Command="{s:Action Close}" Columns="{Binding ButtonsCount}">
Content="CLOSE" <!-- OK -->
IsCancel="True" <Button
IsDefault="True" Command="{s:Action Close}"
Style="{DynamicResource MaterialDesignOutlinedButton}" /> Content="{Binding OkButtonText}"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}"
Visibility="{Binding IsOkButtonVisible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Button.CommandParameter>
<system:Boolean>True</system:Boolean>
</Button.CommandParameter>
</Button>
<!-- Cancel -->
<Button
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
Command="{s:Action Close}"
Content="{Binding CancelButtonText}"
IsCancel="True"
Style="{DynamicResource MaterialDesignOutlinedButton}"
Visibility="{Binding IsCancelButtonVisible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
</UniformGrid>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -6,7 +6,7 @@
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet" xmlns:s="https://github.com/canton7/Stylet"
Width="310" Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}" d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
Style="{DynamicResource MaterialDesignRoot}" Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d"> mc:Ignorable="d">
+17 -359
View File
@@ -1,374 +1,32 @@
<Window <Window
Background="{DynamicResource MaterialDesignPaper}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Height="550"
Icon="/DiscordChatExporter;component/favicon.ico"
MinWidth="325"
Style="{DynamicResource MaterialDesignRoot}"
Width="600"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
mc:Ignorable="d"
x:Class="DiscordChatExporter.Gui.Views.RootView" x:Class="DiscordChatExporter.Gui.Views.RootView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet" xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels" xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> Width="600"
Height="550"
MinWidth="325"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
Background="{DynamicResource MaterialDesignPaper}"
Icon="/DiscordChatExporter;component/favicon.ico"
Style="{DynamicResource MaterialDesignRoot}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.TaskbarItemInfo> <Window.TaskbarItemInfo>
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding Progress.Current.Fraction}" /> <TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding Dashboard.Progress.Current.Fraction}" />
</Window.TaskbarItemInfo> </Window.TaskbarItemInfo>
<Window.Resources>
<CollectionViewSource Source="{Binding AvailableChannels, Mode=OneWay}" x:Key="AvailableChannelsViewSource">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category.Name" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</Window.Resources>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}"> <materialDesign:DialogHost
Loaded="{s:Action OnViewFullyLoaded}"
SnackbarMessageQueue="{Binding Notifications}"
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid> <Grid>
<Grid.RowDefinitions> <ContentControl s:View.Model="{Binding Dashboard}" />
<RowDefinition Height="Auto" /> <materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid Background="{DynamicResource MaterialDesignDarkBackground}" Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token and pull data button -->
<materialDesign:Card
Grid.Column="0"
Grid.Row="0"
Margin="12,12,0,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token icon -->
<materialDesign:PackIcon
Foreground="{DynamicResource PrimaryHueMidBrush}"
Grid.Column="0"
Height="24"
Kind="Password"
Margin="8"
VerticalAlignment="Center"
Width="24" />
<!-- Token value -->
<TextBox
BorderThickness="0"
FontSize="16"
Grid.Column="1"
Margin="0,6,6,8"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
x:Name="TokenValueTextBox" />
<!-- Pull data button -->
<Button
Command="{s:Action PopulateGuildsAndChannels}"
Grid.Column="2"
IsDefault="True"
Margin="0,6,6,6"
Padding="4"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Pull available guilds and channels (Enter)">
<materialDesign:PackIcon
Height="24"
Kind="ArrowRight"
Width="24" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Settings button -->
<Button
Command="{s:Action ShowSettings}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Grid.Column="1"
Margin="6"
Padding="4"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Settings">
<Button.Resources>
<SolidColorBrush Color="#4C4C4C" x:Key="MaterialDesignFlatButtonClick" />
</Button.Resources>
<materialDesign:PackIcon
Height="24"
Kind="Settings"
Width="24" />
</Button>
</Grid>
<!-- Progress bar -->
<ProgressBar
Background="{DynamicResource MaterialDesignDarkBackground}"
Grid.Row="1"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
<!-- Content -->
<Grid
Background="{DynamicResource MaterialDesignCardBackground}"
Grid.Row="2"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="Light" />
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions -->
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock FontSize="14" Margin="32,16">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<LineBreak />
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account"
Margin="1,0,0,-2" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<Run FontWeight="SemiBold" Text="web browser" />
<Run Text="and login" />
<LineBreak />
<Run Text="2. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
<Run Text="to toggle device toolbar" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. On the left, expand" />
<Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="and select" />
<Run FontWeight="SemiBold" Text="https://discord.com" />
<LineBreak />
<Run Text="6. Type" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="into the" />
<Run FontWeight="SemiBold" Text="Filter" />
<Run Text="box" />
<LineBreak />
<Run Text="7. If the token key does not appear, press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="key" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialDesign:PackIcon
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot"
Margin="1,0,0,-2" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate as a bot" />
<LineBreak />
<Run Text="1. Open Discord developer portal" />
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
</TextBlock>
</ScrollViewer>
</Grid>
<!-- Guilds and channels -->
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Guilds -->
<Border
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,1,0"
Grid.Column="0">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid
Background="Transparent"
Cursor="Hand"
Margin="-8"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Fill="{DynamicResource MaterialDesignDivider}"
Height="48"
Margin="12,4,12,4"
Width="48" />
<!-- Guild icon -->
<Ellipse
Height="48"
Margin="12,4,12,4"
Stroke="{DynamicResource MaterialDesignDivider}"
StrokeThickness="1"
Width="48">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border Grid.Column="1">
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
SelectionMode="Extended"
TextSearch.TextPath="Model.Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<i:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</i:Interaction.Behaviors>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
<Expander
Background="Transparent"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,0,1"
Header="{Binding Name}"
IsExpanded="False"
Margin="0"
Padding="0">
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Background="Transparent" Margin="-8">
<Grid.InputBindings>
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
</Grid.InputBindings>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Channel icon -->
<materialDesign:PackIcon
Grid.Column="0"
Kind="Pound"
Margin="16,7,0,6"
VerticalAlignment="Center" />
<!-- Channel name -->
<TextBlock
FontSize="14"
Grid.Column="1"
Margin="3,8,8,8"
Text="{Binding Name, Mode=OneWay}"
VerticalAlignment="Center" />
<!-- Is selected checkmark -->
<materialDesign:PackIcon
Grid.Column="2"
Height="24"
Kind="Check"
Margin="8,0"
VerticalAlignment="Center"
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}"
Width="24" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Grid>
<!-- Export button -->
<Button
Command="{s:Action ExportChannels}"
HorizontalAlignment="Right"
Margin="32,24"
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
VerticalAlignment="Bottom"
Visibility="{Binding CanExportChannels, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<materialDesign:PackIcon
Height="32"
Kind="Download"
Width="32" />
</Button>
<!-- Notifications snackbar -->
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid>
</Grid> </Grid>
</materialDesign:DialogHost> </materialDesign:DialogHost>
</Window> </Window>
+11 -40
View File
@@ -1,19 +1,20 @@
# DiscordChatExporter # DiscordChatExporter
[![Build](https://github.com/Tyrrrz/DiscordChatExporter/workflows/CI/badge.svg?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions) [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://vshymanskyy.github.io/StandWithUkraine)
[![Coverage](https://codecov.io/gh/Tyrrrz/DiscordChatExporter/branch/master/graph/badge.svg)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter) [![Build](https://img.shields.io/github/workflow/status/Tyrrrz/DiscordChatExporter/CI/master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm) [![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm)
[![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate) [![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-black.svg)](https://twitter.com/Tyrrrz/status/1495972128977571848) [![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)
**Project status: active**<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup> > 🟢 **Project status**: active<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file. **DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features. It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
**If you have questions or issues, please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)** > If you have questions or issues, **please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**.
## Terms of use ## Terms of use
@@ -86,43 +87,13 @@ The following table lists all available download options:
- Graphical user interface (Windows) - Graphical user interface (Windows)
- Command line interface (Windows, Linux, macOS) - Command line interface (Windows, Linux, macOS)
- Support for both user and bot tokens - Authentication via both user and bot tokens
- Support for Discord's dialect of markdown
- Support for Discord's message filter syntax
- Support for attachments, embeds, emoji, and other rich media features
- Multiple output formats: HTML (dark/light), TXT, CSV, JSON - Multiple output formats: HTML (dark/light), TXT, CSV, JSON
- File partitioning, date ranges, and other export options - Support for markdown, attachments, embeds, emoji, and other rich media features
- Exports messages on the fly without buffering in-memory - File partitioning, date ranges, message filtering, and other export options
- Self-contained exports that don't require internet
## Screenshots ## Screenshots
![channel list](.screenshots/list.png) ![channel list](.screenshots/list.png)
![rendered output](.screenshots/output.png) ![rendered output](.screenshots/output.png)
## Building the project locally
Prerequisites:
- [.NET 6.0 SDK](https://dotnet.microsoft.com/download/dotnet/6.0)
- _(Recommended)_ C#/.NET IDE, such as [JetBrains Rider](https://www.jetbrains.com/rider), [VS Code](https://code.visualstudio.com/docs/languages/csharp), or [Visual Studio](https://visualstudio.microsoft.com).
To build the entire solution run the following command in the root of the repository:
```sh
> dotnet build
```
This will generate runtime artifacts for each project:
```plaintext
./DiscordChatExporter.Gui/bin/[Debug|Release]/[runtime]/*
./DiscordChatExporter.Cli/bin/[Debug|Release]/[runtime]/*
```
You can also build and run a specific project directly.
To do that, navigate to its directory and use `dotnet run`:
```sh
> cd DiscordChatExporter.Gui
> dotnet run
```