mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2024-11-25 05:42:45 +01:00
Compare commits
6 Commits
86dd64119d
...
50d2288203
Author | SHA1 | Date | |
---|---|---|---|
50d2288203 | |||
c3d4fa5532 | |||
a6225b9721 | |||
943163473a | |||
fa00df10d8 | |||
f54465e5fe |
@ -249,11 +249,8 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||||
|
|
||||||
await foreach (var user in state.Db.Users.Get()) {
|
await foreach (var user in state.Db.Users.Get()) {
|
||||||
var name = user.Name;
|
|
||||||
var discriminator = user.Discriminator;
|
|
||||||
|
|
||||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -73,6 +73,7 @@ sealed class DebugPageModel {
|
|||||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("u"),
|
Name = RandomName("u"),
|
||||||
|
DisplayName = RandomName("u"),
|
||||||
AvatarUrl = null,
|
AvatarUrl = null,
|
||||||
Discriminator = rand.Next(0, 9999).ToString(),
|
Discriminator = rand.Next(0, 9999).ToString(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
@ -9,6 +9,8 @@ items:
|
|||||||
pattern: "^[0-9]+$"
|
pattern: "^[0-9]+$"
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
avatar:
|
avatar:
|
||||||
type: string
|
type: string
|
||||||
discriminator:
|
discriminator:
|
||||||
|
47
app/Resources/Tracker/bootstrap.js
vendored
47
app/Resources/Tracker/bootstrap.js
vendored
@ -10,11 +10,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*[IMPORTS]*/
|
||||||
|
|
||||||
|
if (!DISCORD.isCompatible()) {
|
||||||
|
alert("Discord History Tracker is not compatible with this version of Discord.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.DHT_LOADED = true;
|
window.DHT_LOADED = true;
|
||||||
window.DHT_ON_UNLOAD = [];
|
window.DHT_ON_UNLOAD = [];
|
||||||
|
|
||||||
/*[IMPORTS]*/
|
|
||||||
|
|
||||||
const port = 0; /*[PORT]*/
|
const port = 0; /*[PORT]*/
|
||||||
const token = "/*[TOKEN]*/";
|
const token = "/*[TOKEN]*/";
|
||||||
STATE.setup(port, token);
|
STATE.setup(port, token);
|
||||||
@ -46,7 +51,7 @@
|
|||||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTrackingContinued = function(anyNewMessages) {
|
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
|
||||||
if (!STATE.isTracking()) {
|
if (!STATE.isTracking()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -63,7 +68,7 @@
|
|||||||
if (SETTINGS.autoscroll) {
|
if (SETTINGS.autoscroll) {
|
||||||
let action = null;
|
let action = null;
|
||||||
|
|
||||||
if (!DISCORD.hasMoreMessages()) {
|
if (!hasMoreBefore) {
|
||||||
console.debug("[DHT] Reached first message.");
|
console.debug("[DHT] Reached first message.");
|
||||||
action = SETTINGS.afterFirstMsg;
|
action = SETTINGS.afterFirstMsg;
|
||||||
}
|
}
|
||||||
@ -84,7 +89,7 @@
|
|||||||
|
|
||||||
let waitUntilSendingFinishedTimer = null;
|
let waitUntilSendingFinishedTimer = null;
|
||||||
|
|
||||||
const onMessagesUpdated = async messages => {
|
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
|
||||||
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -94,24 +99,16 @@
|
|||||||
|
|
||||||
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||||
waitUntilSendingFinishedTimer = null;
|
waitUntilSendingFinishedTimer = null;
|
||||||
onMessagesUpdated(messages);
|
onMessagesUpdated(server, channel, messages, hasMoreBefore);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
GUI.setStatus("Error (Unknown Channel)");
|
|
||||||
stopTrackingDelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSending = true;
|
isSending = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await STATE.addDiscordChannel(info.server, info.channel);
|
await STATE.addDiscordChannel(server, channel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
return;
|
return;
|
||||||
@ -120,32 +117,28 @@
|
|||||||
try {
|
try {
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
onTrackingContinued(false);
|
onTrackingContinued(false, hasMoreBefore);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||||
onTrackingContinued(anyNewMessages);
|
onTrackingContinued(anyNewMessages, hasMoreBefore);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||||
|
|
||||||
STATE.onTrackingStateChanged(enabled => {
|
STATE.onTrackingStateChanged(enabled => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const messages = DISCORD.getMessages();
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GUI.setStatus("Starting");
|
GUI.setStatus("Starting");
|
||||||
hasJustStarted = true;
|
hasJustStarted = true;
|
||||||
// noinspection JSIgnoredPromiseFromCall
|
|
||||||
onMessagesUpdated(messages);
|
if (!starter()) {
|
||||||
|
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||||
|
hasJustStarted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
|
@ -4,11 +4,26 @@ class DISCORD {
|
|||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
static CHANNEL_TYPE = {
|
static CHANNEL_TYPE = {
|
||||||
|
GUILD_TEXT: 0,
|
||||||
DM: 1,
|
DM: 1,
|
||||||
GROUP_DM: 3,
|
GROUP_DM: 3,
|
||||||
|
GUILD_ANNOUNCEMENT: 5,
|
||||||
ANNOUNCEMENT_THREAD: 10,
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
PUBLIC_THREAD: 11,
|
PUBLIC_THREAD: 11,
|
||||||
PRIVATE_THREAD: 12
|
PRIVATE_THREAD: 12,
|
||||||
|
|
||||||
|
isPrivate(type) {
|
||||||
|
return type === this.DM
|
||||||
|
|| type === this.GROUP_DM;
|
||||||
|
},
|
||||||
|
|
||||||
|
isNavigableGuildChannel(type) {
|
||||||
|
return type === this.GUILD_TEXT
|
||||||
|
|| type === this.GUILD_ANNOUNCEMENT
|
||||||
|
|| type === this.ANNOUNCEMENT_THREAD
|
||||||
|
|| type === this.PUBLIC_THREAD
|
||||||
|
|| type === this.PRIVATE_THREAD;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
@ -18,6 +33,74 @@ class DISCORD {
|
|||||||
THREAD_STARTER: 21
|
THREAD_STARTER: 21
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||||
|
static PERMISSION = {
|
||||||
|
VIEW_CHANNEL: 1n << 10n
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordGuild} getGuild
|
||||||
|
*/
|
||||||
|
static #guildStore = WEBPACK.findModule(WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): Boolean} isOptInEnabled
|
||||||
|
* @property {function(String): Set<String>} getOptedInChannels
|
||||||
|
*/
|
||||||
|
static #guildSettings = WEBPACK.findModule(WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordChannel} getChannel
|
||||||
|
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
|
||||||
|
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
|
||||||
|
*/
|
||||||
|
static #channelStore = WEBPACK.findModule(WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(BigInt, Object): Boolean}
|
||||||
|
*/
|
||||||
|
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): MessageData}
|
||||||
|
*/
|
||||||
|
static #getMessages = WEBPACK.findFunction("getMessages");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(): String}
|
||||||
|
*/
|
||||||
|
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String, String, String=null): void}
|
||||||
|
*/
|
||||||
|
static #transitionToGuild = WEBPACK.findFunction("transitionToGuild", [ "transitionTo" ]);
|
||||||
|
|
||||||
|
static isCompatible() {
|
||||||
|
return !!this.#guildStore
|
||||||
|
&& !!this.#guildSettings
|
||||||
|
&& !!this.#channelStore
|
||||||
|
&& !!this.#hasPermission
|
||||||
|
&& !!this.#getMessages
|
||||||
|
&& !!this.#jumpToMessage
|
||||||
|
&& !!this.#getCurrentlySelectedChannelId
|
||||||
|
&& !!this.#selectPrivateChannel
|
||||||
|
&& !!this.#transitionToGuild;
|
||||||
|
}
|
||||||
|
|
||||||
static getMessageOuterElement() {
|
static getMessageOuterElement() {
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
}
|
}
|
||||||
@ -26,14 +109,6 @@ class DISCORD {
|
|||||||
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
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() {
|
static loadOlderMessages() {
|
||||||
const view = this.getMessageScrollerElement();
|
const view = this.getMessageScrollerElement();
|
||||||
|
|
||||||
@ -48,20 +123,36 @@ class DISCORD {
|
|||||||
static setupMessageCallback(callback) {
|
static setupMessageCallback(callback) {
|
||||||
const previousMessages = new Set();
|
const previousMessages = new Set();
|
||||||
|
|
||||||
const onMessageElementsChanged = function() {
|
const onMessageElementsChanged = force => {
|
||||||
const messages = DISCORD.getMessages();
|
const channelId = this.#getCurrentlySelectedChannelId();
|
||||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
if (!channelId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = this.#getMessages(channelId);
|
||||||
|
if (!messages || !messages.ready || messages.loadingMore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = this.#channelStore.getChannel(channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
|
||||||
if (!hasChanged) {
|
if (!hasChanged) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
previousMessages.clear();
|
previousMessages.clear();
|
||||||
for (const message of messages) {
|
for (const message of messages._array) {
|
||||||
previousMessages.add(message.id);
|
previousMessages.add(message.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(messages);
|
const server = this.#guildStore.getGuild(channel.guild_id);
|
||||||
|
|
||||||
|
callback(server, channel, messages._array, messages.hasMoreBefore);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
let debounceTimer;
|
let debounceTimer;
|
||||||
@ -74,7 +165,7 @@ class DISCORD {
|
|||||||
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver(function () {
|
const observer = new MutationObserver(function() {
|
||||||
onMessageElementsChangedLater();
|
onMessageElementsChangedLater();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,216 +203,55 @@ class DISCORD {
|
|||||||
observedElement = null;
|
observedElement = null;
|
||||||
window.clearInterval(observerTimer);
|
window.clearInterval(observerTimer);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return () => onMessageElementsChanged(true);
|
||||||
* 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.
|
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||||
*/
|
*/
|
||||||
static selectNextTextChannel() {
|
static selectNextTextChannel() {
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
|
||||||
|
if (!currentChannel) {
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
|
||||||
if (!nextChannelLink) {
|
const privateChannel = this.#channelStore.getSortedPrivateChannels();
|
||||||
|
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextChannelLink.click();
|
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
|
||||||
nextChannelLink.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const channelListEle = document.getElementById("channels");
|
const guildId = currentChannel.guild_id;
|
||||||
if (!channelListEle) {
|
|
||||||
|
let isChannelOptedIn;
|
||||||
|
if (this.#guildSettings.isOptInEnabled(guildId)) {
|
||||||
|
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
|
||||||
|
isChannelOptedIn = channel => optedInChannels.has(channel.id);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isChannelOptedIn = _ => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
|
||||||
|
const guildChannels = Object.values(guildChannelMap)
|
||||||
|
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
debugger;
|
||||||
|
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkElement(channel) {
|
this.#transitionToGuild(guildId, guildChannels[currentIndex + 1].id);
|
||||||
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,31 +55,4 @@ class DOM {
|
|||||||
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
||||||
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -58,64 +58,39 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordChannel} channel
|
||||||
|
*/
|
||||||
|
const getPrivateChannelName = function(channel) {
|
||||||
|
if (channel.name === "") {
|
||||||
|
return channel.rawRecipients.map(user => user.username).join(", ");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return channel.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Number} type
|
||||||
|
*/
|
||||||
|
const getChannelTypeName = function(type) {
|
||||||
|
if (type === DISCORD.CHANNEL_TYPE.DM) {
|
||||||
|
return "DM";
|
||||||
|
}
|
||||||
|
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
|
||||||
|
return "GROUP";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "SERVER";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackingStateChangedListeners = [];
|
const trackingStateChangedListeners = [];
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
const addedChannels = new Set();
|
const addedChannels = new Set();
|
||||||
const addedUsers = new Set();
|
const addedUsers = new Set();
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordUser
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} username
|
|
||||||
* @property {String} discriminator
|
|
||||||
* @property {String} [avatar]
|
|
||||||
* @property {Boolean} [bot]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessage
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} channel_id
|
|
||||||
* @property {DiscordUser} author
|
|
||||||
* @property {String} content
|
|
||||||
* @property {Date} timestamp
|
|
||||||
* @property {Date|null} editedTimestamp
|
|
||||||
* @property {DiscordAttachment[]} attachments
|
|
||||||
* @property {Object[]} embeds
|
|
||||||
* @property {DiscordMessageReaction[]} [reactions]
|
|
||||||
* @property {DiscordMessageReference} [messageReference]
|
|
||||||
* @property {Number} type
|
|
||||||
* @property {String} state
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordAttachment
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} filename
|
|
||||||
* @property {String} [content_type]
|
|
||||||
* @property {String} size
|
|
||||||
* @property {String} url
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessageReaction
|
|
||||||
* @property {DiscordEmoji} emoji
|
|
||||||
* @property {Number} count
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessageReference
|
|
||||||
* @property {String} [message_id]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordEmoji
|
|
||||||
* @property {String|null} id
|
|
||||||
* @property {String|null} name
|
|
||||||
* @property {Boolean} animated
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup(port, token) {
|
setup(port, token) {
|
||||||
serverPort = port;
|
serverPort = port;
|
||||||
@ -146,32 +121,51 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {?DiscordGuild} serverInfo
|
||||||
|
* @param {DiscordChannel} channelInfo
|
||||||
|
*/
|
||||||
async addDiscordChannel(serverInfo, channelInfo) {
|
async addDiscordChannel(serverInfo, channelInfo) {
|
||||||
if (addedChannels.has(channelInfo.id)) {
|
if (addedChannels.has(channelInfo.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = {
|
const server = {
|
||||||
id: serverInfo.id,
|
type: getChannelTypeName(channelInfo.type)
|
||||||
name: serverInfo.name,
|
|
||||||
type: serverInfo.type
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const channel = {
|
const channel = {
|
||||||
id: channelInfo.id,
|
id: channelInfo.id,
|
||||||
name: channelInfo.name
|
extra: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("extra" in channelInfo) {
|
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
|
||||||
const extra = channelInfo.extra;
|
server.id = channelInfo.id;
|
||||||
|
server.name = channel.name = getPrivateChannelName(channelInfo);
|
||||||
if ("parent" in extra) {
|
}
|
||||||
channel.parent = extra.parent;
|
else if (serverInfo) {
|
||||||
|
server.id = serverInfo.id;
|
||||||
|
server.name = serverInfo.name;
|
||||||
|
channel.name = channelInfo.name;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.position = extra.position;
|
if ("nsfw" in channelInfo) {
|
||||||
channel.topic = extra.topic;
|
channel.extra.nsfw = channelInfo.nsfw;
|
||||||
channel.nsfw = extra.nsfw;
|
}
|
||||||
|
|
||||||
|
if ("topic" in channelInfo) {
|
||||||
|
channel.extra.topic = channelInfo.topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("position" in channelInfo) {
|
||||||
|
channel.extra.position = channelInfo.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||||
|
channel.extra.parent = channelInfo.parent_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await post("/track-channel", { server, channel });
|
await post("/track-channel", { server, channel });
|
||||||
@ -200,6 +194,10 @@ const STATE = (function() {
|
|||||||
name: user.username
|
name: user.username
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (user.globalName) {
|
||||||
|
obj.displayName = user.globalName;
|
||||||
|
}
|
||||||
|
|
||||||
if (user.avatar) {
|
if (user.avatar) {
|
||||||
obj.avatar = user.avatar;
|
obj.avatar = user.avatar;
|
||||||
}
|
}
|
||||||
|
81
app/Resources/Tracker/scripts/types.js
Normal file
81
app/Resources/Tracker/scripts/types.js
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @name DiscordGuild
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} name
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordChannel
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} name
|
||||||
|
* @property {Number} type
|
||||||
|
* @property {String} [guild_id]
|
||||||
|
* @property {String} [parent_id]
|
||||||
|
* @property {Number} [position]
|
||||||
|
* @property {String} [topic]
|
||||||
|
* @property {Boolean} [nsfw]
|
||||||
|
* @property {DiscordUser[]} [rawRecipients]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordUser
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} username
|
||||||
|
* @property {String} discriminator
|
||||||
|
* @property {String} [globalName]
|
||||||
|
* @property {String} [avatar]
|
||||||
|
* @property {Boolean} [bot]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessage
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} channel_id
|
||||||
|
* @property {DiscordUser} author
|
||||||
|
* @property {String} content
|
||||||
|
* @property {Date} timestamp
|
||||||
|
* @property {Date|null} editedTimestamp
|
||||||
|
* @property {DiscordAttachment[]} attachments
|
||||||
|
* @property {Object[]} embeds
|
||||||
|
* @property {DiscordMessageReaction[]} [reactions]
|
||||||
|
* @property {DiscordMessageReference} [messageReference]
|
||||||
|
* @property {Number} type
|
||||||
|
* @property {String} state
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordAttachment
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} filename
|
||||||
|
* @property {String} [content_type]
|
||||||
|
* @property {String} size
|
||||||
|
* @property {String} url
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessageReaction
|
||||||
|
* @property {DiscordEmoji} emoji
|
||||||
|
* @property {Number} count
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessageReference
|
||||||
|
* @property {String} [message_id]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordEmoji
|
||||||
|
* @property {String|null} id
|
||||||
|
* @property {String|null} name
|
||||||
|
* @property {Boolean} animated
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name MessageData
|
||||||
|
* @type {Object}
|
||||||
|
* @property {Boolean} ready
|
||||||
|
* @property {Boolean} loadingMore
|
||||||
|
* @property {Boolean} hasMoreAfter
|
||||||
|
* @property {Boolean} hasMoreBefore
|
||||||
|
* @property {Array<DiscordMessage>} _array
|
||||||
|
*/
|
93
app/Resources/Tracker/scripts/webpack.js
Normal file
93
app/Resources/Tracker/scripts/webpack.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Parts copied from Better Discord, licensed under Apache License 2.0.
|
||||||
|
*
|
||||||
|
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
|
||||||
|
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
|
||||||
|
*/
|
||||||
|
class WEBPACK {
|
||||||
|
static get require() {
|
||||||
|
if (this._require) {
|
||||||
|
return this._require;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {Object} m
|
||||||
|
* @property {Object} c
|
||||||
|
*/
|
||||||
|
let hookedRequire;
|
||||||
|
|
||||||
|
const id = "dht-webpackmodules-" + new Date().getTime();
|
||||||
|
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
|
||||||
|
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete hookedRequire.m[id];
|
||||||
|
delete hookedRequire.c[id];
|
||||||
|
return this._require = hookedRequire;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAllModules() {
|
||||||
|
return this.require.c;
|
||||||
|
}
|
||||||
|
|
||||||
|
static filterByProps(...props) {
|
||||||
|
return module => props.every(prop => prop in module);
|
||||||
|
}
|
||||||
|
|
||||||
|
static filterByPropsWithPredicate(predicate, ...props) {
|
||||||
|
return module => props.every(prop => prop in module && predicate(module[prop]));
|
||||||
|
}
|
||||||
|
|
||||||
|
static findModules(filter) {
|
||||||
|
const defaultExport = true;
|
||||||
|
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const module of Object.values(this.getAllModules())) {
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property [Z]
|
||||||
|
* @property [ZP]
|
||||||
|
* @property [__esModule]
|
||||||
|
* @property [default]
|
||||||
|
*/
|
||||||
|
const exports = module.exports;
|
||||||
|
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundModule = null;
|
||||||
|
if (exports.Z && moduleFilter(exports.Z)) {
|
||||||
|
foundModule = defaultExport ? exports.Z : exports;
|
||||||
|
}
|
||||||
|
if (exports.ZP && moduleFilter(exports.ZP)) {
|
||||||
|
foundModule = defaultExport ? exports.ZP : exports;
|
||||||
|
}
|
||||||
|
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
|
||||||
|
foundModule = defaultExport ? exports.default : exports;
|
||||||
|
}
|
||||||
|
if (moduleFilter(exports)) {
|
||||||
|
foundModule = exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundModule) {
|
||||||
|
results.push(foundModule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findModule(filter) {
|
||||||
|
const modules = this.findModules(filter);
|
||||||
|
return modules.length === 1 ? modules[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findFunction(name, additionalRequiredProps) {
|
||||||
|
const searchedProps = additionalRequiredProps ? [name, ...additionalRequiredProps] : [name];
|
||||||
|
const matchingModule = this.findModule(this.filterByPropsWithPredicate(prop => typeof(prop) === "function", ...searchedProps));
|
||||||
|
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,8 @@
|
|||||||
<div id="menu">
|
<div id="menu">
|
||||||
<button id="btn-settings">Settings</button>
|
<button id="btn-settings">Settings</button>
|
||||||
|
|
||||||
|
<div class="splitter"></div>
|
||||||
|
|
||||||
<div> <!-- needed to stop the select from messing up -->
|
<div> <!-- needed to stop the select from messing up -->
|
||||||
<select id="opt-messages-per-page">
|
<select id="opt-messages-per-page">
|
||||||
<option value="50">50 messages per page </option>
|
<option value="50">50 messages per page </option>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import discord from "./discord.mjs";
|
import discord from "./discord.mjs";
|
||||||
import gui from "./gui.mjs";
|
import gui from "./gui.mjs";
|
||||||
import state from "./state.mjs";
|
import state from "./state.mjs";
|
||||||
|
import "./polyfills.mjs";
|
||||||
|
|
||||||
window.DISCORD = discord;
|
window.DISCORD = discord;
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ export default (function() {
|
|||||||
processed = processed
|
processed = processed
|
||||||
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
|
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
|
||||||
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>")
|
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>")
|
||||||
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (state.getUserTag(match) || "????") + "'>@" + state.getUserName(match) + "</span>")
|
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='" + state.getUserName(match) + "'>@" + state.getUserDisplayName(match) + "</span>")
|
||||||
.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
|
.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
|
||||||
.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
|
.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ export default (function() {
|
|||||||
templateMessageNoAvatar = new template([
|
templateMessageNoAvatar = new template([
|
||||||
"<div>",
|
"<div>",
|
||||||
"<div class='reply-message'>{reply}</div>",
|
"<div class='reply-message'>{reply}</div>",
|
||||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||||
"{reactions}",
|
"{reactions}",
|
||||||
"</div>"
|
"</div>"
|
||||||
@ -141,7 +141,7 @@ export default (function() {
|
|||||||
"<div class='avatar-wrapper'>",
|
"<div class='avatar-wrapper'>",
|
||||||
"<div class='avatar'>{avatar}</div>",
|
"<div class='avatar'>{avatar}</div>",
|
||||||
"<div>",
|
"<div>",
|
||||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||||
"{reactions}",
|
"{reactions}",
|
||||||
"</div>",
|
"</div>",
|
||||||
@ -227,8 +227,8 @@ export default (function() {
|
|||||||
if (property === "avatar") {
|
if (property === "avatar") {
|
||||||
return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
|
return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
|
||||||
}
|
}
|
||||||
else if (property === "user.tag") {
|
else if (property === "user.displayName") {
|
||||||
return value ? value : "????";
|
return value ? value : message.user.name;
|
||||||
}
|
}
|
||||||
else if (property === "timestamp") {
|
else if (property === "timestamp") {
|
||||||
return dom.getHumanReadableTime(value);
|
return dom.getHumanReadableTime(value);
|
||||||
@ -292,7 +292,7 @@ export default (function() {
|
|||||||
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
|
const user = "<span class='reply-username' title='" + value.user.name + "'>" + (value.user.displayName ?? value.user.name) + "</span>";
|
||||||
const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
|
const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
|
||||||
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
|
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
|
||||||
|
|
||||||
|
@ -243,10 +243,11 @@ export default (function() {
|
|||||||
|
|
||||||
const options = [];
|
const options = [];
|
||||||
|
|
||||||
for (const key of Object.keys(users)) {
|
for (const id of Object.keys(users)) {
|
||||||
|
const user = users[id];
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = key;
|
option.value = id;
|
||||||
option.text = users[key].name;
|
option.text = user.displayName ? `${user.displayName} (${user.name})` : user.name;
|
||||||
options.push(option);
|
options.push(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
35
app/Resources/Viewer/scripts/polyfills.mjs
Normal file
35
app/Resources/Viewer/scripts/polyfills.mjs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e/088f061ab79b296f29225467ae9ba86ff990195d
|
||||||
|
|
||||||
|
ReadableStream.prototype.values ??= function({ preventCancel = false } = {}) {
|
||||||
|
const reader = this.getReader();
|
||||||
|
return {
|
||||||
|
async next() {
|
||||||
|
try {
|
||||||
|
const result = await reader.read();
|
||||||
|
if (result.done) {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
reader.releaseLock();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async return(value) {
|
||||||
|
if (!preventCancel) {
|
||||||
|
const cancelPromise = reader.cancel(value);
|
||||||
|
reader.releaseLock();
|
||||||
|
await cancelPromise;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
return { done: true, value };
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;
|
@ -316,16 +316,16 @@ export default (function() {
|
|||||||
return (channelObj && channelObj.name) || channel;
|
return (channelObj && channelObj.name) || channel;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserTag(user) {
|
|
||||||
const userObj = loadedFileMeta.users[user];
|
|
||||||
return (userObj && userObj.tag) || "????";
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserName(user) {
|
getUserName(user) {
|
||||||
const userObj = loadedFileMeta.users[user];
|
const userObj = loadedFileMeta.users[user];
|
||||||
return (userObj && userObj.name) || user;
|
return (userObj && userObj.name) || user;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUserDisplayName(user) {
|
||||||
|
const userObj = loadedFileMeta.users[user];
|
||||||
|
return (userObj && (userObj.displayName || userObj.name)) || user;
|
||||||
|
},
|
||||||
|
|
||||||
selectChannel(channel) {
|
selectChannel(channel) {
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
selectedChannel = channel;
|
selectedChannel = channel;
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
#menu {
|
#menu {
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
background-color: #17181c;
|
background-color: #17181c;
|
||||||
border-bottom: 1px dotted #5d626b;
|
border-bottom: 1px dotted #5d626b;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .splitter {
|
#menu .splitter {
|
||||||
width: 1px;
|
flex: 0 0 1px;
|
||||||
margin: 9px 4px;
|
margin: 9px 1px;
|
||||||
background-color: #5d626b;
|
background-color: #5d626b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +24,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu button, #menu select, #menu input[type="text"] {
|
#menu button, #menu select, #menu input[type="text"] {
|
||||||
margin: 8px;
|
height: 31px;
|
||||||
|
padding: 0 10px;
|
||||||
background-color: #7289da;
|
background-color: #7289da;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
||||||
@ -31,28 +33,25 @@
|
|||||||
|
|
||||||
#menu button {
|
#menu button {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
padding: 0 12px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu select {
|
#menu select {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 6px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu input[type="text"] {
|
#menu input[type="text"] {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 7px 12px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav {
|
#menu .nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin: 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav > button {
|
#menu .nav > button {
|
||||||
@ -66,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav > button, #menu .nav > p {
|
#menu .nav > button, #menu .nav > p {
|
||||||
margin: 8px 1px;
|
margin: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#opt-filter-list > select, #opt-filter-list > input {
|
#opt-filter-list > select, #opt-filter-list > input {
|
||||||
@ -76,3 +75,7 @@
|
|||||||
#opt-filter-list > .active {
|
#opt-filter-list > .active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-about {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@ -3,6 +3,7 @@ namespace DHT.Server.Data;
|
|||||||
public readonly struct User {
|
public readonly struct User {
|
||||||
public ulong Id { get; init; }
|
public ulong Id { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
public string? AvatarUrl { get; init; }
|
public string? AvatarUrl { get; init; }
|
||||||
public string? Discriminator { get; init; }
|
public string? Discriminator { get; init; }
|
||||||
}
|
}
|
||||||
|
@ -14,10 +14,10 @@ static class ViewerJson {
|
|||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Avatar { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Tag { get; init; }
|
public string? Avatar { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonServer {
|
public sealed class JsonServer {
|
||||||
|
@ -68,8 +68,8 @@ static class ViewerJsonExport {
|
|||||||
await foreach (var user in db.Users.Get(cancellationToken)) {
|
await foreach (var user in db.Users.Get(cancellationToken)) {
|
||||||
users[user.Id] = new ViewerJson.JsonUser {
|
users[user.Id] = new ViewerJson.JsonUser {
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
Avatar = user.AvatarUrl,
|
Avatar = user.AvatarUrl,
|
||||||
Tag = user.Discriminator
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
|
|||||||
await using var cmd = conn.Upsert("users", [
|
await using var cmd = conn.Upsert("users", [
|
||||||
("id", SqliteType.Integer),
|
("id", SqliteType.Integer),
|
||||||
("name", SqliteType.Text),
|
("name", SqliteType.Text),
|
||||||
|
("display_name", SqliteType.Text),
|
||||||
("avatar_url", SqliteType.Text),
|
("avatar_url", SqliteType.Text),
|
||||||
("discriminator", SqliteType.Text)
|
("discriminator", SqliteType.Text)
|
||||||
]);
|
]);
|
||||||
@ -38,6 +39,7 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
|
|||||||
foreach (var user in users) {
|
foreach (var user in users) {
|
||||||
cmd.Set(":id", user.Id);
|
cmd.Set(":id", user.Id);
|
||||||
cmd.Set(":name", user.Name);
|
cmd.Set(":name", user.Name);
|
||||||
|
cmd.Set(":display_name", user.DisplayName);
|
||||||
cmd.Set(":avatar_url", user.AvatarUrl);
|
cmd.Set(":avatar_url", user.AvatarUrl);
|
||||||
cmd.Set(":discriminator", user.Discriminator);
|
cmd.Set(":discriminator", user.Discriminator);
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
@ -62,15 +64,16 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
|
|||||||
public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users");
|
await using var cmd = conn.Command("SELECT id, name, display_name, avatar_url, discriminator FROM users");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken)) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new User {
|
yield return new User {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
Name = reader.GetString(1),
|
Name = reader.GetString(1),
|
||||||
AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2),
|
DisplayName = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3),
|
AvatarUrl = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
Discriminator = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo8.cs
Normal file
11
app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo8.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite.Schema;
|
||||||
|
|
||||||
|
sealed class SqliteSchemaUpgradeTo8 : ISchemaUpgrade {
|
||||||
|
async Task ISchemaUpgrade.Run(ISqliteConnection conn, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE users ADD display_name TEXT");
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,7 @@ using DHT.Utils.Logging;
|
|||||||
namespace DHT.Server.Database.Sqlite;
|
namespace DHT.Server.Database.Sqlite;
|
||||||
|
|
||||||
sealed class SqliteSchema {
|
sealed class SqliteSchema {
|
||||||
internal const int Version = 7;
|
internal const int Version = 8;
|
||||||
|
|
||||||
private static readonly Log Log = Log.ForType<SqliteSchema>();
|
private static readonly Log Log = Log.ForType<SqliteSchema>();
|
||||||
|
|
||||||
@ -48,6 +48,7 @@ sealed class SqliteSchema {
|
|||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
discriminator TEXT
|
discriminator TEXT
|
||||||
)
|
)
|
||||||
@ -171,6 +172,7 @@ sealed class SqliteSchema {
|
|||||||
{ 4, new SqliteSchemaUpgradeTo5() },
|
{ 4, new SqliteSchemaUpgradeTo5() },
|
||||||
{ 5, new SqliteSchemaUpgradeTo6() },
|
{ 5, new SqliteSchemaUpgradeTo6() },
|
||||||
{ 6, new SqliteSchemaUpgradeTo7() },
|
{ 6, new SqliteSchemaUpgradeTo7() },
|
||||||
|
{ 7, new SqliteSchemaUpgradeTo8() },
|
||||||
};
|
};
|
||||||
|
|
||||||
var perf = Log.Start("from version " + dbVersion);
|
var perf = Log.Start("from version " + dbVersion);
|
||||||
|
@ -15,7 +15,7 @@ sealed class GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parame
|
|||||||
string bootstrap = await resources.ReadTextAsync("Tracker/bootstrap.js");
|
string bootstrap = await resources.ReadTextAsync("Tracker/bootstrap.js");
|
||||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + parameters.Port + ";")
|
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + parameters.Port + ";")
|
||||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(parameters.Token))
|
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(parameters.Token))
|
||||||
.Replace("/*[IMPORTS]*/", await resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
.Replace("/*[IMPORTS]*/", await resources.ReadJoinedAsync("Tracker/scripts/", '\n', [ "/webpack.js" ]))
|
||||||
.Replace("/*[CSS-CONTROLLER]*/", await resources.ReadTextAsync("Tracker/styles/controller.css"))
|
.Replace("/*[CSS-CONTROLLER]*/", await resources.ReadTextAsync("Tracker/styles/controller.css"))
|
||||||
.Replace("/*[CSS-SETTINGS]*/", await resources.ReadTextAsync("Tracker/styles/settings.css"))
|
.Replace("/*[CSS-SETTINGS]*/", await resources.ReadTextAsync("Tracker/styles/settings.css"))
|
||||||
.Replace("/*[DEBUGGER]*/", request.Query.ContainsKey("debug") ? "debugger;" : "");
|
.Replace("/*[DEBUGGER]*/", request.Query.ContainsKey("debug") ? "debugger;" : "");
|
||||||
|
@ -30,6 +30,7 @@ sealed class TrackUsersEndpoint(IDatabaseFile db) : BaseEndpoint(db) {
|
|||||||
private static User ReadUser(JsonElement json, string path) => new () {
|
private static User ReadUser(JsonElement json, string path) => new () {
|
||||||
Id = json.RequireSnowflake("id", path),
|
Id = json.RequireSnowflake("id", path),
|
||||||
Name = json.RequireString("name", path),
|
Name = json.RequireString("name", path),
|
||||||
|
DisplayName = json.HasKey("displayName") ? json.RequireString("displayName", path) : null,
|
||||||
AvatarUrl = json.HasKey("avatar") ? json.RequireString("avatar", path) : null,
|
AvatarUrl = json.HasKey("avatar") ? json.RequireString("avatar", path) : null,
|
||||||
Discriminator = json.HasKey("discriminator") ? json.RequireString("discriminator", path) : null
|
Discriminator = json.HasKey("discriminator") ? json.RequireString("discriminator", path) : null
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@ -43,15 +45,27 @@ public sealed class ResourceLoader(Assembly assembly) {
|
|||||||
return TryGetEmbeddedStream(filename) is {} stream ? await ReadBytesAsync(stream) : null;
|
return TryGetEmbeddedStream(filename) is {} stream ? await ReadBytesAsync(stream) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> ReadJoinedAsync(string path, char separator) {
|
public async Task<string> ReadJoinedAsync(string path, char separator, string[] order) {
|
||||||
StringBuilder joined = new ();
|
List<(string, Stream)> resourceNames = [];
|
||||||
|
|
||||||
foreach (var embeddedName in assembly.GetManifestResourceNames()) {
|
foreach (var embeddedName in assembly.GetManifestResourceNames()) {
|
||||||
if (embeddedName.Replace('\\', '/').StartsWith(path)) {
|
var embeddedNameNormalized = embeddedName.Replace('\\', '/');
|
||||||
joined.Append(await ReadTextAsync(assembly.GetManifestResourceStream(embeddedName)!)).Append(separator);
|
if (embeddedNameNormalized.StartsWith(path)) {
|
||||||
|
resourceNames.Add((embeddedNameNormalized, assembly.GetManifestResourceStream(embeddedName)!));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StringBuilder joined = new ();
|
||||||
|
|
||||||
|
int GetOrderKey(string name) {
|
||||||
|
int key = Array.FindIndex(order, name.EndsWith);
|
||||||
|
return key == -1 ? order.Length : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach(var (_, stream) in resourceNames.OrderBy(item => GetOrderKey(item.Item1))) {
|
||||||
|
joined.Append(await ReadTextAsync(stream)).Append(separator);
|
||||||
|
}
|
||||||
|
|
||||||
return joined.ToString(0, Math.Max(0, joined.Length - 1));
|
return joined.ToString(0, Math.Max(0, joined.Length - 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,5 +8,5 @@ using DHT.Utils;
|
|||||||
namespace DHT.Utils;
|
namespace DHT.Utils;
|
||||||
|
|
||||||
static class Version {
|
static class Version {
|
||||||
public const string Tag = "42.0.0.0";
|
public const string Tag = "42.1.0.0";
|
||||||
}
|
}
|
||||||
|
BIN
app/empty.dht
BIN
app/empty.dht
Binary file not shown.
Loading…
Reference in New Issue
Block a user