Compare commits

...
3 Commits
Author SHA1 Message Date
will-caandGitHub 72b23ee4f8 Merge 274f40636e into ce87901088 2023-11-23 06:50:04 +00:00
will-ca 274f40636e Remove superfluous comments. 2023-11-23 06:49:48 +00:00
will-ca bc438753a3 Use code from the desktop app. 2023-11-23 06:27:02 +00:00
22 changed files with 5574 additions and 2622 deletions
-1
View File
@@ -31,7 +31,6 @@ After you've done changes to the source code, you will need to build it. Before
Now open the folder that contains `build.py` in a command line, and run `python build.py` to create a build with default settings. The following files will be created:
* `bld/track.js` is the raw tracker script that can be pasted into a browser console
* `bld/track.html` is the tracker script but sanitized for inclusion in HTML (see `web/index.php` for examples)
* `bld/viewer.html` is the complete offline viewer
You can tweak the build process using the following flags:
+1483 -1
View File
File diff suppressed because one or more lines are too long
+1347 -1242
View File
File diff suppressed because it is too large Load Diff
+1263 -5
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -42,7 +42,7 @@ if USE_UGLIFYJS:
def combine_files(input_pattern, output_file):
is_first_file = True
with fileinput.input(sorted(glob.glob(input_pattern))) as stream:
with fileinput.input(sorted(glob.glob(input_pattern, recursive=True))) as stream:
for line in stream:
if stream.isfirstline():
if is_first_file:
@@ -82,7 +82,7 @@ def build_tracker_html():
output_file_html = "bld/track.html"
output_file_tmp = "bld/track.tmp.js"
input_pattern = "src/tracker/*.js"
input_pattern = "src/tracker/**/*.js"
with open(output_file_raw, "w") as out:
if not USE_UGLIFYJS:
@@ -116,7 +116,7 @@ def build_tracker_html():
def build_tracker_userscript():
output_file = "bld/track.user.js"
input_pattern = "src/tracker/*.js"
input_pattern = "src/tracker/**/*.js"
userscript_base = "src/base/track.user.js"
with open(userscript_base, "r") as base:
@@ -0,0 +1,5 @@
**STOP!**
These files must be kept in sync with the upstream desktop app branch in order for future changes/fixes to the desktop script to be cleanly merged here.
Changes for the browser version should be done in another file to maintain mergeablity.
+37
View File
@@ -0,0 +1,37 @@
// NOTE: Currently unused. See `.createStyle()` calls in `gui.js`.
/*
const css_controller = `
#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;
}
`
*/
+33
View File
@@ -0,0 +1,33 @@
// NOTE: Currently unused. See `.createStyle()` calls in `gui.js`.
/*
const css_settings = `
#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;
}
`
*/
+304
View File
@@ -0,0 +1,304 @@
// noinspection JSUnresolvedVariable
class DISCORD {
static getMessageOuterElement() {
return DOM.queryReactClass("messagesWrapper");
}
static getMessageScrollerElement() {
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
}
static getMessageElements() {
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
}
static hasMoreMessages() {
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
}
static loadOlderMessages() {
const view = this.getMessageScrollerElement();
if (view.scrollTop > 0) {
view.scrollTop = 0;
}
}
/**
* Calls the provided function with a list of messages whenever the currently loaded messages change.
*/
static setupMessageCallback(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
const previousMessages = new Set();
const timer = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = this.getMessageScrollerElement();
// noinspection JSUnusedGlobalSymbols
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = this.getMessages();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
if (!hasChanged) {
return;
}
previousMessages.clear();
for (const message of messages) {
previousMessages.add(message.id);
}
callback(messages);
}, 200);
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
}
/**
* Returns the property object of a message element.
* @returns { null | { message: DiscordMessage, channel: Object } }
*/
static getMessageElementProps(ele) {
const props = DOM.getReactProps(ele);
if (props.children && props.children.length) {
for (let i = 3; i < props.children.length; i++) {
const childProps = props.children[i].props;
if (childProps && "message" in childProps && "channel" in childProps) {
return childProps;
}
}
}
return null;
}
/**
* Returns an array containing currently loaded messages.
*/
static getMessages() {
try {
const messages = [];
for (const ele of this.getMessageElements()) {
try {
const props = this.getMessageElementProps(ele);
if (props != null) {
messages.push(props.message);
}
} catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
}
}
return messages;
} catch (e) {
console.error("[DHT] Error retrieving messages.", e);
return [];
}
}
/**
* Returns an object containing the selected server and channel information.
* For types DM and GROUP, the server and channel ids and names are identical.
* @returns { {} | null }
*/
static getSelectedChannel() {
try {
let obj;
try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
if (child && child.props && child.props.channel) {
obj = child.props.channel;
break;
}
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
}
if (!obj || typeof obj.id !== "string") {
return null;
}
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
let name;
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) {
name = node.nodeValue;
break;
}
}
if (!name) {
return null;
}
let type;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) {
case 1: type = "DM"; break;
case 3: type = "GROUP"; break;
default: return null;
}
const id = obj.id;
const server = { id, name, type };
const channel = { id, name };
return { server, channel };
}
else if (obj.guild_id) {
let guild;
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
if (child && child.props && child.props.guild) {
guild = child.props.guild;
break;
}
}
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
return null;
}
const server = {
"id": guild.id,
"name": guild.name,
"type": "SERVER"
};
const channel = {
"id": obj.id,
"name": obj.name,
"extra": {
"nsfw": obj.nsfw
}
};
if (obj.parent_id) {
channel["extra"]["parent"] = obj.parent_id;
}
else {
channel["extra"]["position"] = obj.position;
channel["extra"]["topic"] = obj.topic;
}
return { server, channel };
}
else {
return null;
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel.", e);
return null;
}
}
/**
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
*/
static selectNextTextChannel() {
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
const currentChannel = DOM.queryReactClass("selected", dms);
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
return false;
}
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannelLink.scrollIntoView(true);
return true;
}
else {
const channelListEle = document.getElementById("channels");
if (!channelListEle) {
return false;
}
function getLinkElement(channel) {
return channel.querySelector("a[href^='/channels/'][role='link']");
}
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
let nextChannel = null;
for (let index = 0; index < allTextChannels.length - 1; index++) {
if (allTextChannels[index].className.includes("selected_")) {
nextChannel = allTextChannels[index + 1];
break;
}
}
if (nextChannel === null) {
return false;
}
const nextChannelLink = getLinkElement(nextChannel);
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannel.scrollIntoView(true);
return true;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
class DOM {
/**
* Returns a child element by its ID. Parent defaults to the entire document.
* @returns {HTMLElement}
*/
static id(id, parent) {
return (parent || document).getElementById(id);
}
/**
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
*/
static queryReactClass(cls, parent) {
return (parent || document).querySelector(`[class*="${cls}_"]`);
}
/**
* Creates an element, adds it to the DOM, and returns it.
*/
static createElement(tag, parent, id, html) {
/** @type HTMLElement */
const ele = document.createElement(tag);
ele.id = id || "";
ele.innerHTML = html || "";
parent.appendChild(ele);
return ele;
}
/**
* Removes an element from the DOM.
*/
static removeElement(ele) {
return ele.parentNode.removeChild(ele);
}
/**
* Creates a new style element with the specified CSS and returns it.
*/
static createStyle(styles) {
return this.createElement("style", document.head, "", styles);
}
/**
* Utility function to save an object into a cookie.
*/
static saveToCookie(name, obj, expiresInSeconds) {
const expires = new Date(Date.now() + 1000 * expiresInSeconds).toUTCString();
document.cookie = name + "=" + encodeURIComponent(JSON.stringify(obj)) + ";path=/;expires=" + expires;
}
/**
* Utility function to load an object from a cookie.
*/
static loadFromCookie(name) {
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
}
/**
* Returns internal React state object of an element.
*/
static getReactProps(ele) {
const keys = Object.keys(ele || {});
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
if (key) {
// noinspection JSUnresolvedVariable
return ele[key].memoizedProps;
}
key = keys.find(key => key.startsWith("__reactProps$"));
return key ? ele[key] : null;
}
/**
* Returns internal React state object of an element, or null if the retrieval throws.
*/
static tryGetReactProps(ele) {
try {
return this.getReactProps(ele);
} catch (e) {
return null;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
const CONSTANTS = {
AUTOSCROLL_ACTION_NOTHING: "optNothing",
AUTOSCROLL_ACTION_PAUSE: "optPause",
AUTOSCROLL_ACTION_SWITCH: "optSwitch"
};
let IS_FIRST_RUN = false;
const SETTINGS = (function() {
const settingsChangedEvents = [];
const saveSettings = function() {
DOM.saveToCookie("DHT_SETTINGS", root, 60 * 60 * 24 * 365 * 5);
};
const triggerSettingsChanged = function(property) {
for (const callback of settingsChangedEvents) {
callback(property);
}
saveSettings();
};
const defineTriggeringProperty = function(obj, property, value) {
const name = "_" + property;
Object.defineProperty(obj, property, {
get: (() => obj[name]),
set: (value => {
obj[name] = value;
triggerSettingsChanged(property);
})
});
obj[name] = value;
};
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
if (!loaded) {
loaded = {
"_autoscroll": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
};
IS_FIRST_RUN = true;
}
const root = {
onSettingsChanged(callback) {
settingsChangedEvents.push(callback);
}
};
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
if (IS_FIRST_RUN) {
saveSettings();
}
return root;
})();
+284
View File
@@ -0,0 +1,284 @@
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(`
#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 &amp; Combine")}
${btn("settings", "Settings")}
${btn("track", "")}
${btn("download", "Download")}
${btn("reset", "Reset")}
<p id='dht-ctrl-status'></p>
<input id='dht-ctrl-upload-input' type='file' multiple>
${btn("close", "X")}`);
// elements
controller.ui = {
btnUpload: DOM.id("dht-ctrl-upload"),
btnSettings: DOM.id("dht-ctrl-settings"),
btnToggleTracking: DOM.id("dht-ctrl-track"),
btnDownload: DOM.id("dht-ctrl-download"),
btnReset: DOM.id("dht-ctrl-reset"),
btnClose: DOM.id("dht-ctrl-close"),
textStatus: DOM.id("dht-ctrl-status"),
inputUpload: DOM.id("dht-ctrl-upload-input")
};
// events
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(`
#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");
settings.overlay.addEventListener("click", () => {
root.hideSettings();
});
// main
var radio = (type, id, label) => "<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";
settings.ele = DOM.createElement("div", document.body, "dht-cfg", `
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Do Nothing")}
${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")}
<br>
<label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Do Nothing")}
${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")}
<p id='dht-cfg-note'>
It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.<br><br>
<sub>{{{version:full}}}</sub>
</p>`);
// elements
settings.ui = {
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
optsAfterFirstMsg: {},
optsAfterSavedMsg: {}
};
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-afm-pause");
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-afm-switch");
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-asm-nothing");
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-asm-pause");
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-asm-switch");
// events
settings.ui.cbAutoscroll.addEventListener("change", () => {
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
});
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
SETTINGS.afterFirstMsg = key;
});
});
Object.keys(settings.ui.optsAfterSavedMsg).forEach(key => {
settings.ui.optsAfterSavedMsg[key].addEventListener("click", () => {
SETTINGS.afterSavedMsg = key;
});
});
setupStateChanged("settings");
},
hideSettings: function(){
if (settings){
DOM.removeElement(settings.overlay);
DOM.removeElement(settings.ele);
DOM.removeElement(settings.styles);
settings = null;
}
},
setStatus: function(state){
console.log("Status: " + state)
// TODO I guess.
}
};
return root;
})();
+341
View File
@@ -0,0 +1,341 @@
/*
* SAVEFILE STRUCTURE
* ==================
*
* {
* meta: {
* users: {
* <discord user id>: {
* name: <user name>,
* avatar: <user icon>,
* tag: <user discriminator> // only present if not a bot
* }, ...
* },
*
* // the user index is an array of discord user ids,
* // these indexes are used in the message objects to save space
* userindex: [
* <discord user id>, ...
* ],
*
* servers: [
* {
* name: <server name>,
* type: <"SERVER"|"GROUP"|DM">
* }, ...
* ],
*
* channels: {
* <discord channel id>: {
* server: <server index in the meta.servers array>,
* name: <channel name>,
* position: <order in channel list>, // only present if server type == SERVER
* topic: <channel topic>, // only present if server type == SERVER
* nsfw: <channel NSFW status> // only present if server type == SERVER
* }, ...
* }
* },
*
* data: {
* <discord channel id>: {
* <discord message id>: {
* u: <user index of the sender>,
* t: <message timestamp>,
* m: <message content>, // only present if not empty
* f: <message flags>, // only present if edited in which case it equals 1, deprecated (use 'te' instead)
* te: <edit timestamp>, // only present if edited
* e: [ // omit for no embeds
* {
* url: <embed url>,
* type: <embed type>,
* t: <rich embed title>, // only present if type == rich, and if not empty
* d: <rich embed description> // only present if type == rich, and if the embed has a simple description text
* }, ...
* ],
* a: [ // omit for no attachments
* {
* url: <attachment url>
* }, ...
* ],
* r: <reply message id>, // only present if referencing another message (reply)
* re: [ // omit for no reactions
* {
* c: <react count>
* n: <emoji name>,
* id: <emoji id>, // only present for custom emoji
* an: <emoji is animated>, // only present for custom animated emoji
* }, ...
* ]
* }, ...
* }, ...
* }
* }
*
*
* TEMPORARY OBJECT STRUCTURE
* ==========================
*
* {
* userlookup: {
* <discord user id>: <user index in the meta.userindex array>
* },
* channelkeys: Set<channel id>,
* messagekeys: Set<message id>,
* }
*/
class SAVEFILE{
constructor(parsedObj){
var me = this;
if (!SAVEFILE.isValid(parsedObj)){
parsedObj = {
meta: {},
data: {}
};
}
me.meta = parsedObj.meta;
me.data = parsedObj.data;
me.meta.users = me.meta.users || {};
me.meta.userindex = me.meta.userindex || [];
me.meta.servers = me.meta.servers || [];
me.meta.channels = me.meta.channels || {};
me.tmp = {
userlookup: {},
channelkeys: new Set(),
messagekeys: new Set(),
};
}
static isValid(parsedObj){
return parsedObj && typeof parsedObj.meta === "object" && typeof parsedObj.data === "object";
}
findOrRegisterUser(userId, userName, userDiscriminator, userAvatar){
var wasPresent = userId in this.meta.users;
var userObj = wasPresent ? this.meta.users[userId] : {};
userObj.name = userName;
if (userDiscriminator){
userObj.tag = userDiscriminator;
}
if (userAvatar){
userObj.avatar = userAvatar;
}
if (!wasPresent){
this.meta.users[userId] = userObj;
this.meta.userindex.push(userId);
return this.tmp.userlookup[userId] = this.meta.userindex.length-1;
}
else if (!(userId in this.tmp.userlookup)){
return this.tmp.userlookup[userId] = this.meta.userindex.findIndex(id => id == userId);
}
else{
return this.tmp.userlookup[userId];
}
}
findOrRegisterServer(serverName, serverType){
var index = this.meta.servers.findIndex(server => server.name === serverName && server.type === serverType);
if (index === -1){
this.meta.servers.push({
"name": serverName,
"type": serverType
});
return this.meta.servers.length-1;
}
else{
return index;
}
}
tryRegisterChannel(serverIndex, channelId, channelName, extraInfo){
if (!this.meta.servers[serverIndex]){
return undefined;
}
var wasPresent = channelId in this.meta.channels;
var channelObj = wasPresent ? this.meta.channels[channelId] : { "server": serverIndex };
channelObj.name = channelName;
if (extraInfo.position){
channelObj.position = extraInfo.position;
}
if (extraInfo.topic){
channelObj.topic = extraInfo.topic;
}
if (extraInfo.nsfw){
channelObj.nsfw = extraInfo.nsfw;
}
if (wasPresent){
return false;
}
else{
this.meta.channels[channelId] = channelObj;
this.tmp.channelkeys.add(channelId);
return true;
}
}
addMessage(channelId, messageId, messageObject){
var container = this.data[channelId] || (this.data[channelId] = {});
var wasPresent = messageId in container;
container[messageId] = messageObject;
this.tmp.messagekeys.add(messageId);
return !wasPresent;
}
convertToMessageObject(discordMessage){
var author = discordMessage.author;
var obj = {
u: this.findOrRegisterUser(author.id, author.username, author.bot ? null : author.discriminator, author.avatar),
t: discordMessage.timestamp.toDate().getTime()
};
if (discordMessage.content.length > 0){
obj.m = discordMessage.content;
}
if (discordMessage.editedTimestamp !== null){
obj.te = discordMessage.editedTimestamp.toDate().getTime();
}
if (discordMessage.embeds.length > 0){
obj.e = discordMessage.embeds.map(embed => {
let conv = {
url: embed.url,
type: embed.type
};
if (embed.type === "rich"){
if (Array.isArray(embed.title) && embed.title.length === 1 && typeof embed.title[0] === "string"){
conv.t = embed.title[0];
if (Array.isArray(embed.description) && embed.description.length === 1 && typeof embed.description[0] === "string"){
conv.d = embed.description[0];
}
}
}
return conv;
});
}
if (discordMessage.attachments.length > 0){
obj.a = discordMessage.attachments.map(attachment => ({
url: attachment.url
}));
}
if (discordMessage.messageReference !== null){
obj.r = discordMessage.messageReference.message_id;
}
if (discordMessage.reactions.length > 0) {
obj.re = discordMessage.reactions.map(reaction => {
let conv = {
c: reaction.count,
n: reaction.emoji.name
};
if (reaction.emoji.id !== null) {
conv.id = reaction.emoji.id;
}
if (reaction.emoji.animated) {
conv.an = true;
}
return conv;
});
}
return obj;
}
addMessagesFromDiscord(discordMessageArray){
var hasNewMessages = false;
for(var discordMessage of discordMessageArray){
var type = discordMessage.type;
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
if ((type === 0 || type === 19) && discordMessage.state === "SENT" && this.addMessage(discordMessage.channel_id, discordMessage.id, this.convertToMessageObject(discordMessage))){
hasNewMessages = true;
}
}
return hasNewMessages;
}
countChannels(){
return this.tmp.channelkeys.size;
}
countMessages(){
return this.tmp.messagekeys.size;
}
combineWith(obj){
var userMap = {};
var shownError = false;
for(var userId in obj.meta.users){
var oldUser = obj.meta.users[userId];
userMap[obj.meta.userindex.findIndex(id => id == userId)] = this.findOrRegisterUser(userId, oldUser.name, oldUser.tag, oldUser.avatar);
}
for(var channelId in obj.meta.channels){
var oldServer = obj.meta.servers[obj.meta.channels[channelId].server];
var oldChannel = obj.meta.channels[channelId];
this.tryRegisterChannel(this.findOrRegisterServer(oldServer.name, oldServer.type), channelId, oldChannel.name, oldChannel /* filtered later */);
}
for(var channelId in obj.data){
var oldChannel = obj.data[channelId];
for(var messageId in oldChannel){
var oldMessage = oldChannel[messageId];
var oldUser = oldMessage.u;
if (oldUser in userMap){
oldMessage.u = userMap[oldUser];
this.addMessage(channelId, messageId, oldMessage);
}
else{
if (!shownError){
shownError = true;
alert("The uploaded archive appears to be corrupted, some messages will be skipped. See console for details.");
console.error("User list:", obj.meta.users);
console.error("User index:", obj.meta.userindex);
console.error("Generated mapping:", userMap);
console.error("Missing user for the following messages:");
}
console.error(oldMessage);
}
}
}
}
toJson(){
return JSON.stringify({
"meta": this.meta,
"data": this.data
});
}
}
+163
View File
@@ -0,0 +1,163 @@
// 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){
let serverName = serverInfo.name
let serverType = serverInfo.type
let channelId = channelInfo.id
let channelName = channelInfo.name
let extraInfo = channelInfo.extra
var serverIndex = this.getSavefile().findOrRegisterServer(serverName, serverType);
if (this.getSavefile().tryRegisterChannel(serverIndex, channelId, channelName, extraInfo) === true){
this._triggerStateChanged("data", "channel");
}
}
// Right. Upstream desktop `bootstrap.js` expects an `async` here. I think it's fine.
/*
* Adds all messages from the array to the specified channel. Returns true if the savefile was updated.
*/
addDiscordMessages(discordMessageArray){
if (this.getSavefile().addMessagesFromDiscord(discordMessageArray)){
this._triggerStateChanged("data", "messages");
return true;
}
else{
return false;
}
}
/*
* Adds a listener that is called whenever the state changes. The callback is a function that takes subject (generic type) and detail (specific type or data).
*/
onStateChanged(callback){
this._stateChangedEvents.push(callback);
}
/*
* Shim for code from the desktop app.
*/
onTrackingStateChanged(callback) {
this._trackingStateChangedListeners.push(callback);
callback(this._isTracking);
}
/*
* Shim for code from the desktop app.
*/
setup(port, token) {
console.log("Placeholder port and token: " + port + " " + token);
}
}
return new CLS();
})();
+161
View File
@@ -0,0 +1,161 @@
// NOTE: STOP! This file must be kept in sync with the upstream desktop app branch in order for future changes/fixes to the desktop script to be cleanly merged here.
// Changes for the browser version should be done in another file to maintain mergeablity.
(function() {
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]*/
const port = 0; /*[PORT]*/
const token = "/*[TOKEN]*/";
STATE.setup(port, token);
let delayedStopRequests = 0;
const stopTrackingDelayed = function(callback) {
delayedStopRequests++;
window.setTimeout(() => {
STATE.setIsTracking(false);
delayedStopRequests--;
if (callback) {
callback();
}
}, 200); // give the user visual feedback after clicking the button before switching off
};
let hasJustStarted = false;
let isSending = false;
const onError = function(e) {
console.log(e);
GUI.setStatus(e.status === "DISCONNECTED" ? "Disconnected" : "Error");
stopTrackingDelayed(() => isSending = false);
};
const isNoAction = function(action) {
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
};
const onTrackingContinued = function(anyNewMessages) {
if (!STATE.isTracking()) {
return;
}
GUI.setStatus("Tracking");
if (hasJustStarted) {
anyNewMessages = true;
hasJustStarted = false;
}
isSending = false;
if (SETTINGS.autoscroll) {
let action = null;
if (!DISCORD.hasMoreMessages()) {
action = SETTINGS.afterFirstMsg;
}
if (isNoAction(action) && !anyNewMessages) {
action = SETTINGS.afterSavedMsg;
}
if (isNoAction(action)) {
DISCORD.loadOlderMessages();
}
else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) {
GUI.setStatus("Reached End");
STATE.setIsTracking(false);
}
}
};
let waitUntilSendingFinishedTimer = null;
const onMessagesUpdated = async messages => {
if (!STATE.isTracking() || delayedStopRequests > 0) {
return;
}
if (isSending) {
window.clearTimeout(waitUntilSendingFinishedTimer);
waitUntilSendingFinishedTimer = window.setTimeout(() => {
waitUntilSendingFinishedTimer = null;
onMessagesUpdated(messages);
}, 100);
return;
}
const info = DISCORD.getSelectedChannel();
if (!info) {
GUI.setStatus("Error (Unknown Channel)");
stopTrackingDelayed();
return;
}
isSending = true;
try {
await STATE.addDiscordChannel(info.server, info.channel);
} catch (e) {
onError(e);
return;
}
try {
if (!messages.length) {
isSending = false;
onTrackingContinued(false);
}
else {
const anyNewMessages = await 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();
}
})();
/*[DEBUGGER]*/
-340
View File
@@ -1,340 +0,0 @@
var DISCORD = (function(){
var getMessageOuterElement = function(){
return DOM.queryReactClass("messagesWrapper");
};
var getMessageScrollerElement = function(){
return getMessageOuterElement().querySelector("[class*='scroller_']");
};
var getMessageElements = function() {
return getMessageOuterElement().querySelectorAll("[class*='message_']");
};
var getReactProps = function(ele) {
var keys = Object.keys(ele || {});
var key = keys.find(key => key.startsWith("__reactInternalInstance"));
if (key){
return ele[key].memoizedProps;
}
key = keys.find(key => key.startsWith("__reactProps$"));
return key ? ele[key] : null;
};
var tryGetReactProps = function(ele) {
try {
return this.getReactProps(ele);
} catch (ignore) {
return null;
}
};
var getMessageElementProps = function(ele) {
const props = getReactProps(ele);
if (props.children && props.children.length) {
for (let i = 3; i < props.children.length; i++) {
const childProps = props.children[i].props;
if (childProps && "message" in childProps && "channel" in childProps) {
return childProps;
}
}
}
return null;
};
var hasMoreMessages = function() {
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
};
var getMessages = function() {
try {
const messages = [];
for (const ele of getMessageElements()) {
try {
const props = getMessageElementProps(ele);
if (props != null) {
messages.push(props.message);
}
} catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, tryGetReactProps(ele));
}
}
return messages;
} catch (e) {
console.error("[DHT] Error retrieving messages.", e);
return [];
}
};
return {
/**
* Calls the provided function with a list of messages whenever the currently loaded messages change,
* or with `false` if there are no more messages.
*/
setupMessageCallback: function(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
let hasReachedStart = false;
const previousMessages = new Set();
const intervalId = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = getMessageScrollerElement();
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = getMessages();
let hasChanged = false;
for (const message of messages) {
if (!previousMessages.has(message.id)) {
hasChanged = true;
break;
}
}
if (!hasChanged) {
if (!hasReachedStart && !hasMoreMessages()) {
hasReachedStart = true;
callback(false);
}
return;
}
previousMessages.clear();
for (const message of messages) {
previousMessages.add(message.id);
}
hasReachedStart = false;
callback(messages);
}, 200);
window.DHT_ON_UNLOAD.push(() => window.clearInterval(intervalId));
},
/*
* Returns internal React state object of an element.
*/
getReactProps: function(ele){
return getReactProps(ele);
},
/*
* Returns an object containing the selected server name, selected channel name and ID, and the object type.
* For types DM and GROUP, the server and channel names are identical.
* For SERVER type, the channel has to be in view, otherwise Discord unloads it.
*/
getSelectedChannel: function() {
try {
let obj;
for (const ele of getMessageElements()) {
const props = getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
if (!obj) {
return null;
}
var dms = DOM.queryReactClass("privateChannels");
if (dms){
let name;
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) {
name = node.nodeValue;
break;
}
}
if (!name) {
return null;
}
let type;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) {
case 1: type = "DM"; break;
case 3: type = "GROUP"; break;
default: return null;
}
return {
"server": name,
"channel": name,
"id": obj.id,
"type": type,
"extra": {}
};
}
else if (obj.guild_id) {
let guild;
for (const child of getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
if (child && child.props && child.props.guild) {
guild = child.props.guild;
break;
}
}
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
return null;
}
return {
"server": guild.name,
"channel": obj.name,
"id": obj.id,
"type": "SERVER",
"extra": {
"position": obj.position,
"topic": obj.topic,
"nsfw": obj.nsfw
}
};
}
else {
return null;
}
} catch(e) {
console.error(e);
return null;
}
},
/*
* Returns an array containing currently loaded messages.
*/
getMessages: function(){
return getMessages();
},
/*
* Returns true if the message view is visible.
*/
isInMessageView: () => !!getMessageOuterElement(),
/*
* Returns true if there are more messages available or if they're still loading.
*/
hasMoreMessages: function(){
return hasMoreMessages();
},
/*
* Forces the message view to load older messages by scrolling all the way up.
*/
loadOlderMessages: function(){
let view = getMessageScrollerElement();
if (view.scrollTop > 0){
view.scrollTop = 0;
}
},
/*
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
*/
selectNextTextChannel: function() {
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
const currentChannel = DOM.queryReactClass("selected", dms);
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
return false;
}
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannelLink.scrollIntoView(true);
return true;
}
else {
const channelListEle = document.getElementById("channels");
if (!channelListEle) {
return false;
}
function getLinkElement(channel) {
return channel.querySelector("a[href^='/channels/'][role='link']");
}
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
let nextChannel = null;
for (let index = 0; index < allTextChannels.length - 1; index++) {
if (allTextChannels[index].className.includes("selected_")) {
nextChannel = allTextChannels[index + 1];
break;
}
}
if (nextChannel === null) {
return false;
}
const nextChannelLink = getLinkElement(nextChannel);
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannel.scrollIntoView(true);
return true;
}
}
};
})();
-85
View File
@@ -1,85 +0,0 @@
var DOM = (function(){
var createElement = (tag, parent, id, html) => {
var ele = document.createElement(tag);
ele.id = id || "";
ele.innerHTML = html || "";
parent.appendChild(ele);
return ele;
};
return {
/*
* Returns a child element by its ID. Parent defaults to the entire document.
*/
id: (id, parent) => (parent || document).getElementById(id),
/*
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
*/
queryReactClass: (cls, parent) => (parent || document).querySelector(`[class*="${cls}_"]`),
/*
* Creates an element, adds it to the DOM, and returns it.
*/
createElement: (tag, parent, id, html) => createElement(tag, parent, id, html),
/*
* Removes an element from the DOM.
*/
removeElement: (ele) => ele.parentNode.removeChild(ele),
/*
* Creates a new style element with the specified CSS and returns it.
*/
createStyle: (styles) => createElement("style", document.head, "", styles),
/*
* Convenience setTimeout function to save space after minification.
*/
setTimer: (callback, timeout) => window.setTimeout(callback, timeout),
/*
* Convenience addEventListener function to save space after minification.
*/
listen: (ele, event, callback) => ele.addEventListener(event, callback),
/*
* Utility function to save an object into a cookie.
*/
saveToCookie: (name, obj, expiresInSeconds) => {
var expires = new Date(Date.now()+1000*expiresInSeconds).toUTCString();
document.cookie = name+"="+encodeURIComponent(JSON.stringify(obj))+";path=/;expires="+expires;
},
/*
* Utility function to load an object from a cookie.
*/
loadFromCookie: (name) => {
var value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)"+name+"\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
},
/*
* Triggers a UTF-8 text file download.
*/
downloadTextFile: (fileName, fileContents) => {
var blob = new Blob([fileContents], { "type": "octet/stream" });
if ("msSaveBlob" in window.navigator){
return window.navigator.msSaveBlob(blob, fileName);
}
var url = window.URL.createObjectURL(blob);
var ele = createElement("a", document.body);
ele.href = url;
ele.download = fileName;
ele.style.display = "none";
ele.click();
document.body.removeChild(ele);
window.URL.revokeObjectURL(url);
}
};
})();
-277
View File
@@ -1,277 +0,0 @@
var GUI = (function(){
var controller;
var settings;
var updateButtonState = () => {
if (STATE.isTracking()){
controller.ui.btnUpload.disabled = true;
controller.ui.btnSettings.disabled = true;
controller.ui.btnReset.disabled = true;
}
else{
controller.ui.btnUpload.disabled = false;
controller.ui.btnSettings.disabled = false;
controller.ui.btnDownload.disabled = controller.ui.btnReset.disabled = !STATE.hasSavedData();
}
};
var stateChangedEvent = (type, detail) => {
if (controller){
var force = type === "gui" && detail === "controller";
if (type === "data" || force){
updateButtonState();
}
if (type === "tracking" || force){
updateButtonState();
controller.ui.btnToggleTracking.innerHTML = STATE.isTracking() ? "Pause Tracking" : "Start Tracking";
}
if (type === "data" || force){
var messageCount = 0;
var channelCount = 0;
if (STATE.hasSavedData()){
messageCount = STATE.getSavefile().countMessages();
channelCount = STATE.getSavefile().countChannels();
}
controller.ui.textStatus.innerHTML = [
messageCount, " message", (messageCount === 1 ? "" : "s"),
" from ",
channelCount, " channel", (channelCount === 1 ? "" : "s")
].join("");
}
}
if (settings){
var force = type === "gui" && detail === "settings";
if (force){
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
}
if (type === "setting" || force){
var autoscrollRev = !SETTINGS.autoscroll;
// discord polyfills Object.values
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollRev);
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollRev);
}
}
};
var registeredEvent = false;
var setupStateChanged = function(detail){
if (!registeredEvent){
STATE.onStateChanged(stateChangedEvent);
SETTINGS.onSettingsChanged(stateChangedEvent);
registeredEvent = true;
}
stateChangedEvent("gui", detail);
};
var root = {
showController: function(){
controller = {};
// styles
controller.styles = DOM.createStyle(`
#app-mount { height: calc(100% - 48px) !important; }
#dht-ctrl { position: absolute; bottom: 0; width: 100%; height: 48px; background-color: #FFF; z-index: 1000000; }
#dht-ctrl button { height: 32px; margin: 8px 0 8px 8px; font-size: 16px; padding: 0 12px; background-color: #7289DA; color: #FFF; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75); }
#dht-ctrl button:disabled { background-color: #7A7A7A; cursor: default; }
#dht-ctrl-close { margin: 8px 8px 8px 0 !important; float: right; }
#dht-ctrl p { display: inline-block; margin: 14px 12px; }
#dht-ctrl input { display: none; }`);
// main
var btn = (id, title) => "<button id='dht-ctrl-"+id+"'>"+title+"</button>";
controller.ele = DOM.createElement("div", document.body, "dht-ctrl", `
${btn("upload", "Upload &amp; Combine")}
${btn("settings", "Settings")}
${btn("track", "")}
${btn("download", "Download")}
${btn("reset", "Reset")}
<p id='dht-ctrl-status'></p>
<input id='dht-ctrl-upload-input' type='file' multiple>
${btn("close", "X")}`);
// elements
controller.ui = {
btnUpload: DOM.id("dht-ctrl-upload"),
btnSettings: DOM.id("dht-ctrl-settings"),
btnToggleTracking: DOM.id("dht-ctrl-track"),
btnDownload: DOM.id("dht-ctrl-download"),
btnReset: DOM.id("dht-ctrl-reset"),
btnClose: DOM.id("dht-ctrl-close"),
textStatus: DOM.id("dht-ctrl-status"),
inputUpload: DOM.id("dht-ctrl-upload-input")
};
// events
DOM.listen(controller.ui.btnUpload, "click", () => {
controller.ui.inputUpload.click();
});
DOM.listen(controller.ui.btnSettings, "click", () => {
root.showSettings();
});
DOM.listen(controller.ui.btnToggleTracking, "click", () => {
STATE.setIsTracking(!STATE.isTracking());
});
DOM.listen(controller.ui.btnDownload, "click", () => {
STATE.downloadSavefile();
});
DOM.listen(controller.ui.btnReset, "click", () => {
STATE.resetState();
});
DOM.listen(controller.ui.btnClose, "click", () => {
root.hideController();
window.DHT_ON_UNLOAD.forEach(f => f());
window.DHT_LOADED = false;
});
DOM.listen(controller.ui.inputUpload, "change", () => {
Array.prototype.forEach.call(controller.ui.inputUpload.files, file => {
var reader = new FileReader();
reader.onload = function(){
var obj = {};
try{
obj = JSON.parse(reader.result);
}catch(e){
alert("Could not parse '"+file.name+"', see console for details.");
console.error(e);
return;
}
if (SAVEFILE.isValid(obj)){
STATE.uploadSavefile(file.name, new SAVEFILE(obj));
}
else{
alert("File '"+file.name+"' has an invalid format.");
}
};
reader.readAsText(file, "UTF-8");
});
controller.ui.inputUpload.value = null;
});
setupStateChanged("controller");
},
hideController: function(){
if (controller){
DOM.removeElement(controller.ele);
DOM.removeElement(controller.styles);
controller = null;
}
},
showSettings: function(){
settings = {};
// styles
settings.styles = DOM.createStyle(`
#dht-cfg-overlay { position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-color: #000; opacity: 0.5; display: block; z-index: 1000001; }
#dht-cfg { position: absolute; left: 50%; top: 50%; width: 800px; height: 262px; margin-left: -400px; margin-top: -131px; padding: 8px; background-color: #fff; z-index: 1000002; }
#dht-cfg-note { margin-top: 22px; }
#dht-cfg sub { color: #666; font-size: 13px; }`);
// overlay
settings.overlay = DOM.createElement("div", document.body, "dht-cfg-overlay");
DOM.listen(settings.overlay, "click", () => {
root.hideSettings();
});
// main
var radio = (type, id, label) => "<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";
settings.ele = DOM.createElement("div", document.body, "dht-cfg", `
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Do Nothing")}
${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")}
<br>
<label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Do Nothing")}
${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")}
<p id='dht-cfg-note'>
It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.<br><br>
<sub>{{{version:full}}}</sub>
</p>`);
// elements
settings.ui = {
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
optsAfterFirstMsg: {},
optsAfterSavedMsg: {}
};
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-afm-pause");
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-afm-switch");
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-asm-nothing");
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE] = DOM.id("dht-cfg-asm-pause");
settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH] = DOM.id("dht-cfg-asm-switch");
// events
settings.ui.cbAutoscroll.addEventListener("change", () => {
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
});
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
DOM.listen(settings.ui.optsAfterFirstMsg[key], "click", () => {
SETTINGS.afterFirstMsg = key;
});
});
Object.keys(settings.ui.optsAfterSavedMsg).forEach(key => {
DOM.listen(settings.ui.optsAfterSavedMsg[key], "click", () => {
SETTINGS.afterSavedMsg = key;
});
});
setupStateChanged("settings");
},
hideSettings: function(){
if (settings){
DOM.removeElement(settings.overlay);
DOM.removeElement(settings.ele);
DOM.removeElement(settings.styles);
settings = null;
}
}
};
return root;
})();
-349
View File
@@ -1,349 +0,0 @@
/*
* SAVEFILE STRUCTURE
* ==================
*
* {
* meta: {
* users: {
* <discord user id>: {
* name: <user name>,
* avatar: <user icon>,
* tag: <user discriminator> // only present if not a bot
* }, ...
* },
*
* // the user index is an array of discord user ids,
* // these indexes are used in the message objects to save space
* userindex: [
* <discord user id>, ...
* ],
*
* servers: [
* {
* name: <server name>,
* type: <"SERVER"|"GROUP"|DM">
* }, ...
* ],
*
* channels: {
* <discord channel id>: {
* server: <server index in the meta.servers array>,
* name: <channel name>,
* position: <order in channel list>, // only present if server type == SERVER
* topic: <channel topic>, // only present if server type == SERVER
* nsfw: <channel NSFW status> // only present if server type == SERVER
* }, ...
* }
* },
*
* data: {
* <discord channel id>: {
* <discord message id>: {
* u: <user index of the sender>,
* t: <message timestamp>,
* m: <message content>, // only present if not empty
* f: <message flags>, // only present if edited in which case it equals 1, deprecated (use 'te' instead)
* te: <edit timestamp>, // only present if edited
* e: [ // omit for no embeds
* {
* url: <embed url>,
* type: <embed type>,
* t: <rich embed title>, // only present if type == rich, and if not empty
* d: <rich embed description> // only present if type == rich, and if the embed has a simple description text
* }, ...
* ],
* a: [ // omit for no attachments
* {
* url: <attachment url>
* }, ...
* ],
* r: <reply message id>, // only present if referencing another message (reply)
* re: [ // omit for no reactions
* {
* c: <react count>
* n: <emoji name>,
* id: <emoji id>, // only present for custom emoji
* an: <emoji is animated>, // only present for custom animated emoji
* }, ...
* ]
* }, ...
* }, ...
* }
* }
*
*
* TEMPORARY OBJECT STRUCTURE
* ==========================
*
* {
* userlookup: {
* <discord user id>: <user index in the meta.userindex array>
* },
* channelkeys: Set<channel id>,
* messagekeys: Set<message id>,
* freshmsgs: Set<message id> // only messages which were newly added to the savefile in the current session
* }
*/
class SAVEFILE{
constructor(parsedObj){
var me = this;
if (!SAVEFILE.isValid(parsedObj)){
parsedObj = {
meta: {},
data: {}
};
}
me.meta = parsedObj.meta;
me.data = parsedObj.data;
me.meta.users = me.meta.users || {};
me.meta.userindex = me.meta.userindex || [];
me.meta.servers = me.meta.servers || [];
me.meta.channels = me.meta.channels || {};
me.tmp = {
userlookup: {},
channelkeys: new Set(),
messagekeys: new Set(),
freshmsgs: new Set()
};
}
static isValid(parsedObj){
return parsedObj && typeof parsedObj.meta === "object" && typeof parsedObj.data === "object";
}
findOrRegisterUser(userId, userName, userDiscriminator, userAvatar){
var wasPresent = userId in this.meta.users;
var userObj = wasPresent ? this.meta.users[userId] : {};
userObj.name = userName;
if (userDiscriminator){
userObj.tag = userDiscriminator;
}
if (userAvatar){
userObj.avatar = userAvatar;
}
if (!wasPresent){
this.meta.users[userId] = userObj;
this.meta.userindex.push(userId);
return this.tmp.userlookup[userId] = this.meta.userindex.length-1;
}
else if (!(userId in this.tmp.userlookup)){
return this.tmp.userlookup[userId] = this.meta.userindex.findIndex(id => id == userId);
}
else{
return this.tmp.userlookup[userId];
}
}
findOrRegisterServer(serverName, serverType){
var index = this.meta.servers.findIndex(server => server.name === serverName && server.type === serverType);
if (index === -1){
this.meta.servers.push({
"name": serverName,
"type": serverType
});
return this.meta.servers.length-1;
}
else{
return index;
}
}
tryRegisterChannel(serverIndex, channelId, channelName, extraInfo){
if (!this.meta.servers[serverIndex]){
return undefined;
}
var wasPresent = channelId in this.meta.channels;
var channelObj = wasPresent ? this.meta.channels[channelId] : { "server": serverIndex };
channelObj.name = channelName;
if (extraInfo.position){
channelObj.position = extraInfo.position;
}
if (extraInfo.topic){
channelObj.topic = extraInfo.topic;
}
if (extraInfo.nsfw){
channelObj.nsfw = extraInfo.nsfw;
}
if (wasPresent){
return false;
}
else{
this.meta.channels[channelId] = channelObj;
this.tmp.channelkeys.add(channelId);
return true;
}
}
addMessage(channelId, messageId, messageObject){
var container = this.data[channelId] || (this.data[channelId] = {});
var wasPresent = messageId in container;
container[messageId] = messageObject;
this.tmp.messagekeys.add(messageId);
return !wasPresent;
}
convertToMessageObject(discordMessage){
var author = discordMessage.author;
var obj = {
u: this.findOrRegisterUser(author.id, author.username, author.bot ? null : author.discriminator, author.avatar),
t: discordMessage.timestamp.toDate().getTime()
};
if (discordMessage.content.length > 0){
obj.m = discordMessage.content;
}
if (discordMessage.editedTimestamp !== null){
obj.te = discordMessage.editedTimestamp.toDate().getTime();
}
if (discordMessage.embeds.length > 0){
obj.e = discordMessage.embeds.map(embed => {
let conv = {
url: embed.url,
type: embed.type
};
if (embed.type === "rich"){
if (Array.isArray(embed.title) && embed.title.length === 1 && typeof embed.title[0] === "string"){
conv.t = embed.title[0];
if (Array.isArray(embed.description) && embed.description.length === 1 && typeof embed.description[0] === "string"){
conv.d = embed.description[0];
}
}
}
return conv;
});
}
if (discordMessage.attachments.length > 0){
obj.a = discordMessage.attachments.map(attachment => ({
url: attachment.url
}));
}
if (discordMessage.messageReference !== null){
obj.r = discordMessage.messageReference.message_id;
}
if (discordMessage.reactions.length > 0) {
obj.re = discordMessage.reactions.map(reaction => {
let conv = {
c: reaction.count,
n: reaction.emoji.name
};
if (reaction.emoji.id !== null) {
conv.id = reaction.emoji.id;
}
if (reaction.emoji.animated) {
conv.an = true;
}
return conv;
});
}
return obj;
}
isMessageFresh(id){
return this.tmp.freshmsgs.has(id);
}
addMessagesFromDiscord(channelId, discordMessageArray){
var hasNewMessages = false;
for(var discordMessage of discordMessageArray){
var type = discordMessage.type;
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
if ((type === 0 || type === 19) && discordMessage.state === "SENT" && this.addMessage(channelId, discordMessage.id, this.convertToMessageObject(discordMessage))){
this.tmp.freshmsgs.add(discordMessage.id);
hasNewMessages = true;
}
}
return hasNewMessages;
}
countChannels(){
return this.tmp.channelkeys.size;
}
countMessages(){
return this.tmp.messagekeys.size;
}
combineWith(obj){
var userMap = {};
var shownError = false;
for(var userId in obj.meta.users){
var oldUser = obj.meta.users[userId];
userMap[obj.meta.userindex.findIndex(id => id == userId)] = this.findOrRegisterUser(userId, oldUser.name, oldUser.tag, oldUser.avatar);
}
for(var channelId in obj.meta.channels){
var oldServer = obj.meta.servers[obj.meta.channels[channelId].server];
var oldChannel = obj.meta.channels[channelId];
this.tryRegisterChannel(this.findOrRegisterServer(oldServer.name, oldServer.type), channelId, oldChannel.name, oldChannel /* filtered later */);
}
for(var channelId in obj.data){
var oldChannel = obj.data[channelId];
for(var messageId in oldChannel){
var oldMessage = oldChannel[messageId];
var oldUser = oldMessage.u;
if (oldUser in userMap){
oldMessage.u = userMap[oldUser];
this.addMessage(channelId, messageId, oldMessage);
}
else{
if (!shownError){
shownError = true;
alert("The uploaded archive appears to be corrupted, some messages will be skipped. See console for details.");
console.error("User list:", obj.meta.users);
console.error("User index:", obj.meta.userindex);
console.error("Generated mapping:", userMap);
console.error("Missing user for the following messages:");
}
console.error(oldMessage);
}
}
}
}
toJson(){
return JSON.stringify({
"meta": this.meta,
"data": this.data
});
}
}
-64
View File
@@ -1,64 +0,0 @@
var CONSTANTS = {
AUTOSCROLL_ACTION_NOTHING: "optNothing",
AUTOSCROLL_ACTION_PAUSE: "optPause",
AUTOSCROLL_ACTION_SWITCH: "optSwitch"
};
var IS_FIRST_RUN = false;
var SETTINGS = (function(){
var root = {};
var settingsChangedEvents = [];
var saveSettings = function(){
DOM.saveToCookie("DHT_SETTINGS", root, 60*60*24*365*5);
};
var triggerSettingsChanged = function(changeType, changeDetail){
for(var callback of settingsChangedEvents){
callback(changeType, changeDetail);
}
saveSettings();
};
var defineTriggeringProperty = function(obj, property, value){
var name = "_"+property;
Object.defineProperty(obj, property, {
get: (() => obj[name]),
set: (value => {
obj[name] = value;
triggerSettingsChanged("setting", property);
})
});
obj[name] = value;
};
var loaded = DOM.loadFromCookie("DHT_SETTINGS");
if (!loaded){
loaded = {
"_autoscroll": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
};
IS_FIRST_RUN = true;
}
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
root.onSettingsChanged = function(callback){
settingsChangedEvents.push(callback);
};
if (IS_FIRST_RUN){
saveSettings();
}
return root;
})();
-119
View File
@@ -1,119 +0,0 @@
var STATE = (function(){
var stateChangedEvents = [];
var triggerStateChanged = function(changeType, changeDetail){
for(var callback of stateChangedEvents){
callback(changeType, changeDetail);
}
};
/*
* Internal class constructor.
*/
class CLS{
constructor(){
this.resetState();
};
/*
* Resets the state to default values.
*/
resetState(){
this._savefile = null;
this._isTracking = false;
this._lastFileName = null;
triggerStateChanged("data", "reset");
}
/*
* Returns the savefile object, creates a new one if needed.
*/
getSavefile(){
if (!this._savefile){
this._savefile = new SAVEFILE();
}
return this._savefile;
}
/*
* Returns true if the database file contains any data.
*/
hasSavedData(){
return this._savefile != null;
}
/*
* Returns true if currently tracking message.
*/
isTracking(){
return this._isTracking;
}
/*
* Sets the tracking state.
*/
setIsTracking(state){
this._isTracking = state;
triggerStateChanged("tracking", state);
}
/*
* Combines current savefile with the provided one.
*/
uploadSavefile(fileName, fileObject){
this._lastFileName = fileName;
this.getSavefile().combineWith(fileObject);
triggerStateChanged("data", "upload");
}
/*
* Triggers a savefile download, if available.
*/
downloadSavefile(){
if (this.hasSavedData()){
DOM.downloadTextFile(this._lastFileName || "dht.txt", this._savefile.toJson());
}
}
/*
* Registers a Discord server and channel.
*/
addDiscordChannel(serverName, serverType, channelId, channelName, extraInfo){
var serverIndex = this.getSavefile().findOrRegisterServer(serverName, serverType);
if (this.getSavefile().tryRegisterChannel(serverIndex, channelId, channelName, extraInfo) === true){
triggerStateChanged("data", "channel");
}
}
/*
* Adds all messages from the array to the specified channel. Returns true if the savefile was updated.
*/
addDiscordMessages(channelId, discordMessageArray){
if (this.getSavefile().addMessagesFromDiscord(channelId, discordMessageArray)){
triggerStateChanged("data", "messages");
return true;
}
else{
return false;
}
}
/*
* Returns true if the message was added during this session.
*/
isMessageFresh(id){
return this.getSavefile().isMessageFresh(id);
}
/*
* Adds a listener that is called whenever the state changes. The callback is a function that takes subject (generic type) and detail (specific type or data).
*/
onStateChanged(callback){
stateChangedEvents.push(callback);
}
}
return new CLS();
})();
-136
View File
@@ -1,136 +0,0 @@
const url = window.location.href;
if (!url.includes("discord.com/") && !url.includes("discordapp.com/") && !confirm("Could not detect Discord in the URL, do you want to run the script anyway?")){
return;
}
if (window.DHT_LOADED){
alert("Discord History Tracker is already loaded.");
return;
}
window.DHT_LOADED = true;
window.DHT_ON_UNLOAD = [];
// Execution
let ignoreMessageCallback = new Set();
let frozenMessageLoadingTimer = null;
let stopTrackingDelayed = function(callback){
ignoreMessageCallback.add("stopping");
DOM.setTimer(() => {
STATE.setIsTracking(false);
ignoreMessageCallback.delete("stopping");
if (callback){
callback();
}
}, 200); // give the user visual feedback after clicking the button before switching off
};
DISCORD.setupMessageCallback(messages => {
if (STATE.isTracking() && ignoreMessageCallback.size === 0){
let info = DISCORD.getSelectedChannel();
if (!info){
stopTrackingDelayed();
return;
}
STATE.addDiscordChannel(info.server, info.type, info.id, info.channel, info.extra);
if (messages !== false && !messages.length){
DISCORD.loadOlderMessages();
return;
}
let hasUpdatedFile = messages !== false && STATE.addDiscordMessages(info.id, messages);
if (SETTINGS.autoscroll){
let action = null;
if (messages === false) {
action = SETTINGS.afterFirstMsg;
}
else if (!hasUpdatedFile && !STATE.isMessageFresh(messages[0].id)){
action = SETTINGS.afterSavedMsg;
}
if (action === null){
if (hasUpdatedFile){
DISCORD.loadOlderMessages();
window.clearTimeout(frozenMessageLoadingTimer);
frozenMessageLoadingTimer = null;
}
else{
frozenMessageLoadingTimer = window.setTimeout(DISCORD.loadOlderMessages, 2500);
}
}
else{
ignoreMessageCallback.add("stalling");
DOM.setTimer(() => {
ignoreMessageCallback.delete("stalling");
let updatedInfo = DISCORD.getSelectedChannel();
if (updatedInfo && updatedInfo.id === info.id){
let lastMessages = DISCORD.getMessages(); // sometimes needed to catch the last few messages before switching
if (lastMessages != null){
STATE.addDiscordMessages(info.id, lastMessages);
}
}
if ((action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel()) || action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE){
STATE.setIsTracking(false);
}
}, 250);
}
}
}
});
STATE.onStateChanged((type, enabled) => {
if (type === "tracking" && enabled){
let info = DISCORD.getSelectedChannel();
if (info){
let messages = DISCORD.getMessages();
if (messages != null){
STATE.addDiscordChannel(info.server, info.type, info.id, info.channel, info.extra);
STATE.addDiscordMessages(info.id, messages);
}
else{
stopTrackingDelayed(() => alert("Cannot see any messages."));
return;
}
}
else{
stopTrackingDelayed(() => alert("The selected channel is not visible in the channel list."));
return;
}
if (SETTINGS.autoscroll && DISCORD.isInMessageView()){
if (DISCORD.hasMoreMessages()){
DISCORD.loadOlderMessages();
}
else{
let action = SETTINGS.afterFirstMsg;
if ((action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel()) || action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE){
stopTrackingDelayed();
}
}
}
}
});
GUI.showController();
if (IS_FIRST_RUN){
GUI.showSettings();
}