Partially fix autocomplete and use patches

This commit is contained in:
Jiiks 2018-03-14 00:34:03 +02:00
parent 7599271f31
commit ae6b745e68
9 changed files with 320 additions and 113 deletions

View File

@ -19,12 +19,3 @@
}
}
</script>
<style>
.bd-emotewrapper {
position: relative;
display: inline-block;
}
.bd-emotewrapper img {
max-height: 32px;
}
</style>

View File

@ -8,84 +8,98 @@
* LICENSE file in the root directory of this source tree.
*/
import { FileUtils } from 'common';
import { Events, Globals } from 'modules';
import { Events, Globals, WebpackModules, ReactComponents } from 'modules';
import { DOM, VueInjector } from 'ui';
import EmoteComponent from './EmoteComponent.vue';
let emotes = null;
const emotesEnabled = true;
export default class {
static get React() {
return WebpackModules.getModuleByName('React');
}
static processMarkup(markup) {
if (!emotesEnabled) return markup; // TODO Get it from setttings
const newMarkup = [];
for (const [ti, t] of markup.entries()) {
if ('string' !== typeof t) {
newMarkup.push(t);
continue;
}
const words = t.split(/([^\s]+)([\s]|$)/g);
if (!words) continue;
let text = null;
for (const [wi, word] of words.entries()) {
let isEmote = false;
if (this.testWord(word)) {
isEmote = true;
}
if (isEmote) {
if (text !== null) {
newMarkup.push(text);
text = null;
}
newMarkup.push(this.React.createElement('span', { className: 'bd-emote-outer' }, word));
continue;
}
if (text === null) {
text = `${word}`;
} else {
text += `${word}`;
}
if (wi === words.length - 1) {
newMarkup.push(text);
}
}
}
return newMarkup;
}
static testWord(word) {
if (!/:[\w]+:/gmi.test(word)) return false;
return true;
}
static injectAll() {
if (!emotesEnabled) return;
const all = document.getElementsByClassName('bd-emote-outer');
for (const ec of all) {
if (ec.children.length) continue;
this.injectEmote(ec);
}
}
static async observe() {
const dataPath = Globals.getObject('paths').find(path => path.id === 'data').path;
try {
emotes = await FileUtils.readJsonFromFile(dataPath + '/emotes.json');
Events.on('ui:mutable:.markup',
markup => {
if (!emotes) return;
this.injectEmotes(markup);
});
const Message = await ReactComponents.getComponent('Message');
Message.on('componentDidMount', ({ element }) => this.injectEmotes(element));
Message.on('componentDidUpdate', ({ state, element }) => {
if (!state.isEditing) this.injectEmotes(element);
});
} catch (err) {
console.log(err);
}
}
static injectEmotes(node) {
if (!/:[\w]+:/gmi.test(node.textContent)) return node;
const childNodes = [...node.childNodes];
const newNode = document.createElement('div');
newNode.className = node.className;
newNode.classList.add('hasEmotes');
static injectEmote(e) {
if (!emotesEnabled) return;
const isEmote = this.isEmote(e.textContent);
if (!isEmote) return;
VueInjector.inject(
e,
DOM.createElement('span'),
{ EmoteComponent },
`<EmoteComponent src="${isEmote.src}" name="${isEmote.name}"/>`
);
e.classList.add('bd-is-emote');
}
for (const [cni, cn] of childNodes.entries()) {
if (cn.nodeType !== Node.TEXT_NODE) {
newNode.appendChild(cn);
continue;
}
const { nodeValue } = cn;
const words = nodeValue.split(/([^\s]+)([\s]|$)/g);
if (!words.some(word => word.startsWith(':') && word.endsWith(':'))) {
newNode.appendChild(cn);
continue;
}
let text = null;
for (const [wi, word] of words.entries()) {
let isEmote = null;
if (word.startsWith(':') && word.endsWith(':')) {
isEmote = this.isEmote(word);
}
if (isEmote) {
if (text !== null) {
newNode.appendChild(document.createTextNode(text));
text = null;
}
const emoteRoot = document.createElement('span');
newNode.appendChild(emoteRoot);
VueInjector.inject(
emoteRoot,
DOM.createElement('span'),
{ EmoteComponent },
`<EmoteComponent src="${isEmote.src}" name="${isEmote.name}"/>`,
true
);
continue;
}
if (text === null) {
text = word;
} else {
text += word;
}
if (wi === words.length - 1) {
newNode.appendChild(document.createTextNode(text));
}
}
}
node.replaceWith(newNode);
static injectEmotes(element) {
if (!emotesEnabled || !element) return;
for (const beo of element.getElementsByClassName('bd-emote-outer')) this.injectEmote(beo);
}
static isEmote(word) {

View File

@ -10,7 +10,7 @@
import { DOM, BdUI, Modals, Reflection } from 'ui';
import BdCss from './styles/index.scss';
import { Patcher, Events, CssEditor, Globals, ExtModuleManager, PluginManager, ThemeManager, ModuleManager, WebpackModules, Settings, Database, ReactComponents, DiscordApi } from 'modules';
import { Patcher, Events, CssEditor, Globals, ExtModuleManager, PluginManager, ThemeManager, ModuleManager, WebpackModules, Settings, Database, ReactComponents, ReactAutoPatcher, DiscordApi } from 'modules';
import { ClientLogger as Logger, ClientIPC, Utils } from 'common';
import { EmoteModule } from 'builtin';
const ignoreExternal = true;
@ -83,35 +83,6 @@ if (window.BetterDiscord) {
function init() {
instance = new BetterDiscord();
}
window.Patcher = Patcher;
Events.on('react-ensure', init);
function ensureReact() {
if (!window.webpackJsonp || !WebpackModules.getModuleByName('React')) return setTimeout(ensureReact, 10);
ReactComponents.getComponent('Message').then(Message => {
Events.emit('react-ensure');
Message.patchRender([{
selector: '.message',
method: 'replace',
fn: function (item) {
if (!this.props || !this.props.message) return item;
const { message } = this.props;
const { id, colorString, bot, author, attachments, embeds } = message;
item.props['data-message-id'] = id;
item.props['data-colourstring'] = colorString;
if (author && author.id) item.props['data-user-id'] = author.id;
if (bot || (author && author.bot)) item.props.className += ' bd-isBot';
if (attachments && attachments.length) item.props.className += ' bd-hasAttachments';
if (embeds && embeds.length) item.props.className += ' bd-hasEmbeds';
if (author && author.id === DiscordApi.currentUser.id) item.props.className += ' bd-isCurrentUser';
return item;
}
}]);
});
Patcher.superpatch('React', 'createElement', function (component, retVal) {
if (!component.displayName) return;
ReactComponents.push(component, retVal);
});
}
ensureReact();
Events.on('autopatcher', init);
ReactAutoPatcher.autoPatch().then(() => Events.emit('autopatcher'));
}

View File

@ -16,4 +16,4 @@ export { default as Database } from './database';
export { default as EventsWrapper } from './eventswrapper';
export { default as DiscordApi } from './discordapi';
export { default as Patcher } from './patcher';
export { default as ReactComponents } from './reactcomponents';
export * from './reactcomponents';

View File

@ -1,4 +1,36 @@
import Patcher from './patcher';
import WebpackModules from './webpackmodules';
import DiscordApi from './discordapi';
import { EmoteModule } from 'builtin';
class Filters {
static get byPrototypeFields() {
return (fields, selector = x => x) => (module) => {
const component = selector(module);
if (!component) return false;
if (!component.prototype) return false;
for (const field of fields) {
if (!component.prototype[field]) return false;
}
return true;
}
}
static get byCode() {
return (search, selector = x => x) => (module) => {
const method = selector(module);
if (!method) return false;
return method.toString().search(search) !== -1;
}
}
static get and() {
return (...filters) => (module) => {
for (const filter of filters) {
if (!filter(module)) return false;
}
return true;
}
}
}
class Helpers {
static get plannedActions() {
@ -105,6 +137,20 @@ class Helpers {
if (selector.startsWith('#')) return { id: selector.substr(1) }
return {}
}
static findByProp(obj, what, value) {
if (obj.hasOwnProperty(what) && obj[what] === value) return obj;
if (obj.props && !obj.children) return this.findByProp(obj.props, what, value);
if (!obj.children || !obj.children.length) return null;
for (const child of obj.children) {
if (!child) continue;
const findInChild = this.findByProp(child, what, value);
if (findInChild) return findInChild;
}
return null;
}
static get ReactDOM() {
return WebpackModules.getModuleByName('ReactDOM');
}
}
class ReactComponent {
@ -112,6 +158,52 @@ class ReactComponent {
this._id = id;
this._component = component;
this._retVal = retVal;
const self = this;
Patcher.slavepatch(this.component.prototype, 'componentDidMount', function (a, parv) {
self.eventCallback('componentDidMount', {
props: this.props,
state: this.state,
element: Helpers.ReactDOM.findDOMNode(this),
retVal: parv.retVal
});
});
Patcher.slavepatch(this.component.prototype, 'componentDidUpdate', function(a, parv) {
self.eventCallback('componentDidUpdate', {
prevProps: a[0],
prevState: a[1],
props: this.props,
state: this.state,
element: Helpers.ReactDOM.findDOMNode(this),
retVal: parv.retVal
});
});
Patcher.slavepatch(this.component.prototype, 'render', function (a, parv) {
self.eventCallback('render', {
component: this,
retVal: parv.retVal,
p: parv
});
});
}
eventCallback(event, eventData) {
for (const listener of this.events.find(e => e.id === event).listeners) {
listener(eventData);
}
}
get events() {
return this._events || (this._events = [
{ id: 'componentDidMount', listeners: [] },
{ id: 'componentDidUpdate', listeners: [] },
{ id: 'render', listeners: [] }
]);
}
on(event, callback) {
const have = this.events.find(e => e.id === event);
if (!have) return;
have.listeners.push(callback);
}
get id() {
@ -129,15 +221,17 @@ class ReactComponent {
unpatchRender() {
}
/*
patchRender(actions, updateOthers) {
const self = this;
if (!(actions instanceof Array)) actions = [actions];
Patcher.slavepatch(this.component.prototype, 'render', function(args, obj) {
Patcher.slavepatch(this.component.prototype, 'render', function (args, obj) {
console.log('obj', obj);
for (const action of actions) {
let { selector, method, fn } = action;
if ('string' === typeof selector) selector = Helpers.parseSelector(selector);
const { item, parent, key } = Helpers.getFirstChild(obj, 'retVal', selector);
console.log('item2', item);
if (!item) continue;
const content = fn.apply(this, [item]);
switch (method) {
@ -149,20 +243,99 @@ class ReactComponent {
if (updateOthers) self.forceUpdateOthers();
});
}
*/
forceUpdateOthers() {
}
}
export default class ReactComponents {
export class ReactAutoPatcher {
static async autoPatch() {
await this.ensureReact();
Patcher.superpatch('React', 'createElement', (component, retVal) => ReactComponents.push(component, retVal));
this.patchem();
return 1;
}
static async ensureReact() {
while (!window.webpackJsonp || !WebpackModules.getModuleByName('React')) await new Promise(resolve => setTimeout(resolve, 10));
return 1;
}
static async patchem() {
this.patchMessage();
this.patchMessageGroup();
this.patchChannelMember();
}
static async patchMessage() {
this.Message.component = await ReactComponents.getComponent('Message');
this.Message.component.on('render', ({ component, retVal, p }) => {
const { message } = component.props;
const { id, colorString, bot, author, attachments, embeds } = message;
retVal.props['data-message-id'] = id;
retVal.props['data-colourstring'] = colorString;
if (author && author.id) retVal.props['data-user-id'] = author.id;
if (bot || (author && author.bot)) retVal.props.className += ' bd-isBot';
if (attachments && attachments.length) retVal.props.className += ' bd-hasAttachments';
if (embeds && embeds.length) retVal.props.className += ' bd-hasEmbeds';
if (author && author.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser';
try {
const markup = Helpers.findByProp(retVal, 'className', 'markup').children; // First child has all the actual text content, second is the edited timestamp
markup[0] = EmoteModule.processMarkup(markup[0]);
} catch (err) {
console.error('MARKUP PARSER ERROR', err);
}
});
}
static async patchMessageGroup() {
ReactComponents.setName('MessageGroup', this.MessageGroup.filter);
this.MessageGroup.component = await ReactComponents.getComponent('MessageGroup');
this.MessageGroup.component.on('render', ({ component, retVal, p }) => {
const authorid = component.props.messages[0].author.id;
retVal.props['data-author-id'] = authorid;
if (authorid === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser';
});
}
static async patchChannelMember() {
this.ChannelMember.component = await ReactComponents.getComponent('ChannelMember');
this.ChannelMember.component.on('render', ({ component, retVal, p }) => {
const { user, isOwner } = component.props;
retVal.props['data-member-id'] = user.id;
if (user.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser';
if (isOwner) retVal.props.className += ' bd-isOwner';
});
}
static get MessageGroup() {
return this._messageGroup || (
this._messageGroup = {
filter: Filters.byCode(/"message-group"[\s\S]*"has-divider"[\s\S]*"hide-overflow"[\s\S]*"is-local-bot-message"/, c => c.prototype && c.prototype.render)
});
}
static get Message() {
return this._message || (this._message = {});
}
static get ChannelMember() {
return this._channelMember || (
this._channelMember = {});
}
}
export class ReactComponents {
static get components() { return this._components || (this._components = []) }
static get unknownComponents() { return this._unknownComponents || (this._unknownComponents = [])}
static get listeners() { return this._listeners || (this._listeners = []) }
static get nameSetters() { return this._nameSetters || (this._nameSetters =[])}
static push(component, retVal) {
if (!(component instanceof Function)) return null;
const { displayName } = component;
if (!displayName) return null;
if (!displayName) {
return this.processUnknown(component, retVal);
}
const have = this.components.find(comp => comp.id === displayName);
if (have) return component;
const c = new ReactComponent(displayName, component, retVal);
@ -189,4 +362,31 @@ export default class ReactComponents {
});
}
static setName(name, filter, callback) {
const have = this.components.find(c => c.id === name);
if (have) return have;
for (const [rci, rc] of this.unknownComponents.entries()) {
if (filter(rc.component)) {
rc.component.displayName = name;
this.unknownComponents.splice(rci, 1);
return this.push(rc.component);
}
}
return this.nameSetters.push({ name, filter });
}
static processUnknown(component, retVal) {
const have = this.unknownComponents.find(c => c.component === component);
for (const [fi, filter] of this.nameSetters.entries()) {
if (filter.filter(component)) {
component.displayName = filter.name;
this.nameSetters.splice(fi, 1);
return this.push(component, retVal);
}
}
if (have) return have;
this.unknownComponents.push(c);
return c;
}
}

View File

@ -0,0 +1,11 @@
.bd-emote-outer.bd-is-emote {
font-size: 0;
}
.bd-emotewrapper {
position: relative;
display: inline-block;
img {
max-height: 32px;
}
}

View File

@ -12,3 +12,4 @@
@import './discordoverrides.scss';
@import './helpers.scss';
@import './misc.scss';
@import './emote.scss';

View File

@ -19,7 +19,7 @@
</div>
</div>
</div>
<div v-for="(emote, index) in emotes" class="bd-autocompleteRow" :key="emote.id">
<div v-for="(emote, index) in emotes" class="bd-autocompleteRow" :key="index">
<div class="bd-autocompleteSelector bd-selectable" :class="{'bd-selected': index === selectedIndex}" @mouseover="() => { selected = emote.id }" @click="() => inject(emote)">
<div class="bd-autocompleteField">
<img :src="getEmoteSrc(emote)"/>
@ -54,12 +54,14 @@
created() {
window.addEventListener('keydown', this.prevents);
const ta = document.querySelector('.chat textarea');
if(!ta) return;
ta.addEventListener('keydown', this.setCaret);
ta.addEventListener('keyup', this.searchEmotes);
},
destroyed() {
window.removeEventListener('keydown', this.prevents);
const ta = document.querySelector('.chat textarea');
if (!ta) return;
ta.removeEventListener('keydown', this.setCaret);
ta.removeEventListener('keyup', this.searchEmotes);
},
@ -91,21 +93,31 @@
const selected = this.emotes[this.selectedIndex];
if (!selected) return;
this.inject(selected);
this.open = false;
return;
}
if (e.key === 'Tab' && !this.open) this.open = true;
if (!this.open) return;
const se = e.target.selectionEnd;
this.sterm = e.target.value.substr(0, se).split(' ').slice(-1).pop();
if (this.sterm.length < 3) {
this.emotes = [];
this.selected = '';
this.selectedIndex = 0;
this.reset();
return;
}
this.title = this.sterm;
this.emotes = EmoteModule.filter(new RegExp(this.sterm, ''), 10);
this.open = this.emotes.length;
},
reset() {
this.emotes = [];
this.title = '';
this.selIndex = 0;
this.selected = '';
this.open = false;
this.selectedIndex = 0;
this.sterm = '';
},
inject(emote) {
const ta = document.querySelector('.chat textarea');
if (!ta) return;

View File

@ -80,6 +80,10 @@ class Reflection {
}
return this.propIterator(curProp, propNames);
}
static getState(node) {
return this.reactInternalInstance(node).return.stateNode.state;
}
}
export default function (node) {
@ -91,6 +95,9 @@ export default function (node) {
get props() {
return 'not yet implemented';
}
get state() {
return Reflection.getState(this.node);
}
get reactInternalInstance() {
return Reflection.reactInternalInstance(this.node);
}