mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 19:31:42 +02:00
Compare commits
29 Commits
c3bf7d5dc3
...
master-bro
Author | SHA1 | Date | |
---|---|---|---|
5277f28318
|
|||
![]() |
515825f7a4 | ||
af48bf60ce
|
|||
3ac968aa38
|
|||
5debfa9ec6
|
|||
92b8450c80
|
|||
ff6e21186c
|
|||
f1bbe6d13c
|
|||
4eb78def90
|
|||
4e8df28dc2
|
|||
![]() |
6ca386b741 | ||
ce87901088
|
|||
ea03f285a6
|
|||
4d914f2ae7
|
|||
a20ce8ee71
|
|||
b5ae561da2
|
|||
3cca167793
|
|||
18f1ea15fa
|
|||
21e196f4fe
|
|||
ad831d89e9
|
|||
3ba4d018a9
|
|||
ab6e929da6
|
|||
5569c159d4
|
|||
3d228152c0
|
|||
155dd226cb
|
|||
4b823802d3
|
|||
7c9ab5137e
|
|||
6de55b434a
|
|||
24a240fd29
|
10
README.md
10
README.md
@@ -1,24 +1,24 @@
|
|||||||
# Welcome
|
# Welcome
|
||||||
|
|
||||||
All you need to **use Discord History Tracker** is either an up-to-date browser, or the [Discord desktop client](https://discord.com/download). Visit the [official website](https://dht.chylex.com) for instructions.
|
This branch is dedicated to the browser-only version of **Discord History Tracker**. All you need to use it is either an up-to-date browser, or the [Discord desktop client](https://discord.com/download). Visit the [official website](https://dht.chylex.com/browser-only) for instructions.
|
||||||
|
|
||||||
To **report an issue or suggestion**, first please see the [issues](https://github.com/chylex/Discord-History-Tracker/issues) page and make sure someone else hasn't already created a similar issue report. If you do find an existing issue, comment on it or add a reaction. Otherwise, either click [New Issue](https://github.com/chylex/Discord-History-Tracker/issues/new), or contact me via email [contact@chylex.com](mailto:contact@chylex.com) or Twitter [@chylexmc](https://twitter.com/chylexmc).
|
To **report an issue or suggestion**, first please see the [issues](https://github.com/chylex/Discord-History-Tracker/issues) page and make sure someone else hasn't already created a similar issue report. If you do find an existing issue, comment on it or add a reaction. Otherwise, either click [New Issue](https://github.com/chylex/Discord-History-Tracker/issues/new), or contact me via email [contact@chylex.com](mailto:contact@chylex.com) or Twitter [@chylexmc](https://twitter.com/chylexmc).
|
||||||
|
|
||||||
If you are interested in **creating your own version** from the source code, continue reading the [build instructions](#Build-Instructions) below.
|
If you are interested in **building from source code**, continue reading the [build instructions](#Build-Instructions) below.
|
||||||
|
|
||||||
# Build Instructions
|
# Build Instructions
|
||||||
|
|
||||||
Follow the steps below to create your own version of Discord History Tracker.
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
Fork the repository and clone it to your computer (if you've never used git, you can download the [GitHub Desktop](https://desktop.github.com) client to get started quickly).
|
Fork the repository and clone it to your computer (if you've never used git, you can download the [GitHub Desktop](https://desktop.github.com) client to get started quickly).
|
||||||
|
|
||||||
|
By default, cloning will default to the `master` branch which is dedicated to the desktop app. Make sure to switch to the `master-browser-only` branch.
|
||||||
|
|
||||||
Now you can modify the source code:
|
Now you can modify the source code:
|
||||||
* `src/tracker/` contains JS files that are automatically combined into the **tracker bookmark/script**
|
* `src/tracker/` contains JS files that are automatically combined into the **tracker bookmark/script**
|
||||||
* `src/viewer/` contains HTML, CSS, JS files that are then combined into the **offline viewer page**
|
* `src/viewer/` contains HTML, CSS, JS files that are then combined into the **offline viewer page**
|
||||||
* `lib/` contains utilities required to build the project
|
* `lib/` contains utilities required to build the project
|
||||||
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
* `web/` contains source code of the [official website](https://dht.chylex.com/browser-only), which can be used as a template when making your own website
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
|
File diff suppressed because one or more lines are too long
2538
bld/track.user.js
2538
bld/track.user.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
96
build.py
96
build.py
@@ -8,11 +8,11 @@ import os
|
|||||||
import re
|
import re
|
||||||
import distutils.dir_util
|
import distutils.dir_util
|
||||||
|
|
||||||
VERSION_SHORT = "v.31"
|
VERSION_SHORT = "v.31h"
|
||||||
VERSION_FULL = VERSION_SHORT + ", released 3 April 2021"
|
VERSION_FULL = VERSION_SHORT + ", released 03 March 2024"
|
||||||
|
|
||||||
EXEC_UGLIFYJS_WIN = "{2}/lib/uglifyjs.cmd --parse bare_returns --compress --mangle toplevel --mangle-props keep_quoted,reserved=[{3}] --output \"{1}\" \"{0}\""
|
EXEC_UGLIFYJS_WIN = "{2}/lib/uglifyjs.cmd --parse bare_returns --compress --output \"{1}\" \"{0}\""
|
||||||
EXEC_UGLIFYJS_AUTO = "uglifyjs --parse bare_returns --compress --mangle toplevel --mangle-props keep_quoted,reserved=[{3}] --output \"{1}\" \"{0}\""
|
EXEC_UGLIFYJS_AUTO = "uglifyjs --parse bare_returns --compress --output \"{1}\" \"{0}\""
|
||||||
|
|
||||||
USE_UGLIFYJS = "--nominify" not in sys.argv
|
USE_UGLIFYJS = "--nominify" not in sys.argv
|
||||||
USE_MINIFICATION = "--nominify" not in sys.argv
|
USE_MINIFICATION = "--nominify" not in sys.argv
|
||||||
@@ -32,10 +32,6 @@ else:
|
|||||||
USE_UGLIFYJS = False
|
USE_UGLIFYJS = False
|
||||||
print("Could not find 'uglifyjs', JS minification will be disabled")
|
print("Could not find 'uglifyjs', JS minification will be disabled")
|
||||||
|
|
||||||
if USE_UGLIFYJS:
|
|
||||||
with open("reserve.txt", "r") as reserved:
|
|
||||||
RESERVED_PROPS = ",".join(line.strip() for line in reserved.readlines())
|
|
||||||
|
|
||||||
|
|
||||||
# File Utilities
|
# File Utilities
|
||||||
|
|
||||||
@@ -53,6 +49,23 @@ def combine_files(input_pattern, output_file):
|
|||||||
output_file.write(line.replace("{{{version:full}}}", VERSION_FULL))
|
output_file.write(line.replace("{{{version:full}}}", VERSION_FULL))
|
||||||
|
|
||||||
|
|
||||||
|
def combine_files_to_str(input_pattern):
|
||||||
|
is_first_file = True
|
||||||
|
output = []
|
||||||
|
|
||||||
|
with fileinput.input(sorted(glob.glob(input_pattern))) as stream:
|
||||||
|
for line in stream:
|
||||||
|
if stream.isfirstline():
|
||||||
|
if is_first_file:
|
||||||
|
is_first_file = False
|
||||||
|
else:
|
||||||
|
output.append("\n")
|
||||||
|
|
||||||
|
output.append(line.replace("{{{version:full}}}", VERSION_FULL))
|
||||||
|
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
def minify_css(input_file, output_file):
|
def minify_css(input_file, output_file):
|
||||||
if not USE_MINIFICATION:
|
if not USE_MINIFICATION:
|
||||||
if input_file != output_file:
|
if input_file != output_file:
|
||||||
@@ -77,24 +90,39 @@ def minify_css(input_file, output_file):
|
|||||||
|
|
||||||
# Build System
|
# Build System
|
||||||
|
|
||||||
def build_tracker_html():
|
def build_tracker():
|
||||||
output_file_raw = "bld/track.js"
|
output_file_raw = "bld/track.js"
|
||||||
output_file_html = "bld/track.html"
|
output_file_html = "bld/track.html"
|
||||||
|
output_file_userscript = "bld/track.user.js"
|
||||||
|
|
||||||
output_file_tmp = "bld/track.tmp.js"
|
with open("src/tracker/styles/controller.css", "r") as f:
|
||||||
input_pattern = "src/tracker/*.js"
|
controller_css = f.read()
|
||||||
|
|
||||||
|
with open("src/tracker/styles/settings.css", "r") as f:
|
||||||
|
settings_css = f.read()
|
||||||
|
|
||||||
|
with open("src/tracker/bootstrap.js", "r") as f:
|
||||||
|
bootstrap_js = f.read()
|
||||||
|
|
||||||
|
combined_tracker_js = combine_files_to_str("src/tracker/scripts/*.js")
|
||||||
|
combined_tracker_js = combined_tracker_js.replace("/*[CSS-CONTROLLER]*/", controller_css)
|
||||||
|
combined_tracker_js = combined_tracker_js.replace("/*[CSS-SETTINGS]*/", settings_css)
|
||||||
|
|
||||||
|
full_tracker_js = bootstrap_js.replace("/*[IMPORTS]*/", combined_tracker_js)
|
||||||
|
|
||||||
with open(output_file_raw, "w") as out:
|
with open(output_file_raw, "w") as out:
|
||||||
if not USE_UGLIFYJS:
|
if not USE_UGLIFYJS:
|
||||||
out.write("(function(){\n")
|
out.write("(function(){\n")
|
||||||
|
|
||||||
combine_files(input_pattern, out)
|
out.write(full_tracker_js)
|
||||||
|
|
||||||
if not USE_UGLIFYJS:
|
if not USE_UGLIFYJS:
|
||||||
out.write("})()")
|
out.write("})()")
|
||||||
|
|
||||||
if USE_UGLIFYJS:
|
if USE_UGLIFYJS:
|
||||||
os.system(EXEC_UGLIFYJS.format(output_file_raw, output_file_tmp, WORKING_DIR, RESERVED_PROPS))
|
output_file_tmp = "bld/track.tmp.js"
|
||||||
|
|
||||||
|
os.system(EXEC_UGLIFYJS.format(output_file_raw, output_file_tmp, WORKING_DIR))
|
||||||
|
|
||||||
with open(output_file_raw, "w") as out:
|
with open(output_file_raw, "w") as out:
|
||||||
out.write("javascript:(function(){")
|
out.write("javascript:(function(){")
|
||||||
@@ -105,27 +133,30 @@ def build_tracker_html():
|
|||||||
out.write("})()")
|
out.write("})()")
|
||||||
|
|
||||||
os.remove(output_file_tmp)
|
os.remove(output_file_tmp)
|
||||||
|
|
||||||
with open(output_file_raw, "r") as raw:
|
with open(output_file_raw, "r") as raw:
|
||||||
script_contents = raw.read().replace("&", "&").replace('"', """).replace("'", "'").replace("<", "<").replace(">", ">")
|
minified_tracker_js = raw.read()
|
||||||
|
|
||||||
with open(output_file_html, "w") as out:
|
write_tracker_html(output_file_html, minified_tracker_js)
|
||||||
out.write(script_contents)
|
write_tracker_userscript(output_file_userscript, full_tracker_js)
|
||||||
|
|
||||||
|
|
||||||
def build_tracker_userscript():
|
def write_tracker_html(output_file, tracker_js):
|
||||||
output_file = "bld/track.user.js"
|
tracker_js = tracker_js.replace("&", "&").replace('"', """).replace("'", "'").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
input_pattern = "src/tracker/*.js"
|
|
||||||
userscript_base = "src/base/track.user.js"
|
|
||||||
|
|
||||||
with open(userscript_base, "r") as base:
|
|
||||||
userscript_contents = base.read().replace("{{{version}}}", VERSION_SHORT).split("{{{contents}}}")
|
|
||||||
|
|
||||||
with open(output_file, "w") as out:
|
with open(output_file, "w") as out:
|
||||||
out.write(userscript_contents[0])
|
out.write(tracker_js)
|
||||||
combine_files(input_pattern, out)
|
|
||||||
out.write(userscript_contents[1])
|
|
||||||
|
def write_tracker_userscript(output_file, full_tracker_js):
|
||||||
|
with open("src/base/track.user.js", "r") as f:
|
||||||
|
userscript_js = f.read()
|
||||||
|
|
||||||
|
userscript_js = userscript_js.replace("{{{version}}}", VERSION_SHORT)
|
||||||
|
userscript_js = userscript_js.replace("{{{contents}}}", full_tracker_js)
|
||||||
|
|
||||||
|
with open(output_file, "w") as out:
|
||||||
|
out.write(userscript_js)
|
||||||
|
|
||||||
|
|
||||||
def build_viewer():
|
def build_viewer():
|
||||||
@@ -150,7 +181,7 @@ def build_viewer():
|
|||||||
combine_files(input_js_pattern, out)
|
combine_files(input_js_pattern, out)
|
||||||
|
|
||||||
if USE_UGLIFYJS:
|
if USE_UGLIFYJS:
|
||||||
os.system(EXEC_UGLIFYJS.format(tmp_js_file_combined, tmp_js_file_minified, WORKING_DIR, RESERVED_PROPS))
|
os.system(EXEC_UGLIFYJS.format(tmp_js_file_combined, tmp_js_file_minified, WORKING_DIR))
|
||||||
else:
|
else:
|
||||||
shutil.copyfile(tmp_js_file_combined, tmp_js_file_minified)
|
shutil.copyfile(tmp_js_file_combined, tmp_js_file_minified)
|
||||||
|
|
||||||
@@ -202,11 +233,8 @@ def build_website():
|
|||||||
|
|
||||||
os.makedirs("bld", exist_ok = True)
|
os.makedirs("bld", exist_ok = True)
|
||||||
|
|
||||||
print("Building tracker html...")
|
print("Building tracker...")
|
||||||
build_tracker_html()
|
build_tracker()
|
||||||
|
|
||||||
print("Building tracker userscript...")
|
|
||||||
build_tracker_userscript()
|
|
||||||
|
|
||||||
print("Building viewer...")
|
print("Building viewer...")
|
||||||
build_viewer()
|
build_viewer()
|
||||||
|
72
reserve.txt
72
reserve.txt
@@ -1,72 +0,0 @@
|
|||||||
autoscroll
|
|
||||||
_autoscroll
|
|
||||||
afterFirstMsg
|
|
||||||
_afterFirstMsg
|
|
||||||
afterSavedMsg
|
|
||||||
_afterSavedMsg
|
|
||||||
enableImagePreviews
|
|
||||||
_enableImagePreviews
|
|
||||||
enableFormatting
|
|
||||||
_enableFormatting
|
|
||||||
enableAnimatedEmoji
|
|
||||||
_enableAnimatedEmoji
|
|
||||||
enableUserAvatars
|
|
||||||
_enableUserAvatars
|
|
||||||
DHT_LOADED
|
|
||||||
DHT_EMBEDDED
|
|
||||||
meta
|
|
||||||
data
|
|
||||||
users
|
|
||||||
userindex
|
|
||||||
servers
|
|
||||||
channels
|
|
||||||
u
|
|
||||||
t
|
|
||||||
m
|
|
||||||
f
|
|
||||||
e
|
|
||||||
a
|
|
||||||
t
|
|
||||||
te
|
|
||||||
d
|
|
||||||
r
|
|
||||||
re
|
|
||||||
c
|
|
||||||
n
|
|
||||||
an
|
|
||||||
tag
|
|
||||||
avatar
|
|
||||||
author
|
|
||||||
type
|
|
||||||
state
|
|
||||||
name
|
|
||||||
position
|
|
||||||
topic
|
|
||||||
nsfw
|
|
||||||
id
|
|
||||||
username
|
|
||||||
bot
|
|
||||||
discriminator
|
|
||||||
timestamp
|
|
||||||
content
|
|
||||||
editedTimestamp
|
|
||||||
mentions
|
|
||||||
embeds
|
|
||||||
attachments
|
|
||||||
title
|
|
||||||
description
|
|
||||||
reply
|
|
||||||
reactions
|
|
||||||
emoji
|
|
||||||
count
|
|
||||||
animated
|
|
||||||
ext
|
|
||||||
toDate
|
|
||||||
memoizedProps
|
|
||||||
props
|
|
||||||
children
|
|
||||||
channel
|
|
||||||
messages
|
|
||||||
msSaveBlob
|
|
||||||
messageReference
|
|
||||||
message_id
|
|
156
src/tracker/bootstrap.js
vendored
Normal file
156
src/tracker/bootstrap.js
vendored
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// noinspection JSAnnotator
|
||||||
|
|
||||||
|
const url = window.location.href;
|
||||||
|
|
||||||
|
if (!url.includes("discord.com/") && !url.includes("discordapp.com/") && !confirm("Could not detect Discord in the URL, do you want to run the script anyway?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.DHT_LOADED) {
|
||||||
|
alert("Discord History Tracker is already loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DHT_LOADED = true;
|
||||||
|
window.DHT_ON_UNLOAD = [];
|
||||||
|
|
||||||
|
/*[IMPORTS]*/
|
||||||
|
|
||||||
|
let delayedStopRequests = 0;
|
||||||
|
const stopTrackingDelayed = function(callback) {
|
||||||
|
delayedStopRequests++;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
STATE.setIsTracking(false);
|
||||||
|
delayedStopRequests--;
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}, 200); // give the user visual feedback after clicking the button before switching off
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasJustStarted = false;
|
||||||
|
let isSending = false;
|
||||||
|
|
||||||
|
const onError = function(e) {
|
||||||
|
console.log(e);
|
||||||
|
GUI.setStatus(e.status === "DISCONNECTED" ? "Disconnected" : "Error");
|
||||||
|
stopTrackingDelayed(() => isSending = false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNoAction = function(action) {
|
||||||
|
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrackingContinued = function(anyNewMessages) {
|
||||||
|
if (!STATE.isTracking()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GUI.setStatus("Tracking");
|
||||||
|
|
||||||
|
if (hasJustStarted) {
|
||||||
|
anyNewMessages = true;
|
||||||
|
hasJustStarted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending = false;
|
||||||
|
|
||||||
|
if (SETTINGS.autoscroll) {
|
||||||
|
let action = null;
|
||||||
|
|
||||||
|
if (!DISCORD.hasMoreMessages()) {
|
||||||
|
console.debug("[DHT] Reached first message.");
|
||||||
|
action = SETTINGS.afterFirstMsg;
|
||||||
|
}
|
||||||
|
if (isNoAction(action) && !anyNewMessages) {
|
||||||
|
console.debug("[DHT] No new messages.");
|
||||||
|
action = SETTINGS.afterSavedMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNoAction(action)) {
|
||||||
|
DISCORD.loadOlderMessages();
|
||||||
|
}
|
||||||
|
else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) {
|
||||||
|
GUI.setStatus("Reached End");
|
||||||
|
STATE.setIsTracking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let waitUntilSendingFinishedTimer = null;
|
||||||
|
|
||||||
|
const onMessagesUpdated = async messages => {
|
||||||
|
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSending) {
|
||||||
|
window.clearTimeout(waitUntilSendingFinishedTimer);
|
||||||
|
|
||||||
|
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||||
|
waitUntilSendingFinishedTimer = null;
|
||||||
|
onMessagesUpdated(messages);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = DISCORD.getSelectedChannel();
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
GUI.setStatus("Error (Unknown Channel)");
|
||||||
|
stopTrackingDelayed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
STATE.addDiscordChannel(info.server, info.channel);
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!messages.length) {
|
||||||
|
isSending = false;
|
||||||
|
onTrackingContinued(false);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const anyNewMessages = STATE.addDiscordMessages(messages);
|
||||||
|
onTrackingContinued(anyNewMessages);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
hasJustStarted = true;
|
||||||
|
// noinspection JSIgnoredPromiseFromCall
|
||||||
|
onMessagesUpdated(messages);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isSending = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
GUI.showController();
|
||||||
|
|
||||||
|
if (IS_FIRST_RUN) {
|
||||||
|
GUI.showSettings();
|
||||||
|
}
|
@@ -1,270 +0,0 @@
|
|||||||
var DISCORD = (function(){
|
|
||||||
var getMessageOuterElement = function(){
|
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
|
||||||
};
|
|
||||||
|
|
||||||
var getMessageScrollerElement = function(){
|
|
||||||
return getMessageOuterElement().querySelector("[class*='scroller-']");
|
|
||||||
};
|
|
||||||
|
|
||||||
var observerTimer = 0, waitingForCleanup = 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
/*
|
|
||||||
* Sets up a callback hook to trigger whenever the list of messages is updated. The callback is given a boolean value that is true if there are more messages to load.
|
|
||||||
*/
|
|
||||||
setupMessageUpdateCallback: function(callback){
|
|
||||||
var onTimerFinished = function(){
|
|
||||||
let view = getMessageOuterElement();
|
|
||||||
|
|
||||||
if (!view){
|
|
||||||
restartTimer(500);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
let anyMessage = getMessageOuterElement().querySelector("[class*='message-']");
|
|
||||||
let messages = anyMessage ? anyMessage.parentElement.children.length : 0;
|
|
||||||
|
|
||||||
if (messages < 100){
|
|
||||||
waitingForCleanup = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waitingForCleanup > 0){
|
|
||||||
--waitingForCleanup;
|
|
||||||
restartTimer(750);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
if (messages > 300){
|
|
||||||
waitingForCleanup = 6;
|
|
||||||
|
|
||||||
DOM.setTimer(() => {
|
|
||||||
let view = getMessageScrollerElement();
|
|
||||||
view.scrollTop = view.scrollHeight/2;
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
restartTimer(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var restartTimer = function(delay){
|
|
||||||
observerTimer = DOM.setTimer(onTimerFinished, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
onTimerFinished();
|
|
||||||
window.DHT_ON_UNLOAD.push(() => window.clearInterval(observerTimer));
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns internal React state object of an element.
|
|
||||||
*/
|
|
||||||
getReactProps: function(ele){
|
|
||||||
var keys = Object.keys(ele || {});
|
|
||||||
var key = keys.find(key => key.startsWith("__reactInternalInstance"));
|
|
||||||
|
|
||||||
if (key){
|
|
||||||
return ele[key].memoizedProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
|
||||||
return key ? ele[key] : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns an object containing the selected server name, selected channel name and ID, and the object type.
|
|
||||||
* For types DM and GROUP, the server and channel names are identical.
|
|
||||||
* For SERVER type, the channel has to be in view, otherwise Discord unloads it.
|
|
||||||
*/
|
|
||||||
getSelectedChannel: function(){
|
|
||||||
try{
|
|
||||||
var obj;
|
|
||||||
var channelListEle = DOM.queryReactClass("privateChannels");
|
|
||||||
|
|
||||||
if (channelListEle){
|
|
||||||
var channel = DOM.queryReactClass("selected", channelListEle);
|
|
||||||
|
|
||||||
if (!channel || !("href" in channel) || !channel.href.includes("/@me/")){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var linkSplit = channel.href.split("/");
|
|
||||||
var link = linkSplit[linkSplit.length-1];
|
|
||||||
|
|
||||||
if (!(/^\d+$/.test(link))){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name;
|
|
||||||
|
|
||||||
for(let ele of channel.querySelectorAll("[class^='name-'] *")){
|
|
||||||
let node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
|
||||||
|
|
||||||
if (node){
|
|
||||||
name = node.nodeValue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon = channel.querySelector("img[class*='avatar']");
|
|
||||||
var iconParent = icon && icon.closest("foreignObject");
|
|
||||||
var iconMask = iconParent && iconParent.getAttribute("mask");
|
|
||||||
|
|
||||||
obj = {
|
|
||||||
"server": name,
|
|
||||||
"channel": name,
|
|
||||||
"id": link,
|
|
||||||
"type": (iconMask && iconMask.includes("#svg-mask-avatar-default")) ? "GROUP" : "DM",
|
|
||||||
"extra": {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
channelListEle = document.getElementById("channels");
|
|
||||||
|
|
||||||
var channel = channelListEle.querySelector("[class*='modeSelected']").parentElement;
|
|
||||||
var props = DISCORD.getReactProps(channel).children.props;
|
|
||||||
|
|
||||||
if (!props){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelObj = props.channel || props.children().props.channel;
|
|
||||||
|
|
||||||
if (!channelObj){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
obj = {
|
|
||||||
"server": document.querySelector("nav header > h1").innerText,
|
|
||||||
"channel": channelObj.name,
|
|
||||||
"id": channelObj.id,
|
|
||||||
"type": "SERVER",
|
|
||||||
"extra": {
|
|
||||||
"position": channelObj.position,
|
|
||||||
"topic": channelObj.topic,
|
|
||||||
"nsfw": channelObj.nsfw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj.channel.length === 0 ? null : obj;
|
|
||||||
}catch(e){
|
|
||||||
console.error(e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns an array containing currently loaded messages.
|
|
||||||
*/
|
|
||||||
getMessages: function(){
|
|
||||||
try{
|
|
||||||
var scroller = getMessageScrollerElement();
|
|
||||||
var props = DISCORD.getReactProps(scroller);
|
|
||||||
var wrappers;
|
|
||||||
|
|
||||||
try{
|
|
||||||
wrappers = props.children.props.children.props.children.props.children.find(ele => Array.isArray(ele));
|
|
||||||
}catch(e){ // old version compatibility
|
|
||||||
wrappers = props.children.find(ele => Array.isArray(ele));
|
|
||||||
}
|
|
||||||
|
|
||||||
var messages = [];
|
|
||||||
|
|
||||||
for(let obj of wrappers){
|
|
||||||
let nested = obj.props;
|
|
||||||
|
|
||||||
if (nested && nested.message){
|
|
||||||
messages.push(nested.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}catch(e){
|
|
||||||
console.error(e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns true if the message view is visible.
|
|
||||||
*/
|
|
||||||
isInMessageView: () => !!getMessageOuterElement(),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns true if there are more messages available or if they're still loading.
|
|
||||||
*/
|
|
||||||
hasMoreMessages: function(){
|
|
||||||
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Forces the message view to load older messages by scrolling all the way up.
|
|
||||||
*/
|
|
||||||
loadOlderMessages: function(){
|
|
||||||
let view = getMessageScrollerElement();
|
|
||||||
|
|
||||||
if (view.scrollTop > 0){
|
|
||||||
view.scrollTop = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
|
||||||
*/
|
|
||||||
selectNextTextChannel: function(){
|
|
||||||
var dms = DOM.queryReactClass("privateChannels");
|
|
||||||
|
|
||||||
if (dms){
|
|
||||||
var currentChannel = DOM.queryReactClass("selected", dms);
|
|
||||||
var nextChannel = currentChannel && currentChannel.nextElementSibling;
|
|
||||||
|
|
||||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-") || !("href" in nextChannel) || !nextChannel.href.includes("/@me/")){
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
nextChannel.click();
|
|
||||||
nextChannel.scrollIntoView(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
var channelIconNormal = "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z";
|
|
||||||
var channelIconSpecial = "M14 8C14 7.44772 13.5523 7 13 7H9.76001L10.3657 3.58738C10.4201 3.28107 10.1845 3 9.87344 3H8.88907C8.64664 3 8.43914 3.17391 8.39677 3.41262L7.76001 7H4.18011C3.93722 7 3.72946 7.17456 3.68759 7.41381L3.51259 8.41381C3.45905 8.71977 3.69449 9 4.00511 9H7.41001L6.35001 15H2.77011C2.52722 15 2.31946 15.1746 2.27759 15.4138L2.10259 16.4138C2.04905 16.7198 2.28449 17 2.59511 17H6.00001L5.39427 20.4126C5.3399 20.7189 5.57547 21 5.88657 21H6.87094C7.11337 21 7.32088 20.8261 7.36325 20.5874L8.00001 17H14L13.3943 20.4126C13.3399 20.7189 13.5755 21 13.8866 21H14.8709C15.1134 21 15.3209 20.8261 15.3632 20.5874L16 17H19.5799C19.8228 17 20.0306 16.8254 20.0724 16.5862L20.2474 15.5862C20.301 15.2802 20.0655 15 19.7549 15H16.35L16.6758 13.1558C16.7823 12.5529 16.3186 12 15.7063 12C15.2286 12 14.8199 12.3429 14.7368 12.8133L14.3504 15H8.35045L9.41045 9H13C13.5523 9 14 8.55228 14 8Z";
|
|
||||||
|
|
||||||
var isValidChannelClass = cls => cls.includes("wrapper-") && !cls.includes("clickable-");
|
|
||||||
var isValidChannelType = ele => !!ele.querySelector('path[d="' + channelIconNormal + '"]') || !!ele.querySelector('path[d="' + channelIconSpecial + '"]');
|
|
||||||
var isValidChannel = ele => ele.childElementCount > 0 && isValidChannelClass(ele.children[0].className) && isValidChannelType(ele);
|
|
||||||
|
|
||||||
var channelListEle = document.querySelector("div[class*='sidebar'] > nav[class*='container'] > div[class*='scroller']");
|
|
||||||
|
|
||||||
if (!channelListEle){
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var allChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), isValidChannel);
|
|
||||||
var nextChannel = null;
|
|
||||||
|
|
||||||
for(var index = 0; index < allChannels.length-1; index++){
|
|
||||||
if (allChannels[index].children[0].className.includes("modeSelected")){
|
|
||||||
nextChannel = allChannels[index+1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextChannel === null){
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
nextChannel.children[0].click();
|
|
||||||
nextChannel.scrollIntoView(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
@@ -1,85 +0,0 @@
|
|||||||
var DOM = (function(){
|
|
||||||
var createElement = (tag, parent, id, html) => {
|
|
||||||
var ele = document.createElement(tag);
|
|
||||||
ele.id = id || "";
|
|
||||||
ele.innerHTML = html || "";
|
|
||||||
parent.appendChild(ele);
|
|
||||||
return ele;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
/*
|
|
||||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
|
||||||
*/
|
|
||||||
id: (id, parent) => (parent || document).getElementById(id),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
|
||||||
*/
|
|
||||||
queryReactClass: (cls, parent) => (parent || document).querySelector(`[class*="${cls}-"]`),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Creates an element, adds it to the DOM, and returns it.
|
|
||||||
*/
|
|
||||||
createElement: (tag, parent, id, html) => createElement(tag, parent, id, html),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Removes an element from the DOM.
|
|
||||||
*/
|
|
||||||
removeElement: (ele) => ele.parentNode.removeChild(ele),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Creates a new style element with the specified CSS and returns it.
|
|
||||||
*/
|
|
||||||
createStyle: (styles) => createElement("style", document.head, "", styles),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Convenience setTimeout function to save space after minification.
|
|
||||||
*/
|
|
||||||
setTimer: (callback, timeout) => window.setTimeout(callback, timeout),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Convenience addEventListener function to save space after minification.
|
|
||||||
*/
|
|
||||||
listen: (ele, event, callback) => ele.addEventListener(event, callback),
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Utility function to save an object into a cookie.
|
|
||||||
*/
|
|
||||||
saveToCookie: (name, obj, expiresInSeconds) => {
|
|
||||||
var expires = new Date(Date.now()+1000*expiresInSeconds).toUTCString();
|
|
||||||
document.cookie = name+"="+encodeURIComponent(JSON.stringify(obj))+";path=/;expires="+expires;
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Utility function to load an object from a cookie.
|
|
||||||
*/
|
|
||||||
loadFromCookie: (name) => {
|
|
||||||
var value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)"+name+"\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
|
||||||
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Triggers a UTF-8 text file download.
|
|
||||||
*/
|
|
||||||
downloadTextFile: (fileName, fileContents) => {
|
|
||||||
var blob = new Blob([fileContents], { "type": "octet/stream" });
|
|
||||||
|
|
||||||
if ("msSaveBlob" in window.navigator){
|
|
||||||
return window.navigator.msSaveBlob(blob, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
var ele = createElement("a", document.body);
|
|
||||||
ele.href = url;
|
|
||||||
ele.download = fileName;
|
|
||||||
ele.style.display = "none";
|
|
||||||
|
|
||||||
ele.click();
|
|
||||||
|
|
||||||
document.body.removeChild(ele);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
@@ -1,277 +0,0 @@
|
|||||||
var GUI = (function(){
|
|
||||||
var controller;
|
|
||||||
var settings;
|
|
||||||
|
|
||||||
var updateButtonState = () => {
|
|
||||||
if (STATE.isTracking()){
|
|
||||||
controller.ui.btnUpload.disabled = true;
|
|
||||||
controller.ui.btnSettings.disabled = true;
|
|
||||||
controller.ui.btnReset.disabled = true;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
controller.ui.btnUpload.disabled = false;
|
|
||||||
controller.ui.btnSettings.disabled = false;
|
|
||||||
controller.ui.btnDownload.disabled = controller.ui.btnReset.disabled = !STATE.hasSavedData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var stateChangedEvent = (type, detail) => {
|
|
||||||
if (controller){
|
|
||||||
var force = type === "gui" && detail === "controller";
|
|
||||||
|
|
||||||
if (type === "data" || force){
|
|
||||||
updateButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tracking" || force){
|
|
||||||
updateButtonState();
|
|
||||||
controller.ui.btnToggleTracking.innerHTML = STATE.isTracking() ? "Pause Tracking" : "Start Tracking";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "data" || force){
|
|
||||||
var messageCount = 0;
|
|
||||||
var channelCount = 0;
|
|
||||||
|
|
||||||
if (STATE.hasSavedData()){
|
|
||||||
messageCount = STATE.getSavefile().countMessages();
|
|
||||||
channelCount = STATE.getSavefile().countChannels();
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.ui.textStatus.innerHTML = [
|
|
||||||
messageCount, " message", (messageCount === 1 ? "" : "s"),
|
|
||||||
" from ",
|
|
||||||
channelCount, " channel", (channelCount === 1 ? "" : "s")
|
|
||||||
].join("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings){
|
|
||||||
var force = type === "gui" && detail === "settings";
|
|
||||||
|
|
||||||
if (force){
|
|
||||||
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
|
||||||
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
|
||||||
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "setting" || force){
|
|
||||||
var autoscrollRev = !SETTINGS.autoscroll;
|
|
||||||
|
|
||||||
// discord polyfills Object.values
|
|
||||||
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollRev);
|
|
||||||
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollRev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var registeredEvent = false;
|
|
||||||
|
|
||||||
var setupStateChanged = function(detail){
|
|
||||||
if (!registeredEvent){
|
|
||||||
STATE.onStateChanged(stateChangedEvent);
|
|
||||||
SETTINGS.onSettingsChanged(stateChangedEvent);
|
|
||||||
registeredEvent = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
stateChangedEvent("gui", detail);
|
|
||||||
};
|
|
||||||
|
|
||||||
var root = {
|
|
||||||
showController: function(){
|
|
||||||
controller = {};
|
|
||||||
|
|
||||||
// styles
|
|
||||||
|
|
||||||
controller.styles = DOM.createStyle(`
|
|
||||||
#app-mount > div[class*="app-"] { margin-bottom: 48px !important; }
|
|
||||||
#dht-ctrl { position: absolute; bottom: 0; width: 100%; height: 48px; background-color: #FFF; }
|
|
||||||
#dht-ctrl button { height: 32px; margin: 8px 0 8px 8px; font-size: 16px; padding: 0 12px; background-color: #7289DA; color: #FFF; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75); }
|
|
||||||
#dht-ctrl button:disabled { background-color: #7A7A7A; cursor: default; }
|
|
||||||
#dht-ctrl-close { margin: 8px 8px 8px 0 !important; float: right; }
|
|
||||||
#dht-ctrl p { display: inline-block; margin: 14px 12px; }
|
|
||||||
#dht-ctrl input { display: none; }`);
|
|
||||||
|
|
||||||
// main
|
|
||||||
|
|
||||||
var btn = (id, title) => "<button id='dht-ctrl-"+id+"'>"+title+"</button>";
|
|
||||||
|
|
||||||
controller.ele = DOM.createElement("div", document.body, "dht-ctrl", `
|
|
||||||
${btn("upload", "Upload & Combine")}
|
|
||||||
${btn("settings", "Settings")}
|
|
||||||
${btn("track", "")}
|
|
||||||
${btn("download", "Download")}
|
|
||||||
${btn("reset", "Reset")}
|
|
||||||
<p id='dht-ctrl-status'></p>
|
|
||||||
<input id='dht-ctrl-upload-input' type='file' multiple>
|
|
||||||
${btn("close", "X")}`);
|
|
||||||
|
|
||||||
// elements
|
|
||||||
|
|
||||||
controller.ui = {
|
|
||||||
btnUpload: DOM.id("dht-ctrl-upload"),
|
|
||||||
btnSettings: DOM.id("dht-ctrl-settings"),
|
|
||||||
btnToggleTracking: DOM.id("dht-ctrl-track"),
|
|
||||||
btnDownload: DOM.id("dht-ctrl-download"),
|
|
||||||
btnReset: DOM.id("dht-ctrl-reset"),
|
|
||||||
btnClose: DOM.id("dht-ctrl-close"),
|
|
||||||
textStatus: DOM.id("dht-ctrl-status"),
|
|
||||||
inputUpload: DOM.id("dht-ctrl-upload-input")
|
|
||||||
};
|
|
||||||
|
|
||||||
// events
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.btnUpload, "click", () => {
|
|
||||||
controller.ui.inputUpload.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.btnSettings, "click", () => {
|
|
||||||
root.showSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.btnToggleTracking, "click", () => {
|
|
||||||
STATE.setIsTracking(!STATE.isTracking());
|
|
||||||
});
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.btnDownload, "click", () => {
|
|
||||||
STATE.downloadSavefile();
|
|
||||||
});
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.btnReset, "click", () => {
|
|
||||||
STATE.resetState();
|
|
||||||
});
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.btnClose, "click", () => {
|
|
||||||
root.hideController();
|
|
||||||
window.DHT_ON_UNLOAD.forEach(f => f());
|
|
||||||
window.DHT_LOADED = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
DOM.listen(controller.ui.inputUpload, "change", () => {
|
|
||||||
Array.prototype.forEach.call(controller.ui.inputUpload.files, file => {
|
|
||||||
var reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = function(){
|
|
||||||
var obj = {};
|
|
||||||
|
|
||||||
try{
|
|
||||||
obj = JSON.parse(reader.result);
|
|
||||||
}catch(e){
|
|
||||||
alert("Could not parse '"+file.name+"', see console for details.");
|
|
||||||
console.error(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SAVEFILE.isValid(obj)){
|
|
||||||
STATE.uploadSavefile(file.name, new SAVEFILE(obj));
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
alert("File '"+file.name+"' has an invalid format.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsText(file, "UTF-8");
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.ui.inputUpload.value = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
setupStateChanged("controller");
|
|
||||||
},
|
|
||||||
|
|
||||||
hideController: function(){
|
|
||||||
if (controller){
|
|
||||||
DOM.removeElement(controller.ele);
|
|
||||||
DOM.removeElement(controller.styles);
|
|
||||||
controller = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showSettings: function(){
|
|
||||||
settings = {};
|
|
||||||
|
|
||||||
// styles
|
|
||||||
|
|
||||||
settings.styles = DOM.createStyle(`
|
|
||||||
#dht-cfg-overlay { position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-color: #000; opacity: 0.5; display: block; z-index: 1000; }
|
|
||||||
#dht-cfg { position: absolute; left: 50%; top: 50%; width: 800px; height: 262px; margin-left: -400px; margin-top: -131px; padding: 8px; background-color: #fff; z-index: 1001; }
|
|
||||||
#dht-cfg-note { margin-top: 22px; }
|
|
||||||
#dht-cfg sub { color: #666; font-size: 13px; }`);
|
|
||||||
|
|
||||||
// overlay
|
|
||||||
|
|
||||||
settings.overlay = DOM.createElement("div", document.body, "dht-cfg-overlay");
|
|
||||||
|
|
||||||
DOM.listen(settings.overlay, "click", () => {
|
|
||||||
root.hideSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
// main
|
|
||||||
|
|
||||||
var radio = (type, id, label) => "<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";
|
|
||||||
|
|
||||||
settings.ele = DOM.createElement("div", document.body, "dht-cfg", `
|
|
||||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
|
||||||
<br>
|
|
||||||
<label>After reaching the first message in channel...</label><br>
|
|
||||||
${radio("afm", "nothing", "Do Nothing")}
|
|
||||||
${radio("afm", "pause", "Pause Tracking")}
|
|
||||||
${radio("afm", "switch", "Switch to Next Channel")}
|
|
||||||
<br>
|
|
||||||
<label>After reaching a previously saved message...</label><br>
|
|
||||||
${radio("asm", "nothing", "Do Nothing")}
|
|
||||||
${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.<br><br>
|
|
||||||
<sub>{{{version:full}}}</sub>
|
|
||||||
</p>`);
|
|
||||||
|
|
||||||
// elements
|
|
||||||
|
|
||||||
settings.ui = {
|
|
||||||
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
|
||||||
optsAfterFirstMsg: {},
|
|
||||||
optsAfterSavedMsg: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
|
||||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-afm-pause");
|
|
||||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-afm-switch");
|
|
||||||
|
|
||||||
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-asm-nothing");
|
|
||||||
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-asm-pause");
|
|
||||||
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-asm-switch");
|
|
||||||
|
|
||||||
// events
|
|
||||||
|
|
||||||
settings.ui.cbAutoscroll.addEventListener("change", () => {
|
|
||||||
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
|
||||||
DOM.listen(settings.ui.optsAfterFirstMsg[key], "click", () => {
|
|
||||||
SETTINGS.afterFirstMsg = key;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(settings.ui.optsAfterSavedMsg).forEach(key => {
|
|
||||||
DOM.listen(settings.ui.optsAfterSavedMsg[key], "click", () => {
|
|
||||||
SETTINGS.afterSavedMsg = key;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setupStateChanged("settings");
|
|
||||||
},
|
|
||||||
|
|
||||||
hideSettings: function(){
|
|
||||||
if (settings){
|
|
||||||
DOM.removeElement(settings.overlay);
|
|
||||||
DOM.removeElement(settings.ele);
|
|
||||||
DOM.removeElement(settings.styles);
|
|
||||||
settings = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return root;
|
|
||||||
})();
|
|
@@ -1,349 +0,0 @@
|
|||||||
/*
|
|
||||||
* SAVEFILE STRUCTURE
|
|
||||||
* ==================
|
|
||||||
*
|
|
||||||
* {
|
|
||||||
* meta: {
|
|
||||||
* users: {
|
|
||||||
* <discord user id>: {
|
|
||||||
* name: <user name>,
|
|
||||||
* avatar: <user icon>,
|
|
||||||
* tag: <user discriminator> // only present if not a bot
|
|
||||||
* }, ...
|
|
||||||
* },
|
|
||||||
*
|
|
||||||
* // the user index is an array of discord user ids,
|
|
||||||
* // these indexes are used in the message objects to save space
|
|
||||||
* userindex: [
|
|
||||||
* <discord user id>, ...
|
|
||||||
* ],
|
|
||||||
*
|
|
||||||
* servers: [
|
|
||||||
* {
|
|
||||||
* name: <server name>,
|
|
||||||
* type: <"SERVER"|"GROUP"|DM">
|
|
||||||
* }, ...
|
|
||||||
* ],
|
|
||||||
*
|
|
||||||
* channels: {
|
|
||||||
* <discord channel id>: {
|
|
||||||
* server: <server index in the meta.servers array>,
|
|
||||||
* name: <channel name>,
|
|
||||||
* position: <order in channel list>, // only present if server type == SERVER
|
|
||||||
* topic: <channel topic>, // only present if server type == SERVER
|
|
||||||
* nsfw: <channel NSFW status> // only present if server type == SERVER
|
|
||||||
* }, ...
|
|
||||||
* }
|
|
||||||
* },
|
|
||||||
*
|
|
||||||
* data: {
|
|
||||||
* <discord channel id>: {
|
|
||||||
* <discord message id>: {
|
|
||||||
* u: <user index of the sender>,
|
|
||||||
* t: <message timestamp>,
|
|
||||||
* m: <message content>, // only present if not empty
|
|
||||||
* f: <message flags>, // only present if edited in which case it equals 1, deprecated (use 'te' instead)
|
|
||||||
* te: <edit timestamp>, // only present if edited
|
|
||||||
* e: [ // omit for no embeds
|
|
||||||
* {
|
|
||||||
* url: <embed url>,
|
|
||||||
* type: <embed type>,
|
|
||||||
* t: <rich embed title>, // only present if type == rich, and if not empty
|
|
||||||
* d: <rich embed description> // only present if type == rich, and if the embed has a simple description text
|
|
||||||
* }, ...
|
|
||||||
* ],
|
|
||||||
* a: [ // omit for no attachments
|
|
||||||
* {
|
|
||||||
* url: <attachment url>
|
|
||||||
* }, ...
|
|
||||||
* ],
|
|
||||||
* r: <reply message id>, // only present if referencing another message (reply)
|
|
||||||
* re: [ // omit for no reactions
|
|
||||||
* {
|
|
||||||
* c: <react count>
|
|
||||||
* n: <emoji name>,
|
|
||||||
* id: <emoji id>, // only present for custom emoji
|
|
||||||
* an: <emoji is animated>, // only present for custom animated emoji
|
|
||||||
* }, ...
|
|
||||||
* ]
|
|
||||||
* }, ...
|
|
||||||
* }, ...
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* TEMPORARY OBJECT STRUCTURE
|
|
||||||
* ==========================
|
|
||||||
*
|
|
||||||
* {
|
|
||||||
* userlookup: {
|
|
||||||
* <discord user id>: <user index in the meta.userindex array>
|
|
||||||
* },
|
|
||||||
* channelkeys: Set<channel id>,
|
|
||||||
* messagekeys: Set<message id>,
|
|
||||||
* freshmsgs: Set<message id> // only messages which were newly added to the savefile in the current session
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
|
|
||||||
class SAVEFILE{
|
|
||||||
constructor(parsedObj){
|
|
||||||
var me = this;
|
|
||||||
|
|
||||||
if (!SAVEFILE.isValid(parsedObj)){
|
|
||||||
parsedObj = {
|
|
||||||
meta: {},
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
me.meta = parsedObj.meta;
|
|
||||||
me.data = parsedObj.data;
|
|
||||||
|
|
||||||
me.meta.users = me.meta.users || {};
|
|
||||||
me.meta.userindex = me.meta.userindex || [];
|
|
||||||
me.meta.servers = me.meta.servers || [];
|
|
||||||
me.meta.channels = me.meta.channels || {};
|
|
||||||
|
|
||||||
me.tmp = {
|
|
||||||
userlookup: {},
|
|
||||||
channelkeys: new Set(),
|
|
||||||
messagekeys: new Set(),
|
|
||||||
freshmsgs: new Set()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static isValid(parsedObj){
|
|
||||||
return parsedObj && typeof parsedObj.meta === "object" && typeof parsedObj.data === "object";
|
|
||||||
}
|
|
||||||
|
|
||||||
findOrRegisterUser(userId, userName, userDiscriminator, userAvatar){
|
|
||||||
var wasPresent = userId in this.meta.users;
|
|
||||||
var userObj = wasPresent ? this.meta.users[userId] : {};
|
|
||||||
|
|
||||||
userObj.name = userName;
|
|
||||||
|
|
||||||
if (userDiscriminator){
|
|
||||||
userObj.tag = userDiscriminator;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userAvatar){
|
|
||||||
userObj.avatar = userAvatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wasPresent){
|
|
||||||
this.meta.users[userId] = userObj;
|
|
||||||
this.meta.userindex.push(userId);
|
|
||||||
return this.tmp.userlookup[userId] = this.meta.userindex.length-1;
|
|
||||||
}
|
|
||||||
else if (!(userId in this.tmp.userlookup)){
|
|
||||||
return this.tmp.userlookup[userId] = this.meta.userindex.findIndex(id => id == userId);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return this.tmp.userlookup[userId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
findOrRegisterServer(serverName, serverType){
|
|
||||||
var index = this.meta.servers.findIndex(server => server.name === serverName && server.type === serverType);
|
|
||||||
|
|
||||||
if (index === -1){
|
|
||||||
this.meta.servers.push({
|
|
||||||
"name": serverName,
|
|
||||||
"type": serverType
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.meta.servers.length-1;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tryRegisterChannel(serverIndex, channelId, channelName, extraInfo){
|
|
||||||
if (!this.meta.servers[serverIndex]){
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
var wasPresent = channelId in this.meta.channels;
|
|
||||||
var channelObj = wasPresent ? this.meta.channels[channelId] : { "server": serverIndex };
|
|
||||||
|
|
||||||
channelObj.name = channelName;
|
|
||||||
|
|
||||||
if (extraInfo.position){
|
|
||||||
channelObj.position = extraInfo.position;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extraInfo.topic){
|
|
||||||
channelObj.topic = extraInfo.topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extraInfo.nsfw){
|
|
||||||
channelObj.nsfw = extraInfo.nsfw;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wasPresent){
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
this.meta.channels[channelId] = channelObj;
|
|
||||||
this.tmp.channelkeys.add(channelId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(channelId, messageId, messageObject){
|
|
||||||
var container = this.data[channelId] || (this.data[channelId] = {});
|
|
||||||
var wasPresent = messageId in container;
|
|
||||||
|
|
||||||
container[messageId] = messageObject;
|
|
||||||
this.tmp.messagekeys.add(messageId);
|
|
||||||
return !wasPresent;
|
|
||||||
}
|
|
||||||
|
|
||||||
convertToMessageObject(discordMessage){
|
|
||||||
var author = discordMessage.author;
|
|
||||||
|
|
||||||
var obj = {
|
|
||||||
u: this.findOrRegisterUser(author.id, author.username, author.bot ? null : author.discriminator, author.avatar),
|
|
||||||
t: discordMessage.timestamp.toDate().getTime()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (discordMessage.content.length > 0){
|
|
||||||
obj.m = discordMessage.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discordMessage.editedTimestamp !== null){
|
|
||||||
obj.te = discordMessage.editedTimestamp.toDate().getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discordMessage.embeds.length > 0){
|
|
||||||
obj.e = discordMessage.embeds.map(embed => {
|
|
||||||
let conv = {
|
|
||||||
url: embed.url,
|
|
||||||
type: embed.type
|
|
||||||
};
|
|
||||||
|
|
||||||
if (embed.type === "rich"){
|
|
||||||
if (Array.isArray(embed.title) && embed.title.length === 1 && typeof embed.title[0] === "string"){
|
|
||||||
conv.t = embed.title[0];
|
|
||||||
|
|
||||||
if (Array.isArray(embed.description) && embed.description.length === 1 && typeof embed.description[0] === "string"){
|
|
||||||
conv.d = embed.description[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conv;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discordMessage.attachments.length > 0){
|
|
||||||
obj.a = discordMessage.attachments.map(attachment => ({
|
|
||||||
url: attachment.url
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discordMessage.messageReference !== null){
|
|
||||||
obj.r = discordMessage.messageReference.message_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discordMessage.reactions.length > 0) {
|
|
||||||
obj.re = discordMessage.reactions.map(reaction => {
|
|
||||||
let conv = {
|
|
||||||
c: reaction.count,
|
|
||||||
n: reaction.emoji.name
|
|
||||||
};
|
|
||||||
|
|
||||||
if (reaction.emoji.id !== null) {
|
|
||||||
conv.id = reaction.emoji.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reaction.emoji.animated) {
|
|
||||||
conv.an = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return conv;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
isMessageFresh(id){
|
|
||||||
return this.tmp.freshmsgs.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessagesFromDiscord(channelId, discordMessageArray){
|
|
||||||
var hasNewMessages = false;
|
|
||||||
|
|
||||||
for(var discordMessage of discordMessageArray){
|
|
||||||
var type = discordMessage.type;
|
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
|
||||||
if ((type === 0 || type === 19) && discordMessage.state === "SENT" && this.addMessage(channelId, discordMessage.id, this.convertToMessageObject(discordMessage))){
|
|
||||||
this.tmp.freshmsgs.add(discordMessage.id);
|
|
||||||
hasNewMessages = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasNewMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
countChannels(){
|
|
||||||
return this.tmp.channelkeys.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
countMessages(){
|
|
||||||
return this.tmp.messagekeys.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
combineWith(obj){
|
|
||||||
var userMap = {};
|
|
||||||
var shownError = false;
|
|
||||||
|
|
||||||
for(var userId in obj.meta.users){
|
|
||||||
var oldUser = obj.meta.users[userId];
|
|
||||||
userMap[obj.meta.userindex.findIndex(id => id == userId)] = this.findOrRegisterUser(userId, oldUser.name, oldUser.tag, oldUser.avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(var channelId in obj.meta.channels){
|
|
||||||
var oldServer = obj.meta.servers[obj.meta.channels[channelId].server];
|
|
||||||
var oldChannel = obj.meta.channels[channelId];
|
|
||||||
this.tryRegisterChannel(this.findOrRegisterServer(oldServer.name, oldServer.type), channelId, oldChannel.name, oldChannel /* filtered later */);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(var channelId in obj.data){
|
|
||||||
var oldChannel = obj.data[channelId];
|
|
||||||
|
|
||||||
for(var messageId in oldChannel){
|
|
||||||
var oldMessage = oldChannel[messageId];
|
|
||||||
var oldUser = oldMessage.u;
|
|
||||||
|
|
||||||
if (oldUser in userMap){
|
|
||||||
oldMessage.u = userMap[oldUser];
|
|
||||||
this.addMessage(channelId, messageId, oldMessage);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
if (!shownError){
|
|
||||||
shownError = true;
|
|
||||||
alert("The uploaded archive appears to be corrupted, some messages will be skipped. See console for details.");
|
|
||||||
|
|
||||||
console.error("User list:", obj.meta.users);
|
|
||||||
console.error("User index:", obj.meta.userindex);
|
|
||||||
console.error("Generated mapping:", userMap);
|
|
||||||
console.error("Missing user for the following messages:");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(oldMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toJson(){
|
|
||||||
return JSON.stringify({
|
|
||||||
"meta": this.meta,
|
|
||||||
"data": this.data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
328
src/tracker/scripts/discord.js
Normal file
328
src/tracker/scripts/discord.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
// noinspection JSUnresolvedVariable
|
||||||
|
// noinspection LocalVariableNamingConventionJS
|
||||||
|
class DISCORD {
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
static CHANNEL_TYPE = {
|
||||||
|
DM: 1,
|
||||||
|
GROUP_DM: 3,
|
||||||
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
|
PUBLIC_THREAD: 11,
|
||||||
|
PRIVATE_THREAD: 12
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
static MESSAGE_TYPE = {
|
||||||
|
DEFAULT: 0,
|
||||||
|
REPLY: 19,
|
||||||
|
THREAD_STARTER: 21
|
||||||
|
};
|
||||||
|
|
||||||
|
static getMessageOuterElement() {
|
||||||
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMessageScrollerElement() {
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (view.scrollTop > 0) {
|
||||||
|
view.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!hasChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMessages.clear();
|
||||||
|
for (const message of messages) {
|
||||||
|
previousMessages.add(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
let debounceTimer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do not trigger the callback too often due to autoscrolling.
|
||||||
|
*/
|
||||||
|
const onMessageElementsChangedLater = function() {
|
||||||
|
window.clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver(function () {
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
let skipsLeft = 0;
|
||||||
|
let observedElement = null;
|
||||||
|
|
||||||
|
const observerTimer = window.setInterval(() => {
|
||||||
|
if (skipsLeft > 0) {
|
||||||
|
--skipsLeft;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = this.getMessageOuterElement();
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
skipsLeft = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observedElement !== null && observedElement.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observedElement = view.querySelector("[data-list-id='chat-messages']");
|
||||||
|
|
||||||
|
if (observedElement) {
|
||||||
|
console.debug("[DHT] Observed message container.");
|
||||||
|
observer.observe(observedElement, { childList: true });
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
window.DHT_ON_UNLOAD.push(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||||
|
*/
|
||||||
|
static selectNextTextChannel() {
|
||||||
|
const dms = DOM.queryReactClass("privateChannels");
|
||||||
|
|
||||||
|
if (dms) {
|
||||||
|
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||||
|
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
||||||
|
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||||
|
|
||||||
|
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
||||||
|
if (!nextChannelLink) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextChannelLink.click();
|
||||||
|
nextChannelLink.scrollIntoView(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const channelListEle = document.getElementById("channels");
|
||||||
|
if (!channelListEle) {
|
||||||
|
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);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
85
src/tracker/scripts/dom.js
Normal file
85
src/tracker/scripts/dom.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
class DOM {
|
||||||
|
/**
|
||||||
|
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
static id(id, parent) {
|
||||||
|
return (parent || document).getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||||
|
*/
|
||||||
|
static queryReactClass(cls, parent) {
|
||||||
|
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an element, adds it to the DOM, and returns it.
|
||||||
|
*/
|
||||||
|
static createElement(tag, parent, id, html) {
|
||||||
|
/** @type HTMLElement */
|
||||||
|
const ele = document.createElement(tag);
|
||||||
|
ele.id = id || "";
|
||||||
|
ele.innerHTML = html || "";
|
||||||
|
parent.appendChild(ele);
|
||||||
|
return ele;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an element from the DOM.
|
||||||
|
*/
|
||||||
|
static removeElement(ele) {
|
||||||
|
return ele.parentNode.removeChild(ele);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new style element with the specified CSS and returns it.
|
||||||
|
*/
|
||||||
|
static createStyle(styles) {
|
||||||
|
return this.createElement("style", document.head, "", styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to save an object into a cookie.
|
||||||
|
*/
|
||||||
|
static saveToCookie(name, obj, expiresInSeconds) {
|
||||||
|
const expires = new Date(Date.now() + 1000 * expiresInSeconds).toUTCString();
|
||||||
|
document.cookie = name + "=" + encodeURIComponent(JSON.stringify(obj)) + ";path=/;expires=" + expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to load an object from a cookie.
|
||||||
|
*/
|
||||||
|
static loadFromCookie(name) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
270
src/tracker/scripts/gui.js
Normal file
270
src/tracker/scripts/gui.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
var GUI = (function(){
|
||||||
|
var controller;
|
||||||
|
var settings;
|
||||||
|
|
||||||
|
var updateButtonState = () => {
|
||||||
|
if (STATE.isTracking()){
|
||||||
|
controller.ui.btnUpload.disabled = true;
|
||||||
|
controller.ui.btnSettings.disabled = true;
|
||||||
|
controller.ui.btnReset.disabled = true;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
controller.ui.btnUpload.disabled = false;
|
||||||
|
controller.ui.btnSettings.disabled = false;
|
||||||
|
controller.ui.btnDownload.disabled = controller.ui.btnReset.disabled = !STATE.hasSavedData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var stateChangedEvent = (type, detail) => {
|
||||||
|
if (controller){
|
||||||
|
var force = type === "gui" && detail === "controller";
|
||||||
|
|
||||||
|
if (type === "data" || force){
|
||||||
|
updateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tracking" || force){
|
||||||
|
updateButtonState();
|
||||||
|
controller.ui.btnToggleTracking.innerHTML = STATE.isTracking() ? "Pause Tracking" : "Start Tracking";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "data" || force){
|
||||||
|
var messageCount = 0;
|
||||||
|
var channelCount = 0;
|
||||||
|
|
||||||
|
if (STATE.hasSavedData()){
|
||||||
|
messageCount = STATE.getSavefile().countMessages();
|
||||||
|
channelCount = STATE.getSavefile().countChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.ui.textStatus.innerHTML = [
|
||||||
|
messageCount, " message", (messageCount === 1 ? "" : "s"),
|
||||||
|
" from ",
|
||||||
|
channelCount, " channel", (channelCount === 1 ? "" : "s")
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings){
|
||||||
|
var force = type === "gui" && detail === "settings";
|
||||||
|
|
||||||
|
if (force){
|
||||||
|
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
||||||
|
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
||||||
|
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "setting" || force){
|
||||||
|
var autoscrollRev = !SETTINGS.autoscroll;
|
||||||
|
|
||||||
|
// discord polyfills Object.values
|
||||||
|
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollRev);
|
||||||
|
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollRev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var registeredEvent = false;
|
||||||
|
|
||||||
|
var setupStateChanged = function(detail){
|
||||||
|
if (!registeredEvent){
|
||||||
|
STATE.onStateChanged(stateChangedEvent);
|
||||||
|
SETTINGS.onSettingsChanged((property) =>
|
||||||
|
stateChangedEvent("setting", property)
|
||||||
|
);
|
||||||
|
registeredEvent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stateChangedEvent("gui", detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = {
|
||||||
|
showController: function(){
|
||||||
|
controller = {};
|
||||||
|
|
||||||
|
// styles
|
||||||
|
|
||||||
|
controller.styles = DOM.createStyle(`/*[CSS-CONTROLLER]*/`);
|
||||||
|
|
||||||
|
// main
|
||||||
|
|
||||||
|
var btn = (id, title) => "<button id='dht-ctrl-"+id+"'>"+title+"</button>";
|
||||||
|
|
||||||
|
controller.ele = DOM.createElement("div", document.body, "dht-ctrl", `
|
||||||
|
${btn("upload", "Upload & Combine")}
|
||||||
|
${btn("settings", "Settings")}
|
||||||
|
${btn("track", "")}
|
||||||
|
${btn("download", "Download")}
|
||||||
|
${btn("reset", "Reset")}
|
||||||
|
<p id='dht-ctrl-status'></p>
|
||||||
|
<input id='dht-ctrl-upload-input' type='file' multiple style="display: none">
|
||||||
|
${btn("close", "X")}`);
|
||||||
|
|
||||||
|
// elements
|
||||||
|
|
||||||
|
controller.ui = {
|
||||||
|
btnUpload: DOM.id("dht-ctrl-upload"),
|
||||||
|
btnSettings: DOM.id("dht-ctrl-settings"),
|
||||||
|
btnToggleTracking: DOM.id("dht-ctrl-track"),
|
||||||
|
btnDownload: DOM.id("dht-ctrl-download"),
|
||||||
|
btnReset: DOM.id("dht-ctrl-reset"),
|
||||||
|
btnClose: DOM.id("dht-ctrl-close"),
|
||||||
|
textStatus: DOM.id("dht-ctrl-status"),
|
||||||
|
inputUpload: DOM.id("dht-ctrl-upload-input")
|
||||||
|
};
|
||||||
|
|
||||||
|
// events
|
||||||
|
|
||||||
|
controller.ui.btnUpload.addEventListener("click", () => {
|
||||||
|
controller.ui.inputUpload.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.btnSettings.addEventListener("click", () => {
|
||||||
|
root.showSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.btnToggleTracking.addEventListener("click", () => {
|
||||||
|
STATE.setIsTracking(!STATE.isTracking());
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.btnDownload.addEventListener("click", () => {
|
||||||
|
STATE.downloadSavefile();
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.btnReset.addEventListener("click", () => {
|
||||||
|
STATE.resetState();
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.btnClose.addEventListener("click", () => {
|
||||||
|
root.hideController();
|
||||||
|
window.DHT_ON_UNLOAD.forEach(f => f());
|
||||||
|
window.DHT_LOADED = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.inputUpload.addEventListener("change", () => {
|
||||||
|
Array.prototype.forEach.call(controller.ui.inputUpload.files, file => {
|
||||||
|
var reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = function(){
|
||||||
|
var obj = {};
|
||||||
|
|
||||||
|
try{
|
||||||
|
obj = JSON.parse(reader.result);
|
||||||
|
}catch(e){
|
||||||
|
alert("Could not parse '"+file.name+"', see console for details.");
|
||||||
|
console.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SAVEFILE.isValid(obj)){
|
||||||
|
STATE.uploadSavefile(file.name, new SAVEFILE(obj));
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
alert("File '"+file.name+"' has an invalid format.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, "UTF-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.ui.inputUpload.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
setupStateChanged("controller");
|
||||||
|
},
|
||||||
|
|
||||||
|
hideController: function(){
|
||||||
|
if (controller){
|
||||||
|
DOM.removeElement(controller.ele);
|
||||||
|
DOM.removeElement(controller.styles);
|
||||||
|
controller = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showSettings: function(){
|
||||||
|
settings = {};
|
||||||
|
|
||||||
|
// styles
|
||||||
|
|
||||||
|
settings.styles = DOM.createStyle(`/*[CSS-SETTINGS]*/`);
|
||||||
|
|
||||||
|
// overlay
|
||||||
|
|
||||||
|
settings.overlay = DOM.createElement("div", document.body, "dht-cfg-overlay");
|
||||||
|
|
||||||
|
settings.overlay.addEventListener("click", () => {
|
||||||
|
root.hideSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// main
|
||||||
|
|
||||||
|
var radio = (type, id, label) => "<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";
|
||||||
|
|
||||||
|
settings.ele = DOM.createElement("div", document.body, "dht-cfg", `
|
||||||
|
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||||
|
<br>
|
||||||
|
<label>After reaching the first message in channel...</label><br>
|
||||||
|
${radio("afm", "nothing", "Continue Tracking")}
|
||||||
|
${radio("afm", "pause", "Pause Tracking")}
|
||||||
|
${radio("afm", "switch", "Switch to Next Channel")}
|
||||||
|
<br>
|
||||||
|
<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.<br><br>
|
||||||
|
<sub>{{{version:full}}}</sub>
|
||||||
|
</p>`);
|
||||||
|
|
||||||
|
// elements
|
||||||
|
|
||||||
|
settings.ui = {
|
||||||
|
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||||
|
optsAfterFirstMsg: {},
|
||||||
|
optsAfterSavedMsg: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
||||||
|
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-afm-pause");
|
||||||
|
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-afm-switch");
|
||||||
|
|
||||||
|
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-asm-nothing");
|
||||||
|
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-asm-pause");
|
||||||
|
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-asm-switch");
|
||||||
|
|
||||||
|
// events
|
||||||
|
|
||||||
|
settings.ui.cbAutoscroll.addEventListener("change", () => {
|
||||||
|
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
||||||
|
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
||||||
|
SETTINGS.afterFirstMsg = key;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(settings.ui.optsAfterSavedMsg).forEach(key => {
|
||||||
|
settings.ui.optsAfterSavedMsg[key].addEventListener("click", () => {
|
||||||
|
SETTINGS.afterSavedMsg = key;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setupStateChanged("settings");
|
||||||
|
},
|
||||||
|
|
||||||
|
hideSettings: function(){
|
||||||
|
if (settings){
|
||||||
|
DOM.removeElement(settings.overlay);
|
||||||
|
DOM.removeElement(settings.ele);
|
||||||
|
DOM.removeElement(settings.styles);
|
||||||
|
settings = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setStatus: function(status) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return root;
|
||||||
|
})();
|
349
src/tracker/scripts/savefile.js
Normal file
349
src/tracker/scripts/savefile.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* SAVEFILE STRUCTURE
|
||||||
|
* ==================
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* meta: {
|
||||||
|
* users: {
|
||||||
|
* <discord user id>: {
|
||||||
|
* name: <user name>,
|
||||||
|
* avatar: <user icon>,
|
||||||
|
* tag: <user discriminator> // only present if not a bot
|
||||||
|
* }, ...
|
||||||
|
* },
|
||||||
|
*
|
||||||
|
* // the user index is an array of discord user ids,
|
||||||
|
* // these indexes are used in the message objects to save space
|
||||||
|
* userindex: [
|
||||||
|
* <discord user id>, ...
|
||||||
|
* ],
|
||||||
|
*
|
||||||
|
* servers: [
|
||||||
|
* {
|
||||||
|
* name: <server name>,
|
||||||
|
* type: <"SERVER"|"GROUP"|DM">
|
||||||
|
* }, ...
|
||||||
|
* ],
|
||||||
|
*
|
||||||
|
* channels: {
|
||||||
|
* <discord channel id>: {
|
||||||
|
* server: <server index in the meta.servers array>,
|
||||||
|
* name: <channel name>,
|
||||||
|
* position: <order in channel list>, // only present if server type == SERVER
|
||||||
|
* topic: <channel topic>, // only present if server type == SERVER
|
||||||
|
* nsfw: <channel NSFW status> // only present if server type == SERVER
|
||||||
|
* }, ...
|
||||||
|
* }
|
||||||
|
* },
|
||||||
|
*
|
||||||
|
* data: {
|
||||||
|
* <discord channel id>: {
|
||||||
|
* <discord message id>: {
|
||||||
|
* u: <user index of the sender>,
|
||||||
|
* t: <message timestamp>,
|
||||||
|
* m: <message content>, // only present if not empty
|
||||||
|
* f: <message flags>, // only present if edited in which case it equals 1, deprecated (use 'te' instead)
|
||||||
|
* te: <edit timestamp>, // only present if edited
|
||||||
|
* e: [ // omit for no embeds
|
||||||
|
* {
|
||||||
|
* url: <embed url>,
|
||||||
|
* type: <embed type>,
|
||||||
|
* t: <rich embed title>, // only present if type == rich, and if not empty
|
||||||
|
* d: <rich embed description> // only present if type == rich, and if the embed has a simple description text
|
||||||
|
* }, ...
|
||||||
|
* ],
|
||||||
|
* a: [ // omit for no attachments
|
||||||
|
* {
|
||||||
|
* url: <attachment url>
|
||||||
|
* }, ...
|
||||||
|
* ],
|
||||||
|
* r: <reply message id>, // only present if referencing another message (reply)
|
||||||
|
* re: [ // omit for no reactions
|
||||||
|
* {
|
||||||
|
* c: <react count>
|
||||||
|
* n: <emoji name>,
|
||||||
|
* id: <emoji id>, // only present for custom emoji
|
||||||
|
* an: <emoji is animated>, // only present for custom animated emoji
|
||||||
|
* }, ...
|
||||||
|
* ]
|
||||||
|
* }, ...
|
||||||
|
* }, ...
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* TEMPORARY OBJECT STRUCTURE
|
||||||
|
* ==========================
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* userlookup: {
|
||||||
|
* <discord user id>: <user index in the meta.userindex array>
|
||||||
|
* },
|
||||||
|
* channelkeys: Set<channel id>,
|
||||||
|
* messagekeys: Set<message id>,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SAVEFILE{
|
||||||
|
constructor(parsedObj){
|
||||||
|
var me = this;
|
||||||
|
|
||||||
|
if (!SAVEFILE.isValid(parsedObj)){
|
||||||
|
parsedObj = {
|
||||||
|
meta: {},
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
me.meta = parsedObj.meta;
|
||||||
|
me.data = parsedObj.data;
|
||||||
|
|
||||||
|
me.meta.users = me.meta.users || {};
|
||||||
|
me.meta.userindex = me.meta.userindex || [];
|
||||||
|
me.meta.servers = me.meta.servers || [];
|
||||||
|
me.meta.channels = me.meta.channels || {};
|
||||||
|
|
||||||
|
me.tmp = {
|
||||||
|
userlookup: {},
|
||||||
|
channelkeys: new Set(),
|
||||||
|
messagekeys: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static isValid(parsedObj){
|
||||||
|
return parsedObj && typeof parsedObj.meta === "object" && typeof parsedObj.data === "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDate(date){
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// noinspection JSUnresolvedReference
|
||||||
|
return date.toDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
findOrRegisterUser(userId, userName, userDiscriminator, userAvatar){
|
||||||
|
var wasPresent = userId in this.meta.users;
|
||||||
|
var userObj = wasPresent ? this.meta.users[userId] : {};
|
||||||
|
|
||||||
|
userObj.name = userName;
|
||||||
|
|
||||||
|
if (userDiscriminator){
|
||||||
|
userObj.tag = userDiscriminator;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userAvatar){
|
||||||
|
userObj.avatar = userAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasPresent){
|
||||||
|
this.meta.users[userId] = userObj;
|
||||||
|
this.meta.userindex.push(userId);
|
||||||
|
return this.tmp.userlookup[userId] = this.meta.userindex.length-1;
|
||||||
|
}
|
||||||
|
else if (!(userId in this.tmp.userlookup)){
|
||||||
|
return this.tmp.userlookup[userId] = this.meta.userindex.findIndex(id => id == userId);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return this.tmp.userlookup[userId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findOrRegisterServer(serverName, serverType){
|
||||||
|
var index = this.meta.servers.findIndex(server => server.name === serverName && server.type === serverType);
|
||||||
|
|
||||||
|
if (index === -1){
|
||||||
|
this.meta.servers.push({
|
||||||
|
"name": serverName,
|
||||||
|
"type": serverType
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.meta.servers.length-1;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryRegisterChannel(serverIndex, channelId, channelName, extraInfo){
|
||||||
|
if (!this.meta.servers[serverIndex]){
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasPresent = channelId in this.meta.channels;
|
||||||
|
var channelObj = wasPresent ? this.meta.channels[channelId] : { "server": serverIndex };
|
||||||
|
|
||||||
|
channelObj.name = channelName;
|
||||||
|
|
||||||
|
if (extraInfo.position) {
|
||||||
|
channelObj.position = extraInfo.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo.topic) {
|
||||||
|
channelObj.topic = extraInfo.topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraInfo.nsfw) {
|
||||||
|
channelObj.nsfw = extraInfo.nsfw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasPresent){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
this.meta.channels[channelId] = channelObj;
|
||||||
|
this.tmp.channelkeys.add(channelId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(channelId, messageId, messageObject){
|
||||||
|
var container = this.data[channelId] || (this.data[channelId] = {});
|
||||||
|
var wasPresent = messageId in container;
|
||||||
|
|
||||||
|
container[messageId] = messageObject;
|
||||||
|
this.tmp.messagekeys.add(messageId);
|
||||||
|
return !wasPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToMessageObject(discordMessage){
|
||||||
|
var author = discordMessage.author;
|
||||||
|
|
||||||
|
var obj = {
|
||||||
|
u: this.findOrRegisterUser(author.id, author.username, author.bot ? null : author.discriminator, author.avatar),
|
||||||
|
t: SAVEFILE.getDate(discordMessage.timestamp).getTime()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (discordMessage.content.length > 0){
|
||||||
|
obj.m = discordMessage.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordMessage.editedTimestamp !== null){
|
||||||
|
obj.te = SAVEFILE.getDate(discordMessage.editedTimestamp).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordMessage.embeds.length > 0){
|
||||||
|
obj.e = discordMessage.embeds.map(embed => {
|
||||||
|
let conv = {
|
||||||
|
url: embed.url,
|
||||||
|
type: embed.type
|
||||||
|
};
|
||||||
|
|
||||||
|
if (embed.type === "rich"){
|
||||||
|
if (Array.isArray(embed.title) && embed.title.length === 1 && typeof embed.title[0] === "string"){
|
||||||
|
conv.t = embed.title[0];
|
||||||
|
|
||||||
|
if (Array.isArray(embed.description) && embed.description.length === 1 && typeof embed.description[0] === "string"){
|
||||||
|
conv.d = embed.description[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conv;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordMessage.attachments.length > 0){
|
||||||
|
obj.a = discordMessage.attachments.map(attachment => ({
|
||||||
|
url: attachment.url
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordMessage.messageReference !== null){
|
||||||
|
obj.r = discordMessage.messageReference.message_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discordMessage.reactions.length > 0) {
|
||||||
|
obj.re = discordMessage.reactions.map(reaction => {
|
||||||
|
let conv = {
|
||||||
|
c: reaction.count,
|
||||||
|
n: reaction.emoji.name
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reaction.emoji.id !== null) {
|
||||||
|
conv.id = reaction.emoji.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reaction.emoji.animated) {
|
||||||
|
conv.an = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conv;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessagesFromDiscord(discordMessageArray){
|
||||||
|
var hasNewMessages = false;
|
||||||
|
|
||||||
|
for(var discordMessage of discordMessageArray){
|
||||||
|
if (this.addMessage(discordMessage.channel_id, discordMessage.id, this.convertToMessageObject(discordMessage))){
|
||||||
|
hasNewMessages = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasNewMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
countChannels(){
|
||||||
|
return this.tmp.channelkeys.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
countMessages(){
|
||||||
|
return this.tmp.messagekeys.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
combineWith(obj){
|
||||||
|
var userMap = {};
|
||||||
|
var shownError = false;
|
||||||
|
|
||||||
|
for(var userId in obj.meta.users){
|
||||||
|
var oldUser = obj.meta.users[userId];
|
||||||
|
userMap[obj.meta.userindex.findIndex(id => id == userId)] = this.findOrRegisterUser(userId, oldUser.name, oldUser.tag, oldUser.avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var channelId in obj.meta.channels){
|
||||||
|
var oldServer = obj.meta.servers[obj.meta.channels[channelId].server];
|
||||||
|
var oldChannel = obj.meta.channels[channelId];
|
||||||
|
this.tryRegisterChannel(this.findOrRegisterServer(oldServer.name, oldServer.type), channelId, oldChannel.name, oldChannel /* filtered later */);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var channelId in obj.data){
|
||||||
|
var oldChannel = obj.data[channelId];
|
||||||
|
|
||||||
|
for(var messageId in oldChannel){
|
||||||
|
var oldMessage = oldChannel[messageId];
|
||||||
|
var oldUser = oldMessage.u;
|
||||||
|
|
||||||
|
if (oldUser in userMap){
|
||||||
|
oldMessage.u = userMap[oldUser];
|
||||||
|
this.addMessage(channelId, messageId, oldMessage);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
if (!shownError){
|
||||||
|
shownError = true;
|
||||||
|
alert("The uploaded archive appears to be corrupted, some messages will be skipped. See console for details.");
|
||||||
|
|
||||||
|
console.error("User list:", obj.meta.users);
|
||||||
|
console.error("User index:", obj.meta.userindex);
|
||||||
|
console.error("Generated mapping:", userMap);
|
||||||
|
console.error("Missing user for the following messages:");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(oldMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(){
|
||||||
|
return JSON.stringify({
|
||||||
|
"meta": this.meta,
|
||||||
|
"data": this.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
65
src/tracker/scripts/settings.js
Normal file
65
src/tracker/scripts/settings.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const CONSTANTS = {
|
||||||
|
AUTOSCROLL_ACTION_NOTHING: "optNothing",
|
||||||
|
AUTOSCROLL_ACTION_PAUSE: "optPause",
|
||||||
|
AUTOSCROLL_ACTION_SWITCH: "optSwitch"
|
||||||
|
};
|
||||||
|
|
||||||
|
let IS_FIRST_RUN = false;
|
||||||
|
|
||||||
|
const SETTINGS = (function() {
|
||||||
|
const settingsChangedEvents = [];
|
||||||
|
|
||||||
|
const saveSettings = function() {
|
||||||
|
DOM.saveToCookie("DHT_SETTINGS", root, 60 * 60 * 24 * 365 * 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSettingsChanged = function(property) {
|
||||||
|
for (const callback of settingsChangedEvents) {
|
||||||
|
callback(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
const defineTriggeringProperty = function(obj, property, value) {
|
||||||
|
const name = "_" + property;
|
||||||
|
|
||||||
|
Object.defineProperty(obj, property, {
|
||||||
|
get: (() => obj[name]),
|
||||||
|
set: (value => {
|
||||||
|
obj[name] = value;
|
||||||
|
triggerSettingsChanged(property);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
obj[name] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
loaded = {
|
||||||
|
"_autoscroll": true,
|
||||||
|
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||||
|
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
|
||||||
|
};
|
||||||
|
|
||||||
|
IS_FIRST_RUN = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = {
|
||||||
|
onSettingsChanged(callback) {
|
||||||
|
settingsChangedEvents.push(callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
||||||
|
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
||||||
|
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
||||||
|
|
||||||
|
if (IS_FIRST_RUN) {
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
})();
|
158
src/tracker/scripts/state.js
Normal file
158
src/tracker/scripts/state.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// noinspection FunctionWithInconsistentReturnsJS
|
||||||
|
const STATE = (function() {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Internal class constructor.
|
||||||
|
*/
|
||||||
|
class CLS{
|
||||||
|
constructor(){
|
||||||
|
this._stateChangedEvents = [];
|
||||||
|
this._trackingStateChangedListeners = [];
|
||||||
|
this.resetState();
|
||||||
|
};
|
||||||
|
|
||||||
|
_triggerStateChanged(changeType, changeDetail){
|
||||||
|
for(var callback of this._stateChangedEvents){
|
||||||
|
callback(changeType, changeDetail);
|
||||||
|
}
|
||||||
|
if (changeType === "tracking") {
|
||||||
|
for (let callback of this._trackingStateChangedListeners) {
|
||||||
|
callback(this._isTracking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resets the state to default values.
|
||||||
|
*/
|
||||||
|
resetState(){
|
||||||
|
this._savefile = null;
|
||||||
|
this._isTracking = false;
|
||||||
|
this._lastFileName = null;
|
||||||
|
this._triggerStateChanged("data", "reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns the savefile object, creates a new one if needed.
|
||||||
|
*/
|
||||||
|
getSavefile(){
|
||||||
|
if (!this._savefile){
|
||||||
|
this._savefile = new SAVEFILE();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._savefile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns true if the database file contains any data.
|
||||||
|
*/
|
||||||
|
hasSavedData(){
|
||||||
|
return this._savefile != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns true if currently tracking message.
|
||||||
|
*/
|
||||||
|
isTracking(){
|
||||||
|
return this._isTracking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sets the tracking state.
|
||||||
|
*/
|
||||||
|
setIsTracking(state){
|
||||||
|
this._isTracking = state;
|
||||||
|
this._triggerStateChanged("tracking", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Combines current savefile with the provided one.
|
||||||
|
*/
|
||||||
|
uploadSavefile(fileName, fileObject){
|
||||||
|
this._lastFileName = fileName;
|
||||||
|
this.getSavefile().combineWith(fileObject);
|
||||||
|
this._triggerStateChanged("data", "upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Triggers a UTF-8 text file download.
|
||||||
|
*/
|
||||||
|
downloadTextFile(fileName, fileContents) {
|
||||||
|
var blob = new Blob([fileContents], { "type": "octet/stream" });
|
||||||
|
|
||||||
|
if ("msSaveBlob" in window.navigator){
|
||||||
|
return window.navigator.msSaveBlob(blob, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
var ele = DOM.createElement("a", document.body);
|
||||||
|
ele.href = url;
|
||||||
|
ele.download = fileName;
|
||||||
|
ele.style.display = "none";
|
||||||
|
|
||||||
|
ele.click();
|
||||||
|
|
||||||
|
document.body.removeChild(ele);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Triggers a savefile download, if available.
|
||||||
|
*/
|
||||||
|
downloadSavefile(){
|
||||||
|
if (this.hasSavedData()){
|
||||||
|
this.downloadTextFile(this._lastFileName || "dht.txt", this._savefile.toJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Registers a Discord server and channel.
|
||||||
|
*/
|
||||||
|
addDiscordChannel(serverInfo, channelInfo){
|
||||||
|
var serverName = serverInfo.name;
|
||||||
|
var serverType = serverInfo.type;
|
||||||
|
var channelId = channelInfo.id;
|
||||||
|
var channelName = channelInfo.name;
|
||||||
|
var extraInfo = channelInfo.extra || {};
|
||||||
|
|
||||||
|
var serverIndex = this.getSavefile().findOrRegisterServer(serverName, serverType);
|
||||||
|
|
||||||
|
if (this.getSavefile().tryRegisterChannel(serverIndex, channelId, channelName, extraInfo) === true){
|
||||||
|
this._triggerStateChanged("data", "channel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adds all messages from the array to the specified channel. Returns true if the savefile was updated.
|
||||||
|
*/
|
||||||
|
addDiscordMessages(discordMessageArray){
|
||||||
|
discordMessageArray = discordMessageArray.filter(msg => (msg.type === DISCORD.MESSAGE_TYPE.DEFAULT || msg.type === DISCORD.MESSAGE_TYPE.REPLY || msg.type === DISCORD.MESSAGE_TYPE.THREAD_STARTER) && msg.state === "SENT");
|
||||||
|
|
||||||
|
if (this.getSavefile().addMessagesFromDiscord(discordMessageArray)){
|
||||||
|
this._triggerStateChanged("data", "messages");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adds a listener that is called whenever the state changes. The callback is a function that takes subject (generic type) and detail (specific type or data).
|
||||||
|
*/
|
||||||
|
onStateChanged(callback){
|
||||||
|
this._stateChangedEvents.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shim for code from the desktop app.
|
||||||
|
*/
|
||||||
|
onTrackingStateChanged(callback) {
|
||||||
|
this._trackingStateChangedListeners.push(callback);
|
||||||
|
callback(this._isTracking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CLS();
|
||||||
|
})();
|
@@ -1,64 +0,0 @@
|
|||||||
var CONSTANTS = {
|
|
||||||
AUTOSCROLL_ACTION_NOTHING: "optNothing",
|
|
||||||
AUTOSCROLL_ACTION_PAUSE: "optPause",
|
|
||||||
AUTOSCROLL_ACTION_SWITCH: "optSwitch"
|
|
||||||
};
|
|
||||||
|
|
||||||
var IS_FIRST_RUN = false;
|
|
||||||
|
|
||||||
var SETTINGS = (function(){
|
|
||||||
var root = {};
|
|
||||||
var settingsChangedEvents = [];
|
|
||||||
|
|
||||||
var saveSettings = function(){
|
|
||||||
DOM.saveToCookie("DHT_SETTINGS", root, 60*60*24*365*5);
|
|
||||||
};
|
|
||||||
|
|
||||||
var triggerSettingsChanged = function(changeType, changeDetail){
|
|
||||||
for(var callback of settingsChangedEvents){
|
|
||||||
callback(changeType, changeDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSettings();
|
|
||||||
};
|
|
||||||
|
|
||||||
var defineTriggeringProperty = function(obj, property, value){
|
|
||||||
var name = "_"+property;
|
|
||||||
|
|
||||||
Object.defineProperty(obj, property, {
|
|
||||||
get: (() => obj[name]),
|
|
||||||
set: (value => {
|
|
||||||
obj[name] = value;
|
|
||||||
triggerSettingsChanged("setting", property);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
obj[name] = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
var loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
|
||||||
|
|
||||||
if (!loaded){
|
|
||||||
loaded = {
|
|
||||||
"_autoscroll": true,
|
|
||||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
|
||||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
|
|
||||||
};
|
|
||||||
|
|
||||||
IS_FIRST_RUN = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
|
||||||
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
|
||||||
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
|
||||||
|
|
||||||
root.onSettingsChanged = function(callback){
|
|
||||||
settingsChangedEvents.push(callback);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (IS_FIRST_RUN){
|
|
||||||
saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
|
||||||
})();
|
|
@@ -1,119 +0,0 @@
|
|||||||
var STATE = (function(){
|
|
||||||
var stateChangedEvents = [];
|
|
||||||
|
|
||||||
var triggerStateChanged = function(changeType, changeDetail){
|
|
||||||
for(var callback of stateChangedEvents){
|
|
||||||
callback(changeType, changeDetail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Internal class constructor.
|
|
||||||
*/
|
|
||||||
class CLS{
|
|
||||||
constructor(){
|
|
||||||
this.resetState();
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Resets the state to default values.
|
|
||||||
*/
|
|
||||||
resetState(){
|
|
||||||
this._savefile = null;
|
|
||||||
this._isTracking = false;
|
|
||||||
this._lastFileName = null;
|
|
||||||
triggerStateChanged("data", "reset");
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns the savefile object, creates a new one if needed.
|
|
||||||
*/
|
|
||||||
getSavefile(){
|
|
||||||
if (!this._savefile){
|
|
||||||
this._savefile = new SAVEFILE();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._savefile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns true if the database file contains any data.
|
|
||||||
*/
|
|
||||||
hasSavedData(){
|
|
||||||
return this._savefile != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns true if currently tracking message.
|
|
||||||
*/
|
|
||||||
isTracking(){
|
|
||||||
return this._isTracking;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Sets the tracking state.
|
|
||||||
*/
|
|
||||||
setIsTracking(state){
|
|
||||||
this._isTracking = state;
|
|
||||||
triggerStateChanged("tracking", state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Combines current savefile with the provided one.
|
|
||||||
*/
|
|
||||||
uploadSavefile(fileName, fileObject){
|
|
||||||
this._lastFileName = fileName;
|
|
||||||
this.getSavefile().combineWith(fileObject);
|
|
||||||
triggerStateChanged("data", "upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Triggers a savefile download, if available.
|
|
||||||
*/
|
|
||||||
downloadSavefile(){
|
|
||||||
if (this.hasSavedData()){
|
|
||||||
DOM.downloadTextFile(this._lastFileName || "dht.txt", this._savefile.toJson());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Registers a Discord server and channel.
|
|
||||||
*/
|
|
||||||
addDiscordChannel(serverName, serverType, channelId, channelName, extraInfo){
|
|
||||||
var serverIndex = this.getSavefile().findOrRegisterServer(serverName, serverType);
|
|
||||||
|
|
||||||
if (this.getSavefile().tryRegisterChannel(serverIndex, channelId, channelName, extraInfo) === true){
|
|
||||||
triggerStateChanged("data", "channel");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Adds all messages from the array to the specified channel. Returns true if the savefile was updated.
|
|
||||||
*/
|
|
||||||
addDiscordMessages(channelId, discordMessageArray){
|
|
||||||
if (this.getSavefile().addMessagesFromDiscord(channelId, discordMessageArray)){
|
|
||||||
triggerStateChanged("data", "messages");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Returns true if the message was added during this session.
|
|
||||||
*/
|
|
||||||
isMessageFresh(id){
|
|
||||||
return this.getSavefile().isMessageFresh(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Adds a listener that is called whenever the state changes. The callback is a function that takes subject (generic type) and detail (specific type or data).
|
|
||||||
*/
|
|
||||||
onStateChanged(callback){
|
|
||||||
stateChangedEvents.push(callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CLS();
|
|
||||||
})();
|
|
37
src/tracker/styles/controller.css
Normal file
37
src/tracker/styles/controller.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#app-mount {
|
||||||
|
height: calc(100% - 48px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-ctrl {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 1000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-ctrl button {
|
||||||
|
height: 32px;
|
||||||
|
margin: 8px 0 8px 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background-color: #7289da;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-ctrl button:disabled {
|
||||||
|
background-color: #7a7a7a;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-ctrl p {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-ctrl-close {
|
||||||
|
margin: 8px 8px 8px 0 !important;
|
||||||
|
float: right;
|
||||||
|
}
|
33
src/tracker/styles/settings.css
Normal file
33
src/tracker/styles/settings.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#dht-cfg-overlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #000;
|
||||||
|
opacity: 0.5;
|
||||||
|
display: block;
|
||||||
|
z-index: 1000001;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-cfg {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 800px;
|
||||||
|
height: 262px;
|
||||||
|
margin-left: -400px;
|
||||||
|
margin-top: -131px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 1000002;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-cfg-note {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-cfg sub {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
142
src/tracker/Σ.js
142
src/tracker/Σ.js
@@ -1,142 +0,0 @@
|
|||||||
const url = window.location.href;
|
|
||||||
|
|
||||||
if (!url.includes("discord.com/") && !url.includes("discordapp.com/") && !confirm("Could not detect Discord in the URL, do you want to run the script anyway?")){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.DHT_LOADED){
|
|
||||||
alert("Discord History Tracker is already loaded.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.DHT_LOADED = true;
|
|
||||||
window.DHT_ON_UNLOAD = [];
|
|
||||||
|
|
||||||
// Execution
|
|
||||||
|
|
||||||
let ignoreMessageCallback = new Set();
|
|
||||||
let frozenMessageLoadingTimer = null;
|
|
||||||
|
|
||||||
let stopTrackingDelayed = function(callback){
|
|
||||||
ignoreMessageCallback.add("stopping");
|
|
||||||
|
|
||||||
DOM.setTimer(() => {
|
|
||||||
STATE.setIsTracking(false);
|
|
||||||
ignoreMessageCallback.delete("stopping");
|
|
||||||
|
|
||||||
if (callback){
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}, 200); // give the user visual feedback after clicking the button before switching off
|
|
||||||
};
|
|
||||||
|
|
||||||
DISCORD.setupMessageUpdateCallback(() => {
|
|
||||||
if (STATE.isTracking() && ignoreMessageCallback.size === 0){
|
|
||||||
let info = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (!info){
|
|
||||||
stopTrackingDelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
STATE.addDiscordChannel(info.server, info.type, info.id, info.channel, info.extra);
|
|
||||||
|
|
||||||
let messages = DISCORD.getMessages();
|
|
||||||
|
|
||||||
if (messages == null){
|
|
||||||
stopTrackingDelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else if (!messages.length){
|
|
||||||
DISCORD.loadOlderMessages();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasUpdatedFile = STATE.addDiscordMessages(info.id, messages);
|
|
||||||
|
|
||||||
if (SETTINGS.autoscroll){
|
|
||||||
let action = null;
|
|
||||||
|
|
||||||
if (!hasUpdatedFile && !STATE.isMessageFresh(messages[0].id)){
|
|
||||||
action = SETTINGS.afterSavedMsg;
|
|
||||||
}
|
|
||||||
else if (!DISCORD.hasMoreMessages()){
|
|
||||||
action = SETTINGS.afterFirstMsg;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === null){
|
|
||||||
if (hasUpdatedFile){
|
|
||||||
DISCORD.loadOlderMessages();
|
|
||||||
window.clearTimeout(frozenMessageLoadingTimer);
|
|
||||||
frozenMessageLoadingTimer = null;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
frozenMessageLoadingTimer = window.setTimeout(DISCORD.loadOlderMessages, 2500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
ignoreMessageCallback.add("stalling");
|
|
||||||
|
|
||||||
DOM.setTimer(() => {
|
|
||||||
ignoreMessageCallback.delete("stalling");
|
|
||||||
|
|
||||||
let updatedInfo = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (updatedInfo && updatedInfo.id === info.id){
|
|
||||||
let lastMessages = DISCORD.getMessages(); // sometimes needed to catch the last few messages before switching
|
|
||||||
|
|
||||||
if (lastMessages != null){
|
|
||||||
STATE.addDiscordMessages(info.id, lastMessages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel()) || action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE){
|
|
||||||
STATE.setIsTracking(false);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
STATE.onStateChanged((type, enabled) => {
|
|
||||||
if (type === "tracking" && enabled){
|
|
||||||
let info = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (info){
|
|
||||||
let messages = DISCORD.getMessages();
|
|
||||||
|
|
||||||
if (messages != null){
|
|
||||||
STATE.addDiscordChannel(info.server, info.type, info.id, info.channel, info.extra);
|
|
||||||
STATE.addDiscordMessages(info.id, messages);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
stopTrackingDelayed(() => alert("The selected channel is not visible in the channel list."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SETTINGS.autoscroll && DISCORD.isInMessageView()){
|
|
||||||
if (DISCORD.hasMoreMessages()){
|
|
||||||
DISCORD.loadOlderMessages();
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
let action = SETTINGS.afterFirstMsg;
|
|
||||||
|
|
||||||
if ((action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel()) || action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE){
|
|
||||||
stopTrackingDelayed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
GUI.showController();
|
|
||||||
|
|
||||||
if (IS_FIRST_RUN){
|
|
||||||
GUI.showSettings();
|
|
||||||
}
|
|
@@ -1,7 +1,8 @@
|
|||||||
var DISCORD = (function(){
|
var DISCORD = (function(){
|
||||||
var REGEX = {
|
var REGEX = {
|
||||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||||
formatItalic: /(.)?\*([\s\S]+?)\*(?!\*)/g,
|
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||||
|
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
|
||||||
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
||||||
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
||||||
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
||||||
@@ -54,7 +55,8 @@ var DISCORD = (function(){
|
|||||||
.replace(REGEX.specialEscapedSingle, escapeHtmlMatch)
|
.replace(REGEX.specialEscapedSingle, escapeHtmlMatch)
|
||||||
.replace(REGEX.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
.replace(REGEX.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
||||||
.replace(REGEX.formatBold, "<b>$1</b>")
|
.replace(REGEX.formatBold, "<b>$1</b>")
|
||||||
.replace(REGEX.formatItalic, (full, pre, match) => pre === '\\' ? full : (pre || "")+"<i>"+match+"</i>")
|
.replace(REGEX.formatItalic1, "<i>$1</i>")
|
||||||
|
.replace(REGEX.formatItalic2, "<i>$1</i>")
|
||||||
.replace(REGEX.formatUnderline, "<u>$1</u>")
|
.replace(REGEX.formatUnderline, "<u>$1</u>")
|
||||||
.replace(REGEX.formatStrike, "<s>$1</s>");
|
.replace(REGEX.formatStrike, "<s>$1</s>");
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="robots" content="index,follow">
|
<meta name="robots" content="index,follow">
|
||||||
@@ -14,14 +14,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<h1>Discord History Tracker <span class="version">{{{version:web}}}</span> <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/wiki/Release-Notes">Release Notes</a></span></h1>
|
<h1>Discord History Tracker <span class="version">{{{version:web}}}</span> <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/wiki/Release-Notes">Release Notes</a></span></h1>
|
||||||
<p>Discord History Tracker is a browser script that lets you locally save chat history in your servers, groups, and private conversations.</p>
|
<p>Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.</p>
|
||||||
<p>When the script is active, it will load history of the selected text channel up to the first message, and let you download it for offline viewing in your browser.</p>
|
|
||||||
|
|
||||||
<img src="img/tracker.png" width="851" class="dht bordered">
|
<img src="img/tracker.png" width="851" class="dht bordered">
|
||||||
|
<p>This page explains how to use Discord History Tracker entirely in your browser. While this method gets you started quicker and works on any device that has a modern web browser, it has <strong>significant limitations and fewer features</strong> than the <a href="https://dht.chylex.com">desktop app</a>.</p>
|
||||||
|
<p>Because everything happens in your browser, if the browser tab is closed, or your browser or computer crashes, you will lose all progress. Your browser may also crash or freeze if you have too many messages. If this is a concern, <a href="https://dht.chylex.com">use the desktop app</a> instead.</p>
|
||||||
|
|
||||||
<h2>How to Save History</h2>
|
<h2>How to Use</h2>
|
||||||
<h3>Running the Script</h3>
|
<p>A tracking script will load messages according to your settings, and temporarily save them in your browser. Once you finish tracking, the browser will create an archive file you can save to your disk, and open in an offline viewer later.</p>
|
||||||
|
|
||||||
|
<h3>Setup the Tracking Script</h3>
|
||||||
<h4>Option 1: Userscript</h4>
|
<h4>Option 1: Userscript</h4>
|
||||||
<div class="quote">
|
<div class="quote">
|
||||||
<p><strong>Preferred option.</strong> Requires a browser addon, but DHT will stay up-to-date and be easily accessible on the Discord website.</p>
|
<p><strong>Preferred option.</strong> Requires a browser addon, but DHT will stay up-to-date and be easily accessible on the Discord website.</p>
|
||||||
@@ -40,19 +41,21 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Option 2: Browser Console</h4>
|
<h4>Option 2: Browser / Discord Console</h4>
|
||||||
<div class="quote">
|
<div class="quote">
|
||||||
<p>The console is the only way to use DHT directly in the desktop app.</p>
|
<p>The console is the only way to use DHT directly in the desktop app.</p>
|
||||||
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>Click <a href="javascript:" id="tracker-copy-button" onauxclick="return false;">Copy to Clipboard</a> to copy the script<noscript> (requires JavaScript)</noscript></li>
|
<li>Click <a href="javascript:" id="tracker-copy-button" onauxclick="return false;">Copy to Clipboard</a> to copy the tracking script
|
||||||
|
<noscript> (requires JavaScript)</noscript>
|
||||||
|
</li>
|
||||||
<li>Press <strong>Ctrl</strong>+<strong>Shift</strong>+<strong>I</strong> in your browser or the Discord app, and select the <strong>Console</strong> tab</li>
|
<li>Press <strong>Ctrl</strong>+<strong>Shift</strong>+<strong>I</strong> in your browser or the Discord app, and select the <strong>Console</strong> tab</li>
|
||||||
<li>Paste the script into the console, and press <strong>Enter</strong> to run it</li>
|
<li>Paste the script into the console, and press <strong>Enter</strong> to run it</li>
|
||||||
<li>Press <strong>Ctrl</strong>+<strong>Shift</strong>+<strong>I</strong> again to close the console</li>
|
<li>Press <strong>Ctrl</strong>+<strong>Shift</strong>+<strong>I</strong> again to close the console</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p id="tracker-copy-issue">Your browser may not support copying to clipboard, please try copying the script manually:</p>
|
<p id="tracker-copy-issue">Your browser may not support copying to clipboard, please try copying the script manually:</p>
|
||||||
<textarea id="tracker-copy-contents"><?php include "./build/track.html"; ?></textarea>
|
<textarea id="tracker-copy-contents"><?php include './build/track.html'; ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Option 3: Bookmarklet</h4>
|
<h4>Option 3: Bookmarklet</h4>
|
||||||
@@ -60,38 +63,33 @@
|
|||||||
<p>Requires Firefox 69 or newer.</p>
|
<p>Requires Firefox 69 or newer.</p>
|
||||||
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>Right-click <a href="<?php include "./build/track.html"; ?>" onclick="return false;" onauxclick="return false;">Discord History Tracker</a></li>
|
<li>Right-click <a href="<?php include './build/track.html'; ?>" onclick="return false;" onauxclick="return false;">Discord History Tracker</a></li>
|
||||||
<li>Select «Bookmark This Link» and save the bookmark</li>
|
<li>Select «Bookmark This Link» and save the bookmark</li>
|
||||||
<li>Open <a href="https://discord.com/channels/@me" rel="noreferrer">Discord</a> and click the bookmark to run the script</li>
|
<li>Open <a href="https://discord.com/channels/@me" rel="noreferrer">Discord</a> and click the bookmark to run the script</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Old Versions</h4>
|
<h4>Old Versions</h4>
|
||||||
<div class="quote">
|
<p>Whenever DHT is updated to work with a new version of Discord, it may no longer work with the previous version of Discord.</p>
|
||||||
<p>Whenever DHT is fixed to work with a recent Discord update, it will no longer work on the previous version of Discord.</p>
|
<p>If you haven't received that Discord update yet, see <a href="https://github.com/chylex/Discord-History-Tracker/wiki/Release-Notes">Release Notes</a> for information about recent updates, and <a href="https://github.com/chylex/Discord-History-Tracker/wiki/Old-Versions">Old Versions</a> if you need to use an older version of DHT.</p>
|
||||||
<p>If you haven't received that Discord update yet, see <a href="https://github.com/chylex/Discord-History-Tracker/wiki/Release-Notes">Release Notes</a> for information about recent updates, and <a href="https://github.com/chylex/Discord-History-Tracker/wiki/Old-Versions">Old Versions</a> if you need to use an older version of DHT.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Using the Script</h3>
|
<h3>How to Track Messages</h3>
|
||||||
<p>When running for the first time, you will see a <strong>Settings</strong> dialog where you can configure the script. These settings will be remembered as long as you don't delete cookies in your browser.</p>
|
<p>When using the script for the first time, you will see a <strong>Settings</strong> dialog where you can configure the script. These settings will be remembered as long as you don't delete cookies in your browser.</p>
|
||||||
<p>By default, Discord History Tracker is set to pause tracking after it reaches a previously saved message to avoid unnecessary history loading. You may also set it to load all channels in the server or your friends list by selecting <strong>Switch to Next Channel</strong>.</p>
|
<p>By default, Discord History Tracker is set to automatically scroll up to load the channel history, and pause tracking if it reaches a previously saved message to avoid unnecessary history loading.</p>
|
||||||
<p>Once you have configured everything, upload your previously saved archive (if you have any), click <strong>Start Tracking</strong>, and let it run. After the script saves all messages, download the archive.</p>
|
<p>Before you <strong>Start Tracking</strong>, you may use <strong>Upload & Combine</strong> to load messages from a previously saved archive file into the browser.</p>
|
||||||
|
<p>When you click <strong>Download</strong>, the browser will generate an archive file from saved messages, and lets you save it to your computer.</p>
|
||||||
|
|
||||||
<h2>How to View History</h2>
|
<h3>How to View History</h3>
|
||||||
<p>Download the <a href="build/viewer.html">Viewer</a>, open it in your browser, and load the archive. By downloading it to your computer, you can view archives offline, and allow the browser to load image previews that might otherwise not load if the remote server prevents embedding them.</p>
|
<p>First, save the <a href="build/viewer.html">Viewer</a> file to your computer. Then you can open the downloaded viewer in your browser, click <strong>Load File</strong>, and select the archive to view.</p>
|
||||||
|
|
||||||
<h2>External Links</h2>
|
<h2>External Links</h2>
|
||||||
<p class="links">
|
<p class="links">
|
||||||
<a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a> —
|
<a href="https://github.com/chylex/Discord-History-Tracker/issues">Issues & Suggestions</a> —
|
||||||
<a href="https://github.com/chylex/Discord-History-Tracker">Source Code</a> —
|
<a href="https://github.com/chylex/Discord-History-Tracker/tree/master-browser-only">Source Code</a> —
|
||||||
<a href="https://twitter.com/chylexmc">Follow Dev on Twitter</a> —
|
<a href="https://twitter.com/chylexmc">Follow Dev on Twitter</a> —
|
||||||
<a href="https://www.patreon.com/chylex">Support via Patreon</a> —
|
<a href="https://www.patreon.com/chylex">Support via Patreon</a> —
|
||||||
<a href="https://ko-fi.com/chylex">Support via Ko-fi</a>
|
<a href="https://ko-fi.com/chylex">Support via Ko-fi</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Disclaimer</h2>
|
|
||||||
<p>Discord History Tracker and the viewer are fully client-side and do not communicate with any servers – the terms 'Upload' and 'Download' only refer to your browser. If you close your browser while the script is running, all unsaved progress will be lost.</p>
|
|
||||||
<p>Please, do not use this script for large or public servers. The script was made as a convenient way of keeping a local copy of private and group chats, as Discord is currently lacking this functionality.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@@ -99,12 +97,12 @@
|
|||||||
var issue = document.getElementById("tracker-copy-issue");
|
var issue = document.getElementById("tracker-copy-issue");
|
||||||
var button = document.getElementById("tracker-copy-button");
|
var button = document.getElementById("tracker-copy-button");
|
||||||
|
|
||||||
if (document.queryCommandSupported("copy")){
|
if (document.queryCommandSupported("copy")) {
|
||||||
contents.style.display = "none";
|
contents.style.display = "none";
|
||||||
issue.style.display = "none";
|
issue.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
button.addEventListener("click", function(){
|
button.addEventListener("click", function() {
|
||||||
contents.style.display = "block";
|
contents.style.display = "block";
|
||||||
issue.style.display = "block";
|
issue.style.display = "block";
|
||||||
|
|
||||||
@@ -116,9 +114,9 @@
|
|||||||
issue.style.display = "none";
|
issue.style.display = "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
contents.addEventListener("click", function(){
|
contents.addEventListener("click", function() {
|
||||||
contents.select();
|
contents.select();
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -1,13 +1,12 @@
|
|||||||
body {
|
body {
|
||||||
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0 0 20px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
text-shadow: 1px 1px 0 #111;
|
text-shadow: 1px 1px 0 #111;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
background-color: #3B3E45;
|
background-color: #3b3e45;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner {
|
.inner {
|
||||||
@@ -22,7 +21,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #0EB3E0;
|
color: #1ecfff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,30 +63,31 @@ h1 span.notes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 36px 0 0;
|
margin: 40px 0 0;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
color: #ffb67b;
|
color: #f9d288;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 24px 0 0;
|
margin: 30px 0 12px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 + h3, h3 + h4 {
|
h2 + h3, h3 + h4 {
|
||||||
margin-top: 12px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 22px 0 0;
|
margin: 25px 0 0;
|
||||||
font-size: 19px;
|
font-size: 20px;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin-top: -6px;
|
margin-top: -6px;
|
||||||
margin-left: -6px;
|
margin-left: -6px;
|
||||||
|
margin-bottom: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
@@ -102,6 +102,10 @@ li > img {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.dht {
|
.dht {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: auto;
|
max-height: auto;
|
||||||
@@ -116,11 +120,10 @@ li > img {
|
|||||||
border: 2px dashed rgba(255, 255, 255, 0.25);
|
border: 2px dashed rgba(255, 255, 255, 0.25);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote {
|
.quote {
|
||||||
border-left: 2px dashed rgba(255, 255, 255, 0.1);
|
border-left: 2px dashed rgba(255, 253, 123, 0.5);
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user