mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 10:31:41 +02:00
Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
bfac9b91d9
|
|||
4215dc76f4
|
|||
86adda610b
|
|||
f1c4cd040e
|
|||
737ab43ea4
|
|||
0229c00259
|
|||
95361d2e01
|
|||
5b7312109b
|
|||
9f1db9a662
|
9
app/Desktop/Common/SystemUtils.cs
Normal file
9
app/Desktop/Common/SystemUtils.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DHT.Desktop.Common;
|
||||
|
||||
static class SystemUtils {
|
||||
public static void OpenUrl(string url) {
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
}
|
@@ -1,45 +1,41 @@
|
||||
using System.Diagnostics;
|
||||
using DHT.Desktop.Common;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
OpenUrl("https://dht.chylex.com");
|
||||
SystemUtils.OpenUrl(Program.Website);
|
||||
}
|
||||
|
||||
public void ShowIssueTracker() {
|
||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||
}
|
||||
|
||||
public void ShowSourceCode() {
|
||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
}
|
||||
|
||||
public void ShowLibraryNetCore() {
|
||||
OpenUrl("https://github.com/dotnet/core");
|
||||
SystemUtils.OpenUrl("https://github.com/dotnet/core");
|
||||
}
|
||||
|
||||
public void ShowLibraryAvalonia() {
|
||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
}
|
||||
|
||||
public void ShowLibraryCommunityToolkit() {
|
||||
OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||
SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqlite() {
|
||||
OpenUrl("https://www.sqlite.org");
|
||||
SystemUtils.OpenUrl("https://www.sqlite.org");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqliteAdoNet() {
|
||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
}
|
||||
|
||||
public void ShowLibraryRxNet() {
|
||||
OpenUrl("https://github.com/dotnet/reactive");
|
||||
}
|
||||
|
||||
private static void OpenUrl(string url) {
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
@@ -52,10 +51,7 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
||||
string serverToken = ServerConfiguration.Token;
|
||||
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||
|
||||
Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)) {
|
||||
UseShellExecute = true
|
||||
});
|
||||
SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||
}
|
||||
|
@@ -22,20 +22,21 @@
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Margin" Value="5 0" />
|
||||
<Style Selector="Grid#ButtonPanel > Button">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel Name="RootPanel">
|
||||
<StackPanel Margin="42">
|
||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
||||
<Button Command="{Binding Exit}">Exit</Button>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="42 30">
|
||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
|
||||
|
||||
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
|
||||
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
|
||||
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
|
||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</UserControl>
|
||||
|
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -10,17 +12,20 @@ using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server.Data.Settings;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Sqlite.Schema;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
|
||||
|
||||
public string Version => Program.Version;
|
||||
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||
|
||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||
|
||||
|
||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
private string? dbFilePath;
|
||||
@@ -46,28 +51,22 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
|
||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||
dbFilePath = path;
|
||||
|
||||
|
||||
bool isNew = !File.Exists(path);
|
||||
|
||||
|
||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
if (db == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
|
||||
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
|
||||
}
|
||||
|
||||
|
||||
DatabaseSelected?.Invoke(this, db);
|
||||
}
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||
private readonly Window window;
|
||||
|
||||
public SchemaUpgradeCallbacks(Window window) {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
|
||||
public async Task<bool> CanUpgrade() {
|
||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||
}
|
||||
@@ -80,20 +79,12 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
await doUpgrade(reporter);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||
}
|
||||
|
||||
|
||||
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||
private readonly IReadOnlyList<IProgressCallback> callbacks;
|
||||
|
||||
private readonly int versionSteps;
|
||||
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||
private int versionProgress = 0;
|
||||
|
||||
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
|
||||
this.callbacks = callbacks;
|
||||
this.versionSteps = versionSteps;
|
||||
}
|
||||
|
||||
public async Task NextVersion() {
|
||||
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||
@@ -118,6 +109,53 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CheckUpdates() {
|
||||
Version? latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
|
||||
var client = new HttpClient(new SocketsHttpHandler {
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false
|
||||
});
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.MaxResponseContentBufferSize = 1024;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||
|
||||
string response;
|
||||
try {
|
||||
response = await client.GetStringAsync(Program.Website + "/version");
|
||||
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(response, out var latestVersion)) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
});
|
||||
|
||||
if (latestVersion == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Program.AssemblyVersion >= latestVersion) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
|
||||
SystemUtils.OpenUrl(Program.Website);
|
||||
Exit();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||
}
|
||||
|
@@ -9,17 +9,18 @@ namespace DHT.Desktop;
|
||||
|
||||
static class Program {
|
||||
public static string Version { get; }
|
||||
public static Version AssemblyVersion { get; }
|
||||
public static CultureInfo Culture { get; }
|
||||
public static ResourceLoader Resources { get; }
|
||||
public static Arguments Arguments { get; }
|
||||
|
||||
public const string Website = "https://dht.chylex.com";
|
||||
|
||||
static Program() {
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
||||
while (Version.EndsWith(".0")) {
|
||||
Version = Version[..^2];
|
||||
}
|
||||
AssemblyVersion = assembly.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
Version = VersionToString(AssemblyVersion);
|
||||
|
||||
Culture = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
@@ -30,6 +31,16 @@ static class Program {
|
||||
Resources = new ResourceLoader(assembly);
|
||||
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||
}
|
||||
|
||||
public static string VersionToString(Version version) {
|
||||
string versionStr = version.ToString();
|
||||
|
||||
while (versionStr.EndsWith(".0")) {
|
||||
versionStr = versionStr[..^2];
|
||||
}
|
||||
|
||||
return versionStr;
|
||||
}
|
||||
|
||||
public static void Main(string[] args) {
|
||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||
|
49
app/Resources/Tracker/bootstrap.js
vendored
49
app/Resources/Tracker/bootstrap.js
vendored
@@ -10,11 +10,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
/*[IMPORTS]*/
|
||||
|
||||
if (!DISCORD.isCompatible()) {
|
||||
alert("Discord History Tracker is not compatible with this version of Discord.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.DHT_LOADED = true;
|
||||
window.DHT_ON_UNLOAD = [];
|
||||
|
||||
/*[IMPORTS]*/
|
||||
|
||||
const port = 0; /*[PORT]*/
|
||||
const token = "/*[TOKEN]*/";
|
||||
STATE.setup(port, token);
|
||||
@@ -46,7 +51,7 @@
|
||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||
};
|
||||
|
||||
const onTrackingContinued = function(anyNewMessages) {
|
||||
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
|
||||
if (!STATE.isTracking()) {
|
||||
return;
|
||||
}
|
||||
@@ -63,7 +68,7 @@
|
||||
if (SETTINGS.autoscroll) {
|
||||
let action = null;
|
||||
|
||||
if (!DISCORD.hasMoreMessages()) {
|
||||
if (!hasMoreBefore) {
|
||||
console.debug("[DHT] Reached first message.");
|
||||
action = SETTINGS.afterFirstMsg;
|
||||
}
|
||||
@@ -84,7 +89,7 @@
|
||||
|
||||
let waitUntilSendingFinishedTimer = null;
|
||||
|
||||
const onMessagesUpdated = async messages => {
|
||||
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
|
||||
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||
return;
|
||||
}
|
||||
@@ -94,24 +99,16 @@
|
||||
|
||||
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||
waitUntilSendingFinishedTimer = null;
|
||||
onMessagesUpdated(messages);
|
||||
onMessagesUpdated(server, channel, messages, hasMoreBefore);
|
||||
}, 100);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const info = DISCORD.getSelectedChannel();
|
||||
|
||||
if (!info) {
|
||||
GUI.setStatus("Error (Unknown Channel)");
|
||||
stopTrackingDelayed();
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
try {
|
||||
await STATE.addDiscordChannel(info.server, info.channel);
|
||||
await STATE.addDiscordChannel(server, channel);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return;
|
||||
@@ -120,35 +117,33 @@
|
||||
try {
|
||||
if (!messages.length) {
|
||||
isSending = false;
|
||||
onTrackingContinued(false);
|
||||
onTrackingContinued(false, hasMoreBefore);
|
||||
}
|
||||
else {
|
||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||
onTrackingContinued(anyNewMessages);
|
||||
onTrackingContinued(anyNewMessages, hasMoreBefore);
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
};
|
||||
|
||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||
|
||||
STATE.onTrackingStateChanged(enabled => {
|
||||
if (enabled) {
|
||||
const messages = DISCORD.getMessages();
|
||||
|
||||
if (messages.length === 0) {
|
||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||
return;
|
||||
}
|
||||
|
||||
GUI.setStatus("Starting");
|
||||
GUI.createTrackingStyles();
|
||||
hasJustStarted = true;
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
onMessagesUpdated(messages);
|
||||
|
||||
if (!starter()) {
|
||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||
hasJustStarted = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
isSending = false;
|
||||
GUI.deleteTrackingStyles();
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -4,11 +4,26 @@ class DISCORD {
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
static CHANNEL_TYPE = {
|
||||
GUILD_TEXT: 0,
|
||||
DM: 1,
|
||||
GROUP_DM: 3,
|
||||
GUILD_ANNOUNCEMENT: 5,
|
||||
ANNOUNCEMENT_THREAD: 10,
|
||||
PUBLIC_THREAD: 11,
|
||||
PRIVATE_THREAD: 12
|
||||
PRIVATE_THREAD: 12,
|
||||
|
||||
isPrivate(type) {
|
||||
return type === this.DM
|
||||
|| type === this.GROUP_DM;
|
||||
},
|
||||
|
||||
isNavigableGuildChannel(type) {
|
||||
return type === this.GUILD_TEXT
|
||||
|| type === this.GUILD_ANNOUNCEMENT
|
||||
|| type === this.ANNOUNCEMENT_THREAD
|
||||
|| type === this.PUBLIC_THREAD
|
||||
|| type === this.PRIVATE_THREAD;
|
||||
}
|
||||
};
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
@@ -18,6 +33,74 @@ class DISCORD {
|
||||
THREAD_STARTER: 21
|
||||
};
|
||||
|
||||
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||
static PERMISSION = {
|
||||
VIEW_CHANNEL: 1n << 10n
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {function(String): ?DiscordGuild} getGuild
|
||||
*/
|
||||
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {function(String): Boolean} isOptInEnabled
|
||||
* @property {function(String): Set<String>} getOptedInChannels
|
||||
*/
|
||||
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {function(String): ?DiscordChannel} getChannel
|
||||
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
|
||||
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
|
||||
*/
|
||||
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
|
||||
|
||||
/**
|
||||
* @type {function(BigInt, Object): Boolean}
|
||||
*/
|
||||
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
|
||||
|
||||
/**
|
||||
* @type {function(String): MessageData}
|
||||
*/
|
||||
static #getMessages = WEBPACK.findFunction("getMessages");
|
||||
|
||||
/**
|
||||
* @type {function(String): void}
|
||||
*/
|
||||
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
|
||||
|
||||
/**
|
||||
* @type {function(): String}
|
||||
*/
|
||||
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
|
||||
|
||||
/**
|
||||
* @type {function(String): void}
|
||||
*/
|
||||
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
|
||||
|
||||
/**
|
||||
* @type {function(String, Object, String=null): void}
|
||||
*/
|
||||
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
|
||||
|
||||
static isCompatible() {
|
||||
return !!this.#guildStore
|
||||
&& !!this.#guildSettings
|
||||
&& !!this.#channelStore
|
||||
&& !!this.#hasPermission
|
||||
&& !!this.#getMessages
|
||||
&& !!this.#jumpToMessage
|
||||
&& !!this.#getCurrentlySelectedChannelId
|
||||
&& !!this.#selectPrivateChannel
|
||||
&& !!this.#transitionToGuildSync;
|
||||
}
|
||||
|
||||
static getMessageOuterElement() {
|
||||
return DOM.queryReactClass("messagesWrapper");
|
||||
}
|
||||
@@ -26,14 +109,6 @@ class DISCORD {
|
||||
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
||||
}
|
||||
|
||||
static getMessageElements() {
|
||||
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
||||
}
|
||||
|
||||
static hasMoreMessages() {
|
||||
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
|
||||
}
|
||||
|
||||
static loadOlderMessages() {
|
||||
const view = this.getMessageScrollerElement();
|
||||
|
||||
@@ -42,26 +117,43 @@ class DISCORD {
|
||||
}
|
||||
}
|
||||
|
||||
static getMessagesFromSelectedChannel() {
|
||||
const channelId = this.#getCurrentlySelectedChannelId();
|
||||
return channelId ? this.#getMessages(channelId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
|
||||
*/
|
||||
static setupMessageCallback(callback) {
|
||||
const previousMessages = new Set();
|
||||
|
||||
const onMessageElementsChanged = function() {
|
||||
const messages = DISCORD.getMessages();
|
||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
||||
const onMessageElementsChanged = force => {
|
||||
const messages = this.getMessagesFromSelectedChannel();
|
||||
if (!messages || !messages.ready || messages.loadingMore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const channel = this.#channelStore.getChannel(messages.channelId);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
previousMessages.clear();
|
||||
for (const message of messages) {
|
||||
for (const message of messages._array) {
|
||||
previousMessages.add(message.id);
|
||||
}
|
||||
|
||||
callback(messages);
|
||||
const server = this.#guildStore.getGuild(channel.guild_id);
|
||||
|
||||
callback(server, channel, messages._array, messages.hasMoreBefore);
|
||||
return true;
|
||||
};
|
||||
|
||||
let debounceTimer;
|
||||
@@ -74,7 +166,7 @@ class DISCORD {
|
||||
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(function () {
|
||||
const observer = new MutationObserver(function() {
|
||||
onMessageElementsChangedLater();
|
||||
});
|
||||
|
||||
@@ -112,216 +204,54 @@ class DISCORD {
|
||||
observedElement = null;
|
||||
window.clearInterval(observerTimer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message from a message element.
|
||||
* @returns { null | DiscordMessage } }
|
||||
*/
|
||||
static getMessageFromElement(ele) {
|
||||
const props = DOM.getReactProps(ele);
|
||||
|
||||
if (props && Array.isArray(props.children)) {
|
||||
for (const child of props.children) {
|
||||
if (!(child instanceof Object)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childProps = child.props;
|
||||
if (childProps instanceof Object && "message" in childProps) {
|
||||
return childProps.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array containing currently loaded messages.
|
||||
*/
|
||||
static getMessages() {
|
||||
try {
|
||||
const messages = [];
|
||||
|
||||
for (const ele of this.getMessageElements()) {
|
||||
try {
|
||||
const message = this.getMessageFromElement(ele);
|
||||
|
||||
if (message != null) {
|
||||
messages.push(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving messages.", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object containing the selected server and channel information.
|
||||
* For types DM and GROUP, the server and channel ids and names are identical.
|
||||
* @returns { {} | null }
|
||||
*/
|
||||
static getSelectedChannel() {
|
||||
try {
|
||||
let obj = null;
|
||||
|
||||
try {
|
||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||
if (child && child.props && child.props.channel) {
|
||||
obj = child.props.channel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
||||
}
|
||||
|
||||
if (!obj || typeof obj.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dms = DOM.queryReactClass("privateChannels");
|
||||
|
||||
if (dms) {
|
||||
let name;
|
||||
|
||||
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
|
||||
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
||||
|
||||
if (node) {
|
||||
name = node.nodeValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let type;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
switch (obj.type) {
|
||||
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
||||
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
const id = obj.id;
|
||||
const server = { id, name, type };
|
||||
const channel = { id, name };
|
||||
|
||||
return { server, channel };
|
||||
}
|
||||
else if (obj.guild_id) {
|
||||
let guild;
|
||||
|
||||
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
||||
if (child && child.props && child.props.guild) {
|
||||
guild = child.props.guild;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = {
|
||||
"id": guild.id,
|
||||
"name": guild.name,
|
||||
"type": "SERVER"
|
||||
};
|
||||
|
||||
const channel = {
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"extra": {
|
||||
"nsfw": obj.nsfw
|
||||
}
|
||||
};
|
||||
|
||||
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||
channel["extra"]["parent"] = obj.parent_id;
|
||||
}
|
||||
else {
|
||||
channel["extra"]["position"] = obj.position;
|
||||
channel["extra"]["topic"] = obj.topic;
|
||||
}
|
||||
|
||||
return { server, channel };
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel.", e);
|
||||
return null;
|
||||
}
|
||||
return () => onMessageElementsChanged(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||
*/
|
||||
static selectNextTextChannel() {
|
||||
const dms = DOM.queryReactClass("privateChannels");
|
||||
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
|
||||
if (!currentChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dms) {
|
||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
|
||||
const privateChannel = this.#channelStore.getSortedPrivateChannels();
|
||||
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
|
||||
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextChannelLink.click();
|
||||
nextChannelLink.scrollIntoView(true);
|
||||
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
const channelListEle = document.getElementById("channels");
|
||||
if (!channelListEle) {
|
||||
const guildId = currentChannel.guild_id;
|
||||
|
||||
let isChannelOptedIn;
|
||||
if (this.#guildSettings.isOptInEnabled(guildId)) {
|
||||
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
|
||||
isChannelOptedIn = channel => optedInChannels.has(channel.id);
|
||||
}
|
||||
else {
|
||||
isChannelOptedIn = _ => true;
|
||||
}
|
||||
|
||||
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
|
||||
const guildChannels = Object.values(guildChannelMap)
|
||||
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
|
||||
|
||||
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getLinkElement(channel) {
|
||||
return channel.querySelector("a[href^='/channels/'][role='link']");
|
||||
}
|
||||
|
||||
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
|
||||
let nextChannel = null;
|
||||
|
||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
||||
if (allTextChannels[index].className.includes("selected_")) {
|
||||
nextChannel = allTextChannels[index + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextChannel === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChannelLink = getLinkElement(nextChannel);
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextChannelLink.click();
|
||||
nextChannel.scrollIntoView(true);
|
||||
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
class DOM {
|
||||
/**
|
||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||
* @param {string} id
|
||||
* @param {HTMLElement?} [parent]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static id(id, parent) {
|
||||
@@ -9,6 +11,9 @@ class DOM {
|
||||
|
||||
/**
|
||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||
* @param {string} cls
|
||||
* @param {HTMLElement?} [parent]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static queryReactClass(cls, parent) {
|
||||
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||
@@ -55,31 +60,4 @@ class DOM {
|
||||
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
||||
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns internal React state object of an element.
|
||||
*/
|
||||
static getReactProps(ele) {
|
||||
const keys = Object.keys(ele || {});
|
||||
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
|
||||
|
||||
if (key) {
|
||||
// noinspection JSUnresolvedVariable
|
||||
return ele[key].memoizedProps;
|
||||
}
|
||||
|
||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
||||
return key ? ele[key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns internal React state object of an element, or null if the retrieval throws.
|
||||
*/
|
||||
static tryGetReactProps(ele) {
|
||||
try {
|
||||
return this.getReactProps(ele);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,14 +2,17 @@
|
||||
const GUI = (function() {
|
||||
let controller = null;
|
||||
let settings = null;
|
||||
let trackingStyles = null;
|
||||
|
||||
const stateChangedEvent = () => {
|
||||
if (settings) {
|
||||
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
||||
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
|
||||
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
||||
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
||||
|
||||
const autoscrollDisabled = !SETTINGS.autoscroll;
|
||||
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
|
||||
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||
}
|
||||
@@ -54,6 +57,7 @@ const GUI = (function() {
|
||||
|
||||
controller.ui.btnClose.addEventListener("click", () => {
|
||||
this.hideController();
|
||||
this.deleteTrackingStyles();
|
||||
window.DHT_ON_UNLOAD.forEach(f => f());
|
||||
delete window.DHT_ON_UNLOAD;
|
||||
delete window.DHT_LOADED;
|
||||
@@ -84,6 +88,7 @@ const GUI = (function() {
|
||||
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
|
||||
const html = `
|
||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br>
|
||||
<br>
|
||||
<label>After reaching the first message in channel...</label><br>
|
||||
${radio("afm", "nothing", "Continue Tracking")}
|
||||
@@ -93,8 +98,7 @@ ${radio("afm", "switch", "Switch to Next Channel")}
|
||||
<label>After reaching a previously saved message...</label><br>
|
||||
${radio("asm", "nothing", "Continue Tracking")}
|
||||
${radio("asm", "pause", "Pause Tracking")}
|
||||
${radio("asm", "switch", "Switch to Next Channel")}
|
||||
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
|
||||
${radio("asm", "switch", "Switch to Next Channel")}`;
|
||||
|
||||
settings = {
|
||||
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
|
||||
@@ -107,9 +111,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
||||
});
|
||||
|
||||
settings.ui = {
|
||||
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||
optsAfterFirstMsg: {},
|
||||
optsAfterSavedMsg: {}
|
||||
/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||
/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
|
||||
/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
|
||||
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
|
||||
};
|
||||
|
||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
||||
@@ -124,6 +129,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
||||
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
||||
});
|
||||
|
||||
settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
|
||||
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
|
||||
});
|
||||
|
||||
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
||||
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
||||
SETTINGS.afterFirstMsg = key;
|
||||
@@ -152,6 +161,29 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
||||
if (controller) {
|
||||
controller.ui.textStatus.innerText = state;
|
||||
}
|
||||
},
|
||||
|
||||
createTrackingStyles() {
|
||||
if (trackingStyles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let style = "";
|
||||
|
||||
if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) {
|
||||
style += `div[id^="message-accessories-"] { display: none; }`;
|
||||
}
|
||||
|
||||
if (style.length > 0) {
|
||||
trackingStyles = DOM.createStyle(style);
|
||||
}
|
||||
},
|
||||
|
||||
deleteTrackingStyles() {
|
||||
if (trackingStyles) {
|
||||
DOM.removeElement(trackingStyles);
|
||||
trackingStyles = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
@@ -35,17 +35,28 @@ const SETTINGS = (function() {
|
||||
obj[name] = value;
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
"_autoscroll": true,
|
||||
"_hidePreviewsWhileAutoscrolling": true,
|
||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||
};
|
||||
|
||||
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
||||
let hasChanged = false;
|
||||
|
||||
if (!loaded) {
|
||||
loaded = {
|
||||
"_autoscroll": true,
|
||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
|
||||
};
|
||||
|
||||
loaded = defaults;
|
||||
IS_FIRST_RUN = true;
|
||||
}
|
||||
else {
|
||||
for (const property in defaults) {
|
||||
if (!(property in loaded)) {
|
||||
loaded[property] = defaults[property];
|
||||
hasChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const root = {
|
||||
onSettingsChanged(callback) {
|
||||
@@ -54,10 +65,11 @@ const SETTINGS = (function() {
|
||||
};
|
||||
|
||||
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
||||
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
|
||||
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
||||
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
||||
|
||||
if (IS_FIRST_RUN) {
|
||||
if (IS_FIRST_RUN || hasChanged) {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
|
@@ -58,65 +58,39 @@ const STATE = (function() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {DiscordChannel} channel
|
||||
*/
|
||||
const getPrivateChannelName = function(channel) {
|
||||
if (channel.name === "") {
|
||||
return channel.rawRecipients.map(user => user.username).join(", ");
|
||||
}
|
||||
else {
|
||||
return channel.name;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Number} type
|
||||
*/
|
||||
const getChannelTypeName = function(type) {
|
||||
if (type === DISCORD.CHANNEL_TYPE.DM) {
|
||||
return "DM";
|
||||
}
|
||||
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
|
||||
return "GROUP";
|
||||
}
|
||||
else {
|
||||
return "SERVER";
|
||||
}
|
||||
};
|
||||
|
||||
const trackingStateChangedListeners = [];
|
||||
let isTracking = false;
|
||||
|
||||
const addedChannels = new Set();
|
||||
const addedUsers = new Set();
|
||||
|
||||
/**
|
||||
* @name DiscordUser
|
||||
* @property {String} id
|
||||
* @property {String} username
|
||||
* @property {String} discriminator
|
||||
* @property {String} [globalName]
|
||||
* @property {String} [avatar]
|
||||
* @property {Boolean} [bot]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessage
|
||||
* @property {String} id
|
||||
* @property {String} channel_id
|
||||
* @property {DiscordUser} author
|
||||
* @property {String} content
|
||||
* @property {Date} timestamp
|
||||
* @property {Date|null} editedTimestamp
|
||||
* @property {DiscordAttachment[]} attachments
|
||||
* @property {Object[]} embeds
|
||||
* @property {DiscordMessageReaction[]} [reactions]
|
||||
* @property {DiscordMessageReference} [messageReference]
|
||||
* @property {Number} type
|
||||
* @property {String} state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordAttachment
|
||||
* @property {String} id
|
||||
* @property {String} filename
|
||||
* @property {String} [content_type]
|
||||
* @property {String} size
|
||||
* @property {String} url
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReaction
|
||||
* @property {DiscordEmoji} emoji
|
||||
* @property {Number} count
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReference
|
||||
* @property {String} [message_id]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordEmoji
|
||||
* @property {String|null} id
|
||||
* @property {String|null} name
|
||||
* @property {Boolean} animated
|
||||
*/
|
||||
|
||||
return {
|
||||
setup(port, token) {
|
||||
serverPort = port;
|
||||
@@ -147,32 +121,51 @@ const STATE = (function() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {?DiscordGuild} serverInfo
|
||||
* @param {DiscordChannel} channelInfo
|
||||
*/
|
||||
async addDiscordChannel(serverInfo, channelInfo) {
|
||||
if (addedChannels.has(channelInfo.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = {
|
||||
id: serverInfo.id,
|
||||
name: serverInfo.name,
|
||||
type: serverInfo.type
|
||||
type: getChannelTypeName(channelInfo.type)
|
||||
};
|
||||
|
||||
const channel = {
|
||||
id: channelInfo.id,
|
||||
name: channelInfo.name
|
||||
extra: {}
|
||||
};
|
||||
|
||||
if ("extra" in channelInfo) {
|
||||
const extra = channelInfo.extra;
|
||||
|
||||
if ("parent" in extra) {
|
||||
channel.parent = extra.parent;
|
||||
}
|
||||
|
||||
channel.position = extra.position;
|
||||
channel.topic = extra.topic;
|
||||
channel.nsfw = extra.nsfw;
|
||||
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
|
||||
server.id = channelInfo.id;
|
||||
server.name = channel.name = getPrivateChannelName(channelInfo);
|
||||
}
|
||||
else if (serverInfo) {
|
||||
server.id = serverInfo.id;
|
||||
server.name = serverInfo.name;
|
||||
channel.name = channelInfo.name;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("nsfw" in channelInfo) {
|
||||
channel.extra.nsfw = channelInfo.nsfw;
|
||||
}
|
||||
|
||||
if ("topic" in channelInfo) {
|
||||
channel.extra.topic = channelInfo.topic;
|
||||
}
|
||||
|
||||
if ("position" in channelInfo) {
|
||||
channel.extra.position = channelInfo.position;
|
||||
}
|
||||
|
||||
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||
channel.extra.parent = channelInfo.parent_id;
|
||||
}
|
||||
|
||||
await post("/track-channel", { server, channel });
|
||||
@@ -293,7 +286,7 @@ const STATE = (function() {
|
||||
}
|
||||
|
||||
if (msg.reactions.length > 0) {
|
||||
obj.reactions = msg.reactions.map(reaction => {
|
||||
obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => {
|
||||
const emoji = reaction.emoji;
|
||||
|
||||
const mapped = {
|
||||
|
82
app/Resources/Tracker/scripts/types.js
Normal file
82
app/Resources/Tracker/scripts/types.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @name DiscordGuild
|
||||
* @property {String} id
|
||||
* @property {String} name
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordChannel
|
||||
* @property {String} id
|
||||
* @property {String} name
|
||||
* @property {Number} type
|
||||
* @property {String} [guild_id]
|
||||
* @property {String} [parent_id]
|
||||
* @property {Number} [position]
|
||||
* @property {String} [topic]
|
||||
* @property {Boolean} [nsfw]
|
||||
* @property {DiscordUser[]} [rawRecipients]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordUser
|
||||
* @property {String} id
|
||||
* @property {String} username
|
||||
* @property {String} discriminator
|
||||
* @property {String} [globalName]
|
||||
* @property {String} [avatar]
|
||||
* @property {Boolean} [bot]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessage
|
||||
* @property {String} id
|
||||
* @property {String} channel_id
|
||||
* @property {DiscordUser} author
|
||||
* @property {String} content
|
||||
* @property {Date} timestamp
|
||||
* @property {Date|null} editedTimestamp
|
||||
* @property {DiscordAttachment[]} attachments
|
||||
* @property {Object[]} embeds
|
||||
* @property {DiscordMessageReaction[]} [reactions]
|
||||
* @property {DiscordMessageReference} [messageReference]
|
||||
* @property {Number} type
|
||||
* @property {String} state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordAttachment
|
||||
* @property {String} id
|
||||
* @property {String} filename
|
||||
* @property {String} [content_type]
|
||||
* @property {String} size
|
||||
* @property {String} url
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReaction
|
||||
* @property {DiscordEmoji} emoji
|
||||
* @property {Number} count
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReference
|
||||
* @property {String} [message_id]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordEmoji
|
||||
* @property {String|null} id
|
||||
* @property {String|null} name
|
||||
* @property {Boolean} animated
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name MessageData
|
||||
* @type {Object}
|
||||
* @property {String} channelId
|
||||
* @property {Boolean} ready
|
||||
* @property {Boolean} loadingMore
|
||||
* @property {Boolean} hasMoreAfter
|
||||
* @property {Boolean} hasMoreBefore
|
||||
* @property {Array<DiscordMessage>} _array
|
||||
*/
|
98
app/Resources/Tracker/scripts/webpack.js
Normal file
98
app/Resources/Tracker/scripts/webpack.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Parts copied from Better Discord, licensed under Apache License 2.0.
|
||||
*
|
||||
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
|
||||
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
|
||||
*/
|
||||
class WEBPACK {
|
||||
static get require() {
|
||||
if (this._require) {
|
||||
return this._require;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {Object} m
|
||||
* @property {Object} c
|
||||
*/
|
||||
let hookedRequire;
|
||||
|
||||
const id = "dht-webpackmodules-" + new Date().getTime();
|
||||
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
|
||||
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
|
||||
}
|
||||
|
||||
delete hookedRequire.m[id];
|
||||
delete hookedRequire.c[id];
|
||||
return this._require = hookedRequire;
|
||||
}
|
||||
|
||||
static getAllModules() {
|
||||
return this.require.c;
|
||||
}
|
||||
|
||||
static filterByProps(...props) {
|
||||
return module => props.every(prop => prop in module);
|
||||
}
|
||||
|
||||
static filterByPropsWithPredicate(predicate, ...props) {
|
||||
return module => props.every(prop => prop in module && predicate(module[prop]));
|
||||
}
|
||||
|
||||
static findModules(filter) {
|
||||
const defaultExport = true;
|
||||
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const module of Object.values(this.getAllModules())) {
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property [Z]
|
||||
* @property [ZP]
|
||||
* @property [__esModule]
|
||||
* @property [default]
|
||||
*/
|
||||
const exports = module.exports;
|
||||
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let foundModule = null;
|
||||
if (exports.Z && moduleFilter(exports.Z)) {
|
||||
foundModule = defaultExport ? exports.Z : exports;
|
||||
}
|
||||
if (exports.ZP && moduleFilter(exports.ZP)) {
|
||||
foundModule = defaultExport ? exports.ZP : exports;
|
||||
}
|
||||
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
|
||||
foundModule = defaultExport ? exports.default : exports;
|
||||
}
|
||||
if (moduleFilter(exports)) {
|
||||
foundModule = exports;
|
||||
}
|
||||
|
||||
if (foundModule) {
|
||||
results.push(foundModule);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
static findModule(name, filter) {
|
||||
const modules = this.findModules(filter);
|
||||
if (modules.length === 1) {
|
||||
return modules[0];
|
||||
}
|
||||
|
||||
console.error("[DHT] Cannot find module " + name + ", results found:", modules.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
static findFunction(name, additionalRequiredProps) {
|
||||
const searchedProps = additionalRequiredProps ? [name, ...additionalRequiredProps] : [name];
|
||||
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof(prop) === "function", ...searchedProps));
|
||||
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
|
||||
}
|
||||
}
|
@@ -18,11 +18,13 @@
|
||||
height: 262px;
|
||||
margin-left: -400px;
|
||||
margin-top: -131px;
|
||||
line-height: 120%;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
z-index: 1000002;
|
||||
}
|
||||
|
||||
#dht-cfg-note {
|
||||
margin-top: 22px;
|
||||
#dht-cfg label {
|
||||
display: inline-block;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ sealed class SqliteSchemaUpgradeTo9 : ISchemaUpgrade {
|
||||
await SqliteSchema.CreateMessageAttachmentsTable(conn);
|
||||
|
||||
await reporter.MainWork("Migrating message attachments...", 1, 3);
|
||||
await conn.ExecuteAsync("INSERT INTO message_attachments (message_id, attachment_id) SELECT message_id, attachment_id FROM attachments");
|
||||
await conn.ExecuteAsync("INSERT INTO message_attachments (message_id, attachment_id) SELECT message_id, attachment_id FROM attachments a JOIN messages m USING (message_id)");
|
||||
|
||||
await reporter.MainWork("Applying schema changes...", 2, 3);
|
||||
await conn.ExecuteAsync("DROP INDEX attachments_message_ix");
|
||||
|
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reactive.Subjects;
|
||||
@@ -70,10 +71,15 @@ sealed class DownloaderTask : IAsyncDisposable {
|
||||
var client = new HttpClient(new SocketsHttpHandler {
|
||||
ConnectTimeout = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
|
||||
|
||||
string tempFileName = Path.GetTempFileName();
|
||||
log.Debug("Using temporary file: " + tempFileName);
|
||||
|
||||
await using var tempFileStream = new FileStream(tempFileName, FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.DeleteOnClose);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
var item = await downloadQueue.Reader.ReadAsync(cancellationToken);
|
||||
log.Debug("Downloading " + item.DownloadUrl + "...");
|
||||
@@ -81,15 +87,7 @@ sealed class DownloaderTask : IAsyncDisposable {
|
||||
try {
|
||||
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, item.DownloadUrl), HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
if (response.Content.Headers.ContentLength is {} contentLength) {
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await db.Downloads.AddDownload(item.ToSuccess(contentLength), stream);
|
||||
}
|
||||
else {
|
||||
await db.Downloads.AddDownload(item.ToFailure(), stream: null);
|
||||
log.Error("Download response has no content length: " + item.DownloadUrl);
|
||||
}
|
||||
await HandleResponse(response, tempFileStream, item);
|
||||
} catch (OperationCanceledException e) when (e.CancellationToken == cancellationToken) {
|
||||
// Ignore.
|
||||
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||
@@ -111,6 +109,27 @@ sealed class DownloaderTask : IAsyncDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleResponse(HttpResponseMessage response, FileStream tempFileStream, DownloadItem item) {
|
||||
if (response.Content.Headers.ContentLength is not {} contentLength) {
|
||||
throw new InvalidOperationException("Download response has no content length: " + item.DownloadUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
if (tempFileStream.Length != 0) {
|
||||
throw new InvalidOperationException("Temporary file is not empty: " + tempFileStream.Name);
|
||||
}
|
||||
|
||||
await using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken)) {
|
||||
await responseStream.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
tempFileStream.Seek(0, SeekOrigin.Begin);
|
||||
await db.Downloads.AddDownload(item.ToSuccess(contentLength), tempFileStream);
|
||||
} finally {
|
||||
tempFileStream.SetLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
try {
|
||||
await cancellationTokenSource.CancelAsync();
|
||||
|
@@ -15,7 +15,7 @@ sealed class GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parame
|
||||
string bootstrap = await resources.ReadTextAsync("Tracker/bootstrap.js");
|
||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + parameters.Port + ";")
|
||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(parameters.Token))
|
||||
.Replace("/*[IMPORTS]*/", await resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
||||
.Replace("/*[IMPORTS]*/", await resources.ReadJoinedAsync("Tracker/scripts/", '\n', [ "/webpack.js" ]))
|
||||
.Replace("/*[CSS-CONTROLLER]*/", await resources.ReadTextAsync("Tracker/styles/controller.css"))
|
||||
.Replace("/*[CSS-SETTINGS]*/", await resources.ReadTextAsync("Tracker/styles/settings.css"))
|
||||
.Replace("/*[DEBUGGER]*/", request.Query.ContainsKey("debug") ? "debugger;" : "");
|
||||
|
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
@@ -43,14 +45,26 @@ public sealed class ResourceLoader(Assembly assembly) {
|
||||
return TryGetEmbeddedStream(filename) is {} stream ? await ReadBytesAsync(stream) : null;
|
||||
}
|
||||
|
||||
public async Task<string> ReadJoinedAsync(string path, char separator) {
|
||||
StringBuilder joined = new ();
|
||||
public async Task<string> ReadJoinedAsync(string path, char separator, string[] order) {
|
||||
List<(string, Stream)> resourceNames = [];
|
||||
|
||||
foreach (var embeddedName in assembly.GetManifestResourceNames()) {
|
||||
if (embeddedName.Replace('\\', '/').StartsWith(path)) {
|
||||
joined.Append(await ReadTextAsync(assembly.GetManifestResourceStream(embeddedName)!)).Append(separator);
|
||||
var embeddedNameNormalized = embeddedName.Replace('\\', '/');
|
||||
if (embeddedNameNormalized.StartsWith(path)) {
|
||||
resourceNames.Add((embeddedNameNormalized, assembly.GetManifestResourceStream(embeddedName)!));
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder joined = new ();
|
||||
|
||||
int GetOrderKey(string name) {
|
||||
int key = Array.FindIndex(order, name.EndsWith);
|
||||
return key == -1 ? order.Length : key;
|
||||
}
|
||||
|
||||
foreach(var (_, stream) in resourceNames.OrderBy(item => GetOrderKey(item.Item1))) {
|
||||
joined.Append(await ReadTextAsync(stream)).Append(separator);
|
||||
}
|
||||
|
||||
return joined.ToString(0, Math.Max(0, joined.Length - 1));
|
||||
}
|
||||
|
@@ -8,5 +8,5 @@ using DHT.Utils;
|
||||
namespace DHT.Utils;
|
||||
|
||||
static class Version {
|
||||
public const string Tag = "43.0.0.0";
|
||||
public const string Tag = "44.0.0.0";
|
||||
}
|
||||
|
Reference in New Issue
Block a user