mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-09-15 19:32:09 +02:00
Compare commits
15 Commits
a20ce8ee71
...
master-bro
Author | SHA1 | Date | |
---|---|---|---|
83603eceb8
|
|||
5277f28318
|
|||
![]() |
515825f7a4 | ||
af48bf60ce
|
|||
3ac968aa38
|
|||
5debfa9ec6
|
|||
92b8450c80
|
|||
ff6e21186c
|
|||
f1bbe6d13c
|
|||
4eb78def90
|
|||
4e8df28dc2
|
|||
![]() |
6ca386b741 | ||
ce87901088
|
|||
ea03f285a6
|
|||
4d914f2ae7
|
File diff suppressed because one or more lines are too long
2602
bld/track.user.js
2602
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 distutils.dir_util
|
||||
|
||||
VERSION_SHORT = "v.31e"
|
||||
VERSION_FULL = VERSION_SHORT + ", released 10 October 2022"
|
||||
VERSION_SHORT = "v.31h"
|
||||
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_AUTO = "uglifyjs --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 --output \"{1}\" \"{0}\""
|
||||
|
||||
USE_UGLIFYJS = "--nominify" not in sys.argv
|
||||
USE_MINIFICATION = "--nominify" not in sys.argv
|
||||
@@ -32,10 +32,6 @@ else:
|
||||
USE_UGLIFYJS = False
|
||||
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
|
||||
|
||||
@@ -53,6 +49,23 @@ def combine_files(input_pattern, output_file):
|
||||
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):
|
||||
if not USE_MINIFICATION:
|
||||
if input_file != output_file:
|
||||
@@ -77,24 +90,39 @@ def minify_css(input_file, output_file):
|
||||
|
||||
# Build System
|
||||
|
||||
def build_tracker_html():
|
||||
def build_tracker():
|
||||
output_file_raw = "bld/track.js"
|
||||
output_file_html = "bld/track.html"
|
||||
output_file_userscript = "bld/track.user.js"
|
||||
|
||||
output_file_tmp = "bld/track.tmp.js"
|
||||
input_pattern = "src/tracker/*.js"
|
||||
with open("src/tracker/styles/controller.css", "r") as f:
|
||||
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:
|
||||
if not USE_UGLIFYJS:
|
||||
out.write("(function(){\n")
|
||||
|
||||
combine_files(input_pattern, out)
|
||||
out.write(full_tracker_js)
|
||||
|
||||
if not USE_UGLIFYJS:
|
||||
out.write("})()")
|
||||
|
||||
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:
|
||||
out.write("javascript:(function(){")
|
||||
@@ -105,27 +133,30 @@ def build_tracker_html():
|
||||
out.write("})()")
|
||||
|
||||
os.remove(output_file_tmp)
|
||||
|
||||
|
||||
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:
|
||||
out.write(script_contents)
|
||||
write_tracker_html(output_file_html, minified_tracker_js)
|
||||
write_tracker_userscript(output_file_userscript, full_tracker_js)
|
||||
|
||||
|
||||
def build_tracker_userscript():
|
||||
output_file = "bld/track.user.js"
|
||||
|
||||
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}}}")
|
||||
def write_tracker_html(output_file, tracker_js):
|
||||
tracker_js = tracker_js.replace("&", "&").replace('"', """).replace("'", "'").replace("<", "<").replace(">", ">")
|
||||
|
||||
with open(output_file, "w") as out:
|
||||
out.write(userscript_contents[0])
|
||||
combine_files(input_pattern, out)
|
||||
out.write(userscript_contents[1])
|
||||
out.write(tracker_js)
|
||||
|
||||
|
||||
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():
|
||||
@@ -150,7 +181,7 @@ def build_viewer():
|
||||
combine_files(input_js_pattern, out)
|
||||
|
||||
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:
|
||||
shutil.copyfile(tmp_js_file_combined, tmp_js_file_minified)
|
||||
|
||||
@@ -202,11 +233,8 @@ def build_website():
|
||||
|
||||
os.makedirs("bld", exist_ok = True)
|
||||
|
||||
print("Building tracker html...")
|
||||
build_tracker_html()
|
||||
|
||||
print("Building tracker userscript...")
|
||||
build_tracker_userscript()
|
||||
print("Building tracker...")
|
||||
build_tracker()
|
||||
|
||||
print("Building viewer...")
|
||||
build_viewer()
|
||||
|
74
reserve.txt
74
reserve.txt
@@ -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
|
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,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-'] *, [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;
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
@@ -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; 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 & 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;
|
||||
})();
|
@@ -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;
|
||||
}
|
136
src/tracker/Σ.js
136
src/tracker/Σ.js
@@ -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();
|
||||
}
|
@@ -1,7 +1,8 @@
|
||||
var DISCORD = (function(){
|
||||
var REGEX = {
|
||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||
formatItalic: /(.)?\*([\s\S]+?)\*(?!\*)/g,
|
||||
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
|
||||
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
||||
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
||||
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
||||
@@ -54,7 +55,8 @@ var DISCORD = (function(){
|
||||
.replace(REGEX.specialEscapedSingle, escapeHtmlMatch)
|
||||
.replace(REGEX.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
||||
.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.formatStrike, "<s>$1</s>");
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 1.7 KiB |
106
web/index.php
106
web/index.php
@@ -13,110 +13,10 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<p>Discord History Tracker lets you save chat history in your servers, groups, and private conversations, and view it offline.</p>
|
||||
<h1>Discord History Tracker <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/wiki/Release-Notes">Release Notes</a></span> <span class="bar">|</span> <span class="notes"><a href="https://github.com/chylex/Discord-History-Tracker/wiki/Old-Versions">Old Versions</a></span></h1>
|
||||
<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 Use</h2>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<ol>
|
||||
<li>Install a userscript manager addon:
|
||||
<ul>
|
||||
<li><a href="https://violentmonkey.github.io/get-it/">Violentmonkey</a> (Chrome)</li>
|
||||
<li><a href="https://tampermonkey.net/">Tampermonkey</a> (Firefox, Edge, Chrome, Opera)</li>
|
||||
<li>Due to browser bugs / limitations, DHT will not work in <strong>Firefox</strong> with Greasemonkey / Violentmonkey, and in <strong>Safari</strong> at all</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click <a href="build/track.user.js">Install Userscript</a> to prompt an installation into the userscript manager</li>
|
||||
<li>Open <a href="https://discord.com/channels/@me" rel="noreferrer">Discord</a>, and view any server, group, or private conversation (it will not appear in Friends list)</li>
|
||||
<li>Click <strong>DHT</strong> in the top right corner:<br><img src="img/button.png" class="bordered"></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h4>Option 2: Browser / Discord Console</h4>
|
||||
<div class="quote">
|
||||
<p>The console is the only way to use DHT directly in the desktop app.</p>
|
||||
|
||||
<ol>
|
||||
<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>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>
|
||||
</ol>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h4>Option 3: Bookmarklet</h4>
|
||||
<div class="quote">
|
||||
<p>Requires Firefox 69 or newer.</p>
|
||||
|
||||
<ol>
|
||||
<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>Open <a href="https://discord.com/channels/@me" rel="noreferrer">Discord</a> and click the bookmark to run the script</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h4>Old Versions</h4>
|
||||
<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>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>
|
||||
|
||||
<h3>How to Track Messages</h3>
|
||||
<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 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>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>
|
||||
|
||||
<h3>How to View History</h3>
|
||||
<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>
|
||||
<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/tree/master-browser-only">Source Code</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://ko-fi.com/chylex">Support via Ko-fi</a>
|
||||
</p>
|
||||
<p>The browser-only version of Discord History Tracker has been superseded by a <a href="https://dht.chylex.com">desktop app version</a>, and is no longer supported.</p>
|
||||
<p>You can import archives from the browser-only version into the desktop app, or view them by downloading the <a href="build/viewer.html">Viewer</a>.</p>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var contents = document.getElementById("tracker-copy-contents");
|
||||
var issue = document.getElementById("tracker-copy-issue");
|
||||
var button = document.getElementById("tracker-copy-button");
|
||||
|
||||
if (document.queryCommandSupported("copy")) {
|
||||
contents.style.display = "none";
|
||||
issue.style.display = "none";
|
||||
}
|
||||
|
||||
button.addEventListener("click", function() {
|
||||
contents.style.display = "block";
|
||||
issue.style.display = "block";
|
||||
|
||||
contents.select();
|
||||
document.execCommand("copy");
|
||||
|
||||
button.innerHTML = "Copied to Clipboard";
|
||||
contents.style.display = "none";
|
||||
issue.style.display = "none";
|
||||
});
|
||||
|
||||
contents.addEventListener("click", function() {
|
||||
contents.select();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
Reference in New Issue
Block a user