1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2024-11-25 14:42:44 +01:00

Compare commits

...

2 Commits

Author SHA1 Message Date
will-ca
da72649247
Merge 21490dd69e into ce87901088 2023-11-23 14:51:45 +01:00
will-ca
21490dd69e
Refactor to match code from the app version (PR #237) 2023-11-23 14:48:09 +01:00
18 changed files with 1507 additions and 1482 deletions

View File

@ -11,8 +11,8 @@ import distutils.dir_util
VERSION_SHORT = "v.31f" VERSION_SHORT = "v.31f"
VERSION_FULL = VERSION_SHORT + ", released 20 November 2023" VERSION_FULL = VERSION_SHORT + ", released 20 November 2023"
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,34 @@ 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)
minified_tracker_js = full_tracker_js
with open(output_file_raw, "w") as out: with open(output_file_raw, "w") as out:
if not USE_UGLIFYJS: out.write(full_tracker_js)
out.write("(function(){\n")
combine_files(input_pattern, out)
if not USE_UGLIFYJS:
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(){")
@ -106,26 +129,29 @@ def build_tracker_html():
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("&", "&amp;").replace('"', "&quot;").replace("'", "&#x27;").replace("<", "&lt;").replace(">", "&gt;") 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("&", "&amp;").replace('"', "&quot;").replace("'", "&#x27;").replace("<", "&lt;").replace(">", "&gt;")
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 +176,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 +228,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()

View File

@ -1,74 +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
guild_id
guild

154
src/tracker/bootstrap.js vendored Normal file
View File

@ -0,0 +1,154 @@
// 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()) {
action = SETTINGS.afterFirstMsg;
}
if (isNoAction(action) && !anyNewMessages) {
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 {
await 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();
}

View File

@ -1,340 +0,0 @@
var DISCORD = (function(){
var getMessageOuterElement = function(){
return DOM.queryReactClass("messagesWrapper");
};
var getMessageScrollerElement = function(){
return getMessageOuterElement().querySelector("[class*='scroller_']");
};
var getMessageElements = function() {
return getMessageOuterElement().querySelectorAll("[class*='message_']");
};
var 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;
};
var tryGetReactProps = function(ele) {
try {
return this.getReactProps(ele);
} catch (ignore) {
return null;
}
};
var getMessageElementProps = function(ele) {
const props = getReactProps(ele);
if (props.children && props.children.length) {
for (let i = 3; i < props.children.length; i++) {
const childProps = props.children[i].props;
if (childProps && "message" in childProps && "channel" in childProps) {
return childProps;
}
}
}
return null;
};
var hasMoreMessages = function() {
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
};
var getMessages = function() {
try {
const messages = [];
for (const ele of getMessageElements()) {
try {
const props = getMessageElementProps(ele);
if (props != null) {
messages.push(props.message);
}
} catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, tryGetReactProps(ele));
}
}
return messages;
} catch (e) {
console.error("[DHT] Error retrieving messages.", e);
return [];
}
};
return {
/**
* Calls the provided function with a list of messages whenever the currently loaded messages change,
* or with `false` if there are no more messages.
*/
setupMessageCallback: function(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
let hasReachedStart = false;
const previousMessages = new Set();
const intervalId = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = getMessageScrollerElement();
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = getMessages();
let hasChanged = false;
for (const message of messages) {
if (!previousMessages.has(message.id)) {
hasChanged = true;
break;
}
}
if (!hasChanged) {
if (!hasReachedStart && !hasMoreMessages()) {
hasReachedStart = true;
callback(false);
}
return;
}
previousMessages.clear();
for (const message of messages) {
previousMessages.add(message.id);
}
hasReachedStart = false;
callback(messages);
}, 200);
window.DHT_ON_UNLOAD.push(() => window.clearInterval(intervalId));
},
/*
* Returns internal React state object of an element.
*/
getReactProps: function(ele){
return getReactProps(ele);
},
/*
* 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 {
let obj;
for (const ele of getMessageElements()) {
const props = getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
if (!obj) {
return null;
}
var 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 1: type = "DM"; break;
case 3: type = "GROUP"; break;
default: return null;
}
return {
"server": name,
"channel": name,
"id": obj.id,
"type": type,
"extra": {}
};
}
else if (obj.guild_id) {
let guild;
for (const child of 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;
}
return {
"server": guild.name,
"channel": obj.name,
"id": obj.id,
"type": "SERVER",
"extra": {
"position": obj.position,
"topic": obj.topic,
"nsfw": obj.nsfw
}
};
}
else {
return null;
}
} catch(e) {
console.error(e);
return null;
}
},
/*
* Returns an array containing currently loaded messages.
*/
getMessages: function(){
return getMessages();
},
/*
* 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 hasMoreMessages();
},
/*
* 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() {
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;
}
}
};
})();

View File

@ -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);
}
};
})();

View File

@ -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 { 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-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 &amp; 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: 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; }`);
// 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;
})();

View File

@ -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
});
}
}

View File

@ -0,0 +1,304 @@
// noinspection JSUnresolvedVariable
class DISCORD {
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) {
let skipsLeft = 0;
let waitForCleanup = false;
const previousMessages = new Set();
const timer = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = this.getMessageScrollerElement();
// noinspection JSUnusedGlobalSymbols
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = this.getMessages();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
if (!hasChanged) {
return;
}
previousMessages.clear();
for (const message of messages) {
previousMessages.add(message.id);
}
callback(messages);
}, 200);
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
}
/**
* Returns the property object of a message element.
* @returns { null | { message: DiscordMessage, channel: Object } }
*/
static getMessageElementProps(ele) {
const props = DOM.getReactProps(ele);
if (props.children && props.children.length) {
for (let i = 3; i < props.children.length; i++) {
const childProps = props.children[i].props;
if (childProps && "message" in childProps && "channel" in childProps) {
return childProps;
}
}
}
return null;
}
/**
* Returns an array containing currently loaded messages.
*/
static getMessages() {
try {
const messages = [];
for (const ele of this.getMessageElements()) {
try {
const props = this.getMessageElementProps(ele);
if (props != null) {
messages.push(props.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;
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);
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
}
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 1: type = "DM"; break;
case 3: 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.parent_id) {
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;
}
}
}

View 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
View 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 &amp; 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", "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 => {
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;
})();

View File

@ -0,0 +1,341 @@
/*
* 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";
}
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;
}
addMessagesFromDiscord(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(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
});
}
}

View 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;
})();

View File

@ -0,0 +1,157 @@
// 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");
}
}
// Right. Upstream desktop `bootstrap.js` expects an `async` here. I think it's fine.
/*
* Adds all messages from the array to the specified channel. Returns true if the savefile was updated.
*/
addDiscordMessages(discordMessageArray){
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();
})();

View File

@ -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;
})();

View File

@ -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();
})();

View 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;
}

View 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;
}

View File

@ -1,136 +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.setupMessageCallback(messages => {
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);
if (messages !== false && !messages.length){
DISCORD.loadOlderMessages();
return;
}
let hasUpdatedFile = messages !== false && STATE.addDiscordMessages(info.id, messages);
if (SETTINGS.autoscroll){
let action = null;
if (messages === false) {
action = SETTINGS.afterFirstMsg;
}
else if (!hasUpdatedFile && !STATE.isMessageFresh(messages[0].id)){
action = SETTINGS.afterSavedMsg;
}
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();
}