implement QR basics

This commit is contained in:
LenAnderson 2023-12-20 13:40:44 +00:00
parent e19bf1afdd
commit 69d6b9379a
13 changed files with 1570 additions and 0 deletions

View File

@ -0,0 +1,53 @@
<div id="qr--qrOptions">
<h3>Context Menu</h3>
<div id="qr--ctxEditor">
<template id="qr--ctxItem">
<div class="qr--ctxItem" data-order="0">
<div class="drag-handle ui-sortable-handle"></div>
<select class="qr--set"></select>
<label class="qr--isChainedLabel checkbox_label" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
Chaining:
<input type="checkbox" class="qr--isChained">
</label>
<div class="qr--delete menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></div>
</div>
</template>
</div>
<div class="qr--ctxEditorActions">
<span id="qr--ctxAdd" class="menu_button menu_button_icon fa-solid fa-plus" title="Add quick reply set to context menu"></span>
</div>
<h3>Auto-Execute</h3>
<div class="flex-container flexFlowColumn">
<label class="checkbox_label">
<input type="checkbox" id="qr--isHidden" >
<span><i class="fa-solid fa-fw fa-eye-slash"></i> Invisible (auto-execute only)</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnStartup" >
<span><i class="fa-solid fa-fw fa-rocket"></i> Execute on app startup</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnUser" >
<span><i class="fa-solid fa-fw fa-user"></i> Execute on user message</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnAi" >
<span><i class="fa-solid fa-fw fa-robot"></i> Execute on AI message</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnChatChange" >
<span><i class="fa-solid fa-fw fa-message"></i> Execute on opening chat</span>
</label>
</div>
<h3>UI Options</h3>
<div class="flex-container flexFlowColumn">
<label>
Title (tooltip, leave empty to show the message or /command)
<input type="text" class="text_pole" id="qr--title">
</label>
</div>
</div>

View File

@ -0,0 +1,61 @@
<div id="qr--settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<strong>Quick Reply</strong>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label class="flex-container">
<input type="checkbox" id="qr--isEnabled"> Enable Quick Replies
</label>
<label class="flex-container">
<input type="checkbox" id="qr--isCombined"> Combine buttons from all active sets
</label>
<hr>
<div class="qr--head">
<div class="qr--title">Global Quick Reply Sets</div>
<div class="qr--actions">
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--global-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--global-setList" class="qr--setList"></div>
<hr>
<div class="qr--head">
<div class="qr--title">Chat Quick Reply Sets</div>
<div class="qr--actions">
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--chat-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--chat-setList" class="qr--setList"></div>
<hr>
<div class="qr--head">
<div class="qr--title">Edit Quick Replies</div>
<div class="qr--actions">
<select id="qr--set" class="text_pole"></select>
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-new" title="Create new quick reply set"></div>
</div>
</div>
<div id="qr--set-settings">
<label class="flex-container">
<input type="checkbox" id="qr--disableSend"> <span>Disable send (insert into input field)</span>
</label>
<label class="flex-container">
<input type="checkbox" id="qr--placeBeforeInput"> <span>Place quick reply before input</span>
</label>
<label class="flex-container" id="qr--injectInputContainer">
<input type="checkbox" id="qr--injectInput"> <span>Inject user input automatically <small>(if disabled, use <code>{{input}}</code> macro for manual injection)</small></span>
</label>
</div>
<div id="qr--set-qrList" class="qr--qrList"></div>
<div class="qr--set-qrListActions">
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-add" title="Add quick reply"></div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,202 @@
import { chat_metadata, eventSource, event_types, getRequestHeaders } from '../../../script.js';
import { extension_settings } from '../../extensions.js';
import { QuickReply } from './src/QuickReply.js';
import { QuickReplyConfig } from './src/QuickReplyConfig.js';
import { QuickReplyContextLink } from './src/QuickReplyContextLink.js';
import { QuickReplySet } from './src/QuickReplySet.js';
import { QuickReplySettings } from './src/QuickReplySettings.js';
import { ButtonUi } from './src/ui/ButtonUi.js';
import { SettingsUi } from './src/ui/SettingsUi.js';
//TODO popout QR button bar (allow separate popouts for each QR set?)
//TODO context menus
//TODO move advanced QR options into own UI class
//TODO slash commands
//TODO create new QR set
//TODO delete QR set
//TODO easy way to CRUD QRs and sets
//TODO easy way to set global and chat sets
const _VERBOSE = true;
export const log = (...msg) => _VERBOSE ? console.log('[QR2]', ...msg) : null;
export const warn = (...msg) => _VERBOSE ? console.warn('[QR2]', ...msg) : null;
const defaultConfig = {
setList: [{
set: 'Default',
isVisible: true,
}],
};
const defaultSettings = {
isEnabled: true,
isCombined: false,
config: defaultConfig,
};
/** @type {QuickReplySettings}*/
let settings;
/** @type {SettingsUi} */
let manager;
/** @type {ButtonUi} */
let buttons;
const loadSets = async () => {
const response = await fetch('/api/settings/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok) {
const setList = (await response.json()).quickReplyPresets ?? [];
for (const set of setList) {
if (set.version == 2) {
QuickReplySet.list.push(QuickReplySet.from(set));
} else {
const qrs = new QuickReplySet();
qrs.name = set.name;
qrs.disableSend = set.quickActionEnabled ?? false;
qrs.placeBeforeInput = set.placeBeforeInputEnabled ?? false;
qrs.injectInput = set.AutoInputInject ?? false;
qrs.qrList = set.quickReplySlots.map((slot,idx)=>{
const qr = new QuickReply();
qr.id = idx + 1;
qr.label = slot.label;
qr.title = slot.title;
qr.message = slot.mes;
qr.isHidden = slot.hidden ?? false;
qr.executeOnStartup = slot.autoExecute_appStartup ?? false;
qr.executeOnUser = slot.autoExecute_userMessage ?? false;
qr.executeOnAi = slot.autoExecute_botMessage ?? false;
qr.executeOnChatChange = slot.autoExecute_chatLoad ?? false;
qr.contextList = (slot.contextMenu ?? []).map(it=>(QuickReplyContextLink.from({
set: it.preset,
isChained: it.chain,
})));
return qr;
});
QuickReplySet.list.push(qrs);
await qrs.save();
}
}
log('sets: ', QuickReplySet.list);
}
};
const loadSettings = async () => {
//TODO migrate old settings
if (!extension_settings.quickReplyV2) {
extension_settings.quickReplyV2 = defaultSettings;
}
try {
settings = QuickReplySettings.from(extension_settings.quickReplyV2);
} catch {
settings = QuickReplySettings.from(defaultSettings);
}
};
const init = async () => {
await loadSets();
await loadSettings();
log('settings: ', settings);
manager = new SettingsUi(settings);
document.querySelector('#extensions_settings2').append(await manager.render());
buttons = new ButtonUi(settings);
buttons.show();
settings.onSave = ()=>buttons.refresh();
window['executeQuickReplyByName'] = async(name) => {
let qr = [...settings.config.setList, ...(settings.chatConfig?.setList ?? [])]
.map(it=>it.set.qrList)
.flat()
.find(it=>it.label == name)
;
if (!qr) {
let [setName, ...qrName] = name.split('.');
name = qrName.join('.');
let qrs = QuickReplySet.get(setName);
if (qrs) {
qr = qrs.qrList.find(it=>it.label == name);
}
}
if (qr && qr.onExecute) {
return await qr.onExecute();
}
};
if (settings.isEnabled) {
const qrList = [
...settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup)).flat(),
...(settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup))?.flat() ?? []),
];
for (const qr of qrList) {
await qr.onExecute();
}
}
};
eventSource.on(event_types.APP_READY, init);
const onChatChanged = async (chatIdx) => {
log('CHAT_CHANGED', chatIdx);
if (chatIdx) {
settings.chatConfig = QuickReplyConfig.from(chat_metadata.quickReply ?? {});
} else {
settings.chatConfig = null;
}
manager.rerender();
buttons.refresh();
if (settings.isEnabled) {
const qrList = [
...settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange)).flat(),
...(settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange))?.flat() ?? []),
];
for (const qr of qrList) {
await qr.onExecute();
}
}
};
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
const onUserMessage = async () => {
if (settings.isEnabled) {
const qrList = [
...settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser)).flat(),
...(settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser))?.flat() ?? []),
];
for (const qr of qrList) {
await qr.onExecute();
}
}
};
eventSource.on(event_types.USER_MESSAGE_RENDERED, onUserMessage);
const onAiMessage = async () => {
if (settings.isEnabled) {
const qrList = [
...settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi)).flat(),
...(settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi))?.flat() ?? []),
];
for (const qr of qrList) {
await qr.onExecute();
}
}
};
eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, onAiMessage);

View File

@ -0,0 +1,338 @@
import { callPopup } from '../../../../script.js';
import { getSortableDelay } from '../../../utils.js';
import { log, warn } from '../index.js';
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReply {
/**
* @param {{ id?: number; contextList?: any; }} props
*/
static from(props) {
props.contextList = (props.contextList ?? []).map((/** @type {any} */ it)=>QuickReplyContextLink.from(it));
return Object.assign(new this(), props);
}
/**@type {Number}*/ id;
/**@type {String}*/ label = '';
/**@type {String}*/ title = '';
/**@type {String}*/ message = '';
/**@type {QuickReplyContextLink[]}*/ contextList;
/**@type {Boolean}*/ isHidden = false;
/**@type {Boolean}*/ executeOnStartup = false;
/**@type {Boolean}*/ executeOnUser = false;
/**@type {Boolean}*/ executeOnAi = false;
/**@type {Boolean}*/ executeOnChatChange = false;
/**@type {Function}*/ onExecute;
/**@type {Function}*/ onDelete;
/**@type {Function}*/ onUpdate;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ settingsDom;
unrender() {
this.dom?.remove();
this.dom = null;
}
updateRender() {
if (!this.dom) return;
this.dom.title = this.title || this.message;
this.dom.textContent = this.label;
}
render() {
this.unrender();
if (!this.dom) {
const root = document.createElement('div'); {
this.dom = root;
root.classList.add('qr--button');
root.title = this.title || this.message;
root.textContent = this.label;
root.addEventListener('click', ()=>{
if (this.message?.length > 0 && this.onExecute) {
this.onExecute(this);
}
});
}
}
return this.dom;
}
/**
* @param {any} idx
*/
renderSettings(idx) {
if (!this.settingsDom) {
const item = document.createElement('div'); {
this.settingsDom = item;
item.classList.add('qr--set-item');
item.setAttribute('data-order', String(idx));
item.setAttribute('data-id', String(this.id));
const drag = document.createElement('div'); {
drag.classList.add('drag-handle');
drag.classList.add('ui-sortable-handle');
drag.textContent = '☰';
item.append(drag);
}
const lblContainer = document.createElement('div'); {
lblContainer.classList.add('qr--set-itemLabelContainer');
const lbl = document.createElement('input'); {
lbl.classList.add('qr--set-itemLabel');
lbl.classList.add('text_pole');
lbl.value = this.label;
lbl.addEventListener('input', ()=>this.updateLabel(lbl.value));
lblContainer.append(lbl);
}
item.append(lblContainer);
}
const optContainer = document.createElement('div'); {
optContainer.classList.add('qr--set-optionsContainer');
const opt = document.createElement('div'); {
opt.classList.add('qr--action');
opt.classList.add('menu_button');
opt.classList.add('fa-solid');
opt.textContent = '⁝';
opt.title = 'Additional options:\n - context menu\n - auto-execution\n - tooltip';
opt.addEventListener('click', ()=>this.showOptions());
optContainer.append(opt);
}
item.append(optContainer);
}
const expandContainer = document.createElement('div'); {
expandContainer.classList.add('qr--set-optionsContainer');
const expand = document.createElement('div'); {
expand.classList.add('qr--expand');
expand.classList.add('menu_button');
expand.classList.add('menu_button_icon');
expand.classList.add('editor_maximize');
expand.classList.add('fa-solid');
expand.classList.add('fa-maximize');
expand.title = 'Expand the editor';
expand.setAttribute('data-for', `qr--set--item${this.id}`);
expand.setAttribute('data-tab', 'true');
expandContainer.append(expand);
}
item.append(expandContainer);
}
const mes = document.createElement('textarea'); {
mes.id = `qr--set--item${this.id}`;
mes.classList.add('qr--set-itemMessage');
mes.value = this.message;
//HACK need to use jQuery to catch the triggered event from the expanded editor
$(mes).on('input', ()=>this.updateMessage(mes.value));
item.append(mes);
}
const actions = document.createElement('div'); {
actions.classList.add('qr--actions');
const del = document.createElement('div'); {
del.classList.add('qr--action');
del.classList.add('menu_button');
del.classList.add('menu_button_icon');
del.classList.add('fa-solid');
del.classList.add('fa-trash-can');
del.title = 'Remove quick reply';
del.addEventListener('click', ()=>this.delete());
actions.append(del);
}
item.append(actions);
}
}
}
return this.settingsDom;
}
unrenderSettings() {
this.settingsDom?.remove();
}
delete() {
if (this.onDelete) {
this.unrender();
this.unrenderSettings();
this.onDelete(this);
}
}
/**
* @param {string} value
*/
updateMessage(value) {
if (this.onUpdate) {
this.message = value;
this.updateRender();
this.onUpdate(this);
}
}
/**
* @param {string} value
*/
updateLabel(value) {
if (this.onUpdate) {
this.label = value;
this.updateRender();
this.onUpdate(this);
}
}
updateContext() {
if (this.onUpdate) {
this.updateRender();
this.onUpdate(this);
}
}
async showOptions() {
const response = await fetch('/scripts/extensions/quick-reply/html/qrOptions.html', { cache: 'no-store' });
if (response.ok) {
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--qrOptions');
/**@type {HTMLElement} */
// @ts-ignore
const dom = this.template.cloneNode(true);
const popupResult = callPopup(dom, 'text', undefined, { okButton: 'OK', wide: false, large: false, rows: 1 });
/**@type {HTMLTemplateElement}*/
const tpl = dom.querySelector('#qr--ctxItem');
const linkList = dom.querySelector('#qr--ctxEditor');
const fillQrSetSelect = (/**@type {HTMLSelectElement}*/select, /**@type {QuickReplyContextLink}*/ link) => {
[{ name: 'Select a QR set' }, ...QuickReplySet.list].forEach(qrs => {
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
opt.selected = qrs.name == link.set?.name;
select.append(opt);
}
});
};
const addCtxItem = (/**@type {QuickReplyContextLink}*/link, /**@type {Number}*/idx) => {
/**@type {HTMLElement} */
// @ts-ignore
const itemDom = tpl.content.querySelector('.qr--ctxItem').cloneNode(true); {
itemDom.setAttribute('data-order', String(idx));
/**@type {HTMLSelectElement} */
const select = itemDom.querySelector('.qr--set');
fillQrSetSelect(select, link);
select.addEventListener('change', () => {
link.set = QuickReplySet.get(select.value);
this.updateContext();
});
/**@type {HTMLInputElement} */
const chain = itemDom.querySelector('.qr--isChained');
chain.checked = link.isChained;
chain.addEventListener('click', () => {
link.isChained = chain.checked;
this.updateContext();
});
itemDom.querySelector('.qr--delete').addEventListener('click', () => {
itemDom.remove();
this.contextList.splice(this.contextList.indexOf(link), 1);
this.updateContext();
});
linkList.append(itemDom);
}
};
[...this.contextList].forEach((link, idx) => addCtxItem(link, idx));
dom.querySelector('#qr--ctxAdd').addEventListener('click', () => {
const link = new QuickReplyContextLink();
this.contextList.push(link);
addCtxItem(link, this.contextList.length - 1);
});
const onContextSort = () => {
this.contextList = Array.from(linkList.querySelectorAll('.qr--ctxItem')).map((it,idx) => {
const link = this.contextList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return link;
});
this.updateContext();
};
// @ts-ignore
$(linkList).sortable({
delay: getSortableDelay(),
stop: () => onContextSort(),
});
// auto-exec
/**@type {HTMLInputElement}*/
const isHidden = dom.querySelector('#qr--isHidden');
isHidden.checked = this.isHidden;
isHidden.addEventListener('click', ()=>{
this.isHidden = isHidden.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnStartup = dom.querySelector('#qr--executeOnStartup');
executeOnStartup.checked = this.executeOnStartup;
executeOnStartup.addEventListener('click', ()=>{
this.executeOnStartup = executeOnStartup.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnUser = dom.querySelector('#qr--executeOnUser');
executeOnUser.checked = this.executeOnUser;
executeOnUser.addEventListener('click', ()=>{
this.executeOnUser = executeOnUser.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnAi = dom.querySelector('#qr--executeOnAi');
executeOnAi.checked = this.executeOnAi;
executeOnAi.addEventListener('click', ()=>{
this.executeOnAi = executeOnAi.checked;
this.updateContext();
});
/**@type {HTMLInputElement}*/
const executeOnChatChange = dom.querySelector('#qr--executeOnChatChange');
executeOnChatChange.checked = this.executeOnChatChange;
executeOnChatChange.addEventListener('click', ()=>{
this.executeOnChatChange = executeOnChatChange.checked;
this.updateContext();
});
// UI options
/**@type {HTMLInputElement}*/
const title = dom.querySelector('#qr--title');
title.value = this.title;
title.addEventListener('input', () => {
this.title = title.value.trim();
this.updateContext();
});
await popupResult;
} else {
warn('failed to fetch qrOptions template');
}
}
toJSON() {
return {
id: this.id,
label: this.label,
title: this.title,
message: this.message,
contextList: this.contextList,
isHidden: this.isHidden,
executeOnStartup: this.executeOnStartup,
executeOnUser: this.executeOnUser,
executeOnAi: this.executeOnAi,
executeOnChatChange: this.executeOnChatChange,
};
}
}

View File

@ -0,0 +1,22 @@
import { QuickReplyLink } from './QuickReplyLink.js';
export class QuickReplyConfig {
/**@type {QuickReplyLink[]}*/ setList = [];
static from(props) {
props.setList = props.setList?.map(it=>QuickReplyLink.from(it)) ?? [];
return Object.assign(new this(), props);
}
toJSON() {
return {
setList: this.setList,
};
}
}

View File

@ -0,0 +1,25 @@
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplyContextLink {
static from(props) {
props.set = QuickReplySet.get(props.set);
const x = Object.assign(new this(), props);
return x;
}
/**@type {QuickReplySet}*/ set;
/**@type {Boolean}*/ isChained = false;
toJSON() {
return {
set: this.set?.name,
isChained: this.isChained,
};
}
}

View File

@ -0,0 +1,24 @@
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplyLink {
/**@type {QuickReplySet}*/ set;
/**@type {Boolean}*/ isVisible = true;
static from(props) {
props.set = QuickReplySet.get(props.set);
return Object.assign(new this(), props);
}
toJSON() {
return {
set: this.set.name,
isVisible: this.isVisible,
};
}
}

View File

@ -0,0 +1,192 @@
import { getRequestHeaders } from '../../../../script.js';
import { executeSlashCommands } from '../../../slash-commands.js';
import { debounce } from '../../../utils.js';
import { warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
export class QuickReplySet {
/**@type {QuickReplySet[]}*/ static list = [];
static from(props) {
props.qrList = props.qrList?.map(it=>QuickReply.from(it));
const instance = Object.assign(new this(), props);
instance.init();
return instance;
}
/**
* @param {String} name - name of the QuickReplySet
*/
static get(name) {
return this.list.find(it=>it.name == name);
}
/**@type {String}*/ name;
/**@type {Boolean}*/ disableSend = false;
/**@type {Boolean}*/ placeBeforeInput = false;
/**@type {Boolean}*/ injectInput = false;
/**@type {QuickReply[]}*/ qrList = [];
/**@type {Number}*/ idIndex = 0;
/**@type {Function}*/ save;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ settingsDom;
constructor() {
this.save = debounce(()=>this.performSave(), 200);
}
init() {
this.qrList.forEach(qr=>this.hookQuickReply(qr));
}
unrender() {
this.dom?.remove();
this.dom = null;
}
render() {
this.unrender();
if (!this.dom) {
const root = document.createElement('div'); {
this.dom = root;
root.classList.add('qr--buttons');
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
root.append(qr.render());
});
}
}
return this.dom;
}
rerender() {
if (!this.dom) return;
this.dom.innerHTML = '';
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
this.dom.append(qr.render());
});
}
renderSettings() {
if (!this.settingsDom) {
this.settingsDom = document.createElement('div'); {
this.settingsDom.classList.add('qr--set-qrListContents');
this.qrList.forEach((qr,idx)=>{
this.renderSettingsItem(qr, idx);
});
}
}
return this.settingsDom;
}
renderSettingsItem(qr, idx) {
this.settingsDom.append(qr.renderSettings(idx));
}
/**
* @param {QuickReply} qr
*/
async execute(qr) {
/**@type {HTMLTextAreaElement}*/
const ta = document.querySelector('#send_textarea');
let input = ta.value;
if (this.injectInput && input.length > 0) {
if (this.placeBeforeInput) {
input = `${qr.message} ${input}`;
} else {
input = `${input} ${qr.message}`;
}
} else {
input = `${qr.message} `;
}
if (input[0] == '/' && !this.disableSend) {
const result = await executeSlashCommands(input);
return typeof result === 'object' ? result?.pipe : '';
}
ta.value = input;
ta.focus();
if (!this.disableSend) {
// @ts-ignore
document.querySelector('#send_but').click();
}
}
addQuickReply() {
const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
this.idIndex = id + 1;
const qr = QuickReply.from({ id });
this.qrList.push(qr);
this.hookQuickReply(qr);
if (this.settingsDom) {
this.renderSettingsItem(qr, this.qrList.length - 1);
}
if (this.dom) {
this.dom.append(qr.render());
}
this.save();
return qr;
}
hookQuickReply(qr) {
qr.onExecute = ()=>this.execute(qr);
qr.onDelete = ()=>this.removeQuickReply(qr);
qr.onUpdate = ()=>this.save();
}
removeQuickReply(qr) {
this.qrList.splice(this.qrList.indexOf(qr), 1);
this.save();
}
toJSON() {
return {
version: 2,
name: this.name,
disableSend: this.disableSend,
placeBeforeInput: this.placeBeforeInput,
injectInput: this.injectInput,
qrList: this.qrList,
idIndex: this.idIndex,
};
}
async performSave() {
const response = await fetch('/savequickreply', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.rerender();
} else {
warn(`Failed to save Quick Reply Set: ${this.name}`);
}
}
}

View File

@ -0,0 +1,42 @@
import { chat_metadata, saveChatDebounced, saveSettingsDebounced } from '../../../../script.js';
import { extension_settings } from '../../../extensions.js';
import { QuickReplyConfig } from './QuickReplyConfig.js';
export class QuickReplySettings {
static from(props) {
props.config = QuickReplyConfig.from(props.config);
return Object.assign(new this(), props);
}
/**@type {Boolean}*/ isEnabled = false;
/**@type {Boolean}*/ isCombined = false;
/**@type {QuickReplyConfig}*/ config;
/**@type {QuickReplyConfig}*/ chatConfig;
/**@type {Function}*/ onSave;
save() {
extension_settings.quickReplyV2 = this.toJSON();
saveSettingsDebounced();
if (this.chatConfig) {
chat_metadata.quickReply = this.chatConfig.toJSON();
saveChatDebounced();
}
if (this.onSave) {
this.onSave();
}
}
toJSON() {
return {
isEnabled: this.isEnabled,
config: this.config,
};
}
}

View File

@ -0,0 +1,63 @@
import { QuickReplySettings } from '../QuickReplySettings.js';
export class ButtonUi {
/**@type {QuickReplySettings}*/ settings;
/**@type {HTMLElement}*/ dom;
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
}
render() {
if (!this.dom) {
let buttonHolder;
const root = document.createElement('div'); {
this.dom = root;
buttonHolder = root;
root.id = 'qr--bar';
root.classList.add('flex-container');
root.classList.add('flexGap5');
if (this.settings.isCombined) {
const buttons = document.createElement('div'); {
buttonHolder = buttons;
buttons.classList.add('qr--buttons');
root.append(buttons);
}
}
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? [])]
.filter(link=>link.isVisible)
.forEach(link=>buttonHolder.append(link.set.render()))
;
}
}
return this.dom;
}
unrender() {
this.dom?.remove();
this.dom = null;
}
show() {
if (!this.settings.isEnabled) return;
const sendForm = document.querySelector('#send_form');
if (sendForm.children.length > 0) {
sendForm.children[0].insertAdjacentElement('beforebegin', this.render());
} else {
sendForm.append(this.render());
}
}
hide() {
this.unrender();
}
refresh() {
this.hide();
this.show();
}
}

View File

@ -0,0 +1,310 @@
import { getSortableDelay } from '../../../../utils.js';
import { warn } from '../../index.js';
import { QuickReplyLink } from '../QuickReplyLink.js';
import { QuickReplySet } from '../QuickReplySet.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplySettings } from '../QuickReplySettings.js';
export class SettingsUi {
/**@type {QuickReplySettings}*/ settings;
/**@type {HTMLElement}*/ template;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLInputElement}*/ isEnabled;
/**@type {HTMLInputElement}*/ isCombined;
/**@type {HTMLElement}*/ globalSetList;
/**@type {HTMLElement}*/ chatSetList;
/**@type {QuickReplySet}*/ currentQrSet;
/**@type {HTMLInputElement}*/ disableSend;
/**@type {HTMLInputElement}*/ placeBeforeInput;
/**@type {HTMLInputElement}*/ injectInput;
/**@type {HTMLSelectElement}*/ currentSet;
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
}
rerender() {
if (!this.dom) return;
const content = this.dom.querySelector('.inline-drawer-content');
content.innerHTML = '';
// @ts-ignore
Array.from(this.template.querySelector('.inline-drawer-content').cloneNode(true).children).forEach(el=>{
content.append(el);
});
this.prepareDom();
}
unrender() {
this.dom?.remove();
this.dom = null;
}
async render() {
if (!this.dom) {
const response = await fetch('/scripts/extensions/quick-reply/html/settings.html', { cache: 'no-store' });
if (response.ok) {
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--settings');
// @ts-ignore
this.dom = this.template.cloneNode(true);
this.prepareDom();
} else {
warn('failed to fetch settings template');
}
}
return this.dom;
}
prepareGeneralSettings() {
// general settings
this.isEnabled = this.dom.querySelector('#qr--isEnabled');
this.isEnabled.checked = this.settings.isEnabled;
this.isEnabled.addEventListener('click', ()=>this.onIsEnabled());
this.isCombined = this.dom.querySelector('#qr--isCombined');
this.isCombined.checked = this.settings.isCombined;
this.isCombined.addEventListener('click', ()=>this.onIsCombined());
}
/**
* @param {QuickReplyLink} qrl
* @param {Number} idx
*/
renderQrLinkItem(qrl, idx, isGlobal) {
const item = document.createElement('div'); {
item.classList.add('qr--item');
item.setAttribute('data-order', String(idx));
const drag = document.createElement('div'); {
drag.classList.add('drag-handle');
drag.classList.add('ui-sortable-handle');
drag.textContent = '☰';
item.append(drag);
}
const set = document.createElement('select'); {
set.addEventListener('change', ()=>{
qrl.set = QuickReplySet.get(set.value);
this.settings.save();
});
QuickReplySet.list.forEach(qrs=>{
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
opt.selected = qrs == qrl.set;
set.append(opt);
}
});
item.append(set);
}
const visible = document.createElement('label'); {
visible.classList.add('qr--visible');
const cb = document.createElement('input'); {
cb.type = 'checkbox';
cb.checked = qrl.isVisible;
cb.addEventListener('click', ()=>{
qrl.isVisible = cb.checked;
this.settings.save();
});
visible.append(cb);
}
visible.append('Show buttons');
item.append(visible);
}
const edit = document.createElement('div'); {
edit.classList.add('menu_button');
edit.classList.add('menu_button_icon');
edit.classList.add('fa-solid');
edit.classList.add('fa-pencil');
edit.title = 'Edit quick reply set';
edit.addEventListener('click', ()=>{
this.currentSet.value = qrl.set.name;
this.onQrSetChange();
});
item.append(edit);
}
const del = document.createElement('div'); {
del.classList.add('menu_button');
del.classList.add('menu_button_icon');
del.classList.add('fa-solid');
del.classList.add('fa-trash-can');
del.title = 'Remove quick reply set';
del.addEventListener('click', ()=>{
item.remove();
if (isGlobal) {
this.settings.config.setList.splice(this.settings.config.setList.indexOf(qrl), 1);
this.updateOrder(this.globalSetList);
} else {
this.settings.chatConfig.setList.splice(this.settings.chatConfig.setList.indexOf(qrl), 1);
this.updateOrder(this.chatSetList);
}
this.settings.save();
});
item.append(del);
}
}
return item;
}
prepareGlobalSetList() {
// global set list
this.dom.querySelector('#qr--global-setListAdd').addEventListener('click', ()=>{
const qrl = new QuickReplyLink();
qrl.set = QuickReplySet.list[0];
this.settings.config.setList.push(qrl);
this.globalSetList.append(this.renderQrLinkItem(qrl, this.settings.config.setList.length - 1, true));
this.settings.save();
});
this.globalSetList = this.dom.querySelector('#qr--global-setList');
// @ts-ignore
$(this.globalSetList).sortable({
delay: getSortableDelay(),
stop: ()=>this.onGlobalSetListSort(),
});
this.settings.config.setList.forEach((qrl,idx)=>{
this.globalSetList.append(this.renderQrLinkItem(qrl, idx, true));
});
}
prepareChatSetList() {
// chat set list
this.dom.querySelector('#qr--chat-setListAdd').addEventListener('click', ()=>{
if (!this.settings.chatConfig) {
toastr.warning('No active chat.');
return;
}
const qrl = new QuickReplyLink();
qrl.set = QuickReplySet.list[0];
this.settings.chatConfig.setList.push(qrl);
this.chatSetList.append(this.renderQrLinkItem(qrl, this.settings.chatConfig.setList.length - 1, false));
this.settings.save();
});
this.chatSetList = this.dom.querySelector('#qr--chat-setList');
if (!this.settings.chatConfig) {
const info = document.createElement('small'); {
info.textContent = 'No active chat.';
this.chatSetList.append(info);
}
}
// @ts-ignore
$(this.chatSetList).sortable({
delay: getSortableDelay(),
stop: ()=>this.onChatSetListSort(),
});
this.settings.chatConfig?.setList?.forEach((qrl,idx)=>{
this.chatSetList.append(this.renderQrLinkItem(qrl, idx, false));
});
}
prepareQrEditor() {
// qr editor
this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
this.currentQrSet.addQuickReply();
});
this.qrList = this.dom.querySelector('#qr--set-qrList');
this.currentSet = this.dom.querySelector('#qr--set');
this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
QuickReplySet.list.forEach(qrs=>{
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
this.currentSet.append(opt);
}
});
this.disableSend = this.dom.querySelector('#qr--disableSend');
this.disableSend.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.disableSend = this.disableSend.checked;
qrs.save();
});
this.placeBeforeInput = this.dom.querySelector('#qr--placeBeforeInput');
this.placeBeforeInput.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.placeBeforeInput = this.placeBeforeInput.checked;
qrs.save();
});
this.injectInput = this.dom.querySelector('#qr--injectInput');
this.injectInput.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.injectInput = this.injectInput.checked;
qrs.save();
});
this.onQrSetChange();
}
onQrSetChange() {
this.currentQrSet = QuickReplySet.get(this.currentSet.value);
this.disableSend.checked = this.currentQrSet.disableSend;
this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
this.injectInput.checked = this.currentQrSet.injectInput;
this.qrList.innerHTML = '';
const qrsDom = this.currentQrSet.renderSettings();
this.qrList.append(qrsDom);
// @ts-ignore
$(qrsDom).sortable({
delay: getSortableDelay(),
stop: ()=>this.onQrListSort(),
});
}
prepareDom() {
this.prepareGeneralSettings();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareQrEditor();
}
async onIsEnabled() {
this.settings.isEnabled = this.isEnabled.checked;
this.settings.save();
}
async onIsCombined() {
this.settings.isCombined = this.isCombined.checked;
this.settings.save();
}
async onGlobalSetListSort() {
this.settings.config.setList = Array.from(this.globalSetList.children).map((it,idx)=>{
const set = this.settings.config.setList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return set;
});
this.settings.save();
}
async onChatSetListSort() {
this.settings.chatConfig.setList = Array.from(this.chatSetList.children).map((it,idx)=>{
const set = this.settings.chatConfig.setList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return set;
});
this.settings.save();
}
updateOrder(list) {
Array.from(list.children).forEach((it,idx)=>{
it.setAttribute('data-order', idx);
});
}
async onQrListSort() {
this.currentQrSet.qrList = Array.from(this.qrList.querySelectorAll('.qr--set-item')).map((it,idx)=>{
const qr = this.currentQrSet.qrList.find(qr=>qr.id == Number(it.getAttribute('data-id')));
it.setAttribute('data-order', String(idx));
return qr;
});
this.currentQrSet.save();
}
}

View File

@ -0,0 +1,114 @@
#qr--bar {
outline: none;
margin: 0;
transition: 0.3s;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
overflow-x: auto;
order: 1;
position: relative;
}
#qr--bar > .qr--buttons {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
}
#qr--bar > .qr--buttons > .qr--buttons {
display: contents;
}
#qr--bar > .qr--buttons .qr--button {
color: var(--SmartThemeBodyColor);
background-color: var(--black50a);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 3px 5px;
margin: 3px 0;
cursor: pointer;
transition: 0.3s;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
#qr--bar > .qr--buttons .qr--button:hover {
opacity: 1;
filter: brightness(1.2);
}
#qr--settings .qr--head {
display: flex;
align-items: baseline;
gap: 1em;
}
#qr--settings .qr--head > .qr--title {
font-weight: bold;
}
#qr--settings .qr--head > .qr--actions {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5em;
}
#qr--settings .qr--setList > .qr--item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0 0.5em;
}
#qr--settings .qr--setList > .qr--item > .qr--visible {
flex: 1 1 200px;
display: flex;
flex-direction: row;
}
#qr--settings #qr--set-settings #qr--injectInputContainer {
flex-wrap: nowrap;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents {
padding: 0 0.5em;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0.25em 0;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(1) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(2) {
flex: 1 1 25%;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(3) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(4) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(5) {
flex: 1 1 75%;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(6) {
flex: 0 0 auto;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemLabel,
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--action {
margin: 0;
}
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemMessage {
font-size: smaller;
}
#qr--qrOptions > #qr--ctxEditor .qr--ctxItem {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
}

View File

@ -0,0 +1,124 @@
#qr--bar {
outline: none;
margin: 0;
transition: 0.3s;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
overflow-x: auto;
order: 1;
position: relative;
> .qr--buttons {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
> .qr--buttons {
display: contents;
}
.qr--button {
color: var(--SmartThemeBodyColor);
background-color: var(--black50a);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 3px 5px;
margin: 3px 0;
cursor: pointer;
transition: 0.3s;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
&:hover {
opacity: 1;
filter: brightness(1.2);
}
}
}
}
#qr--settings {
.qr--head {
display: flex;
align-items: baseline;
gap: 1em;
> .qr--title {
font-weight: bold;
}
> .qr--actions {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5em;
}
}
.qr--setList {
> .qr--item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0 0.5em;
> .qr--visible {
flex: 1 1 200px;
display: flex;
flex-direction: row;
}
}
}
#qr--set-settings {
#qr--injectInputContainer {
flex-wrap: nowrap;
}
}
#qr--set-qrList {
.qr--set-qrListContents > {
padding: 0 0.5em;
> .qr--set-item {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
padding: 0.25em 0;
> :nth-child(1) { flex: 0 0 auto; }
> :nth-child(2) { flex: 1 1 25%; }
> :nth-child(3) { flex: 0 0 auto; }
> :nth-child(4) { flex: 0 0 auto; }
> :nth-child(5) { flex: 1 1 75%; }
> :nth-child(6) { flex: 0 0 auto; }
.qr--set-itemLabel, .qr--action {
margin: 0;
}
.qr--set-itemMessage {
font-size: smaller;
}
}
}
}
}
#qr--qrOptions {
> #qr--ctxEditor {
.qr--ctxItem {
display: flex;
flex-direction: row;
gap: 0.5em;
align-items: baseline;
}
}
}