// ==UserScript==
// @name Discord History Tracker
// @version v.31h
// @license MIT
// @namespace https://chylex.com
// @homepageURL https://dht.chylex.com/
// @supportURL https://github.com/chylex/Discord-History-Tracker/issues
// @include https://discord.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
const start = function(){
// 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 = [];
// 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;
}
}
}
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;
}
}
}
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 {
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;
}
`);
// main
var btn = (id, title) => "";
controller.ele = DOM.createElement("div", document.body, "dht-ctrl", `
${btn("upload", "Upload & Combine")}
${btn("settings", "Settings")}
${btn("track", "")}
${btn("download", "Download")}
${btn("reset", "Reset")}
${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) => "
";
settings.ele = DOM.createElement("div", document.body, "dht-cfg", `
${radio("afm", "nothing", "Continue Tracking")}
${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")}
${radio("asm", "nothing", "Continue Tracking")}
${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")}
It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.
v.31h, released 03 March 2024
`);
// 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;
})();
/*
* SAVEFILE STRUCTURE
* ==================
*
* {
* meta: {
* users: {
* : {
* name: ,
* avatar: ,
* tag: // 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: [
* , ...
* ],
*
* servers: [
* {
* name: ,
* type: <"SERVER"|"GROUP"|DM">
* }, ...
* ],
*
* channels: {
* : {
* server: ,
* name: ,
* position: , // only present if server type == SERVER
* topic: , // only present if server type == SERVER
* nsfw: // only present if server type == SERVER
* }, ...
* }
* },
*
* data: {
* : {
* : {
* u: ,
* t: ,
* m: , // only present if not empty
* f: , // only present if edited in which case it equals 1, deprecated (use 'te' instead)
* te: , // only present if edited
* e: [ // omit for no embeds
* {
* url: