Reimplement compose and add tiling windows
This commit is contained in:
1
core/static/js/bulma-calendar.min.js
vendored
1
core/static/js/bulma-calendar.min.js
vendored
File diff suppressed because one or more lines are too long
1
core/static/js/bulma-slider.min.js
vendored
1
core/static/js/bulma-slider.min.js
vendored
File diff suppressed because one or more lines are too long
1
core/static/js/bulma-tagsinput.min.js
vendored
1
core/static/js/bulma-tagsinput.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,259 +0,0 @@
|
||||
// Author: Grzegorz Tężycki
|
||||
|
||||
$(document).ready(function(){
|
||||
|
||||
// In web storage is saved structure like that:
|
||||
// localStorage['django_tables2_column_shifter'] = {
|
||||
// 'table_class_container1' : {
|
||||
// 'id' : 'on',
|
||||
// 'col1' : 'off',
|
||||
// 'col2' : 'on',
|
||||
// 'col3' : 'on',
|
||||
// },
|
||||
// 'table_class_container2' : {
|
||||
// 'id' : 'on',
|
||||
// 'col1' : 'on'
|
||||
// },
|
||||
// }
|
||||
|
||||
// main name for key in web storage
|
||||
var COLUMN_SHIFTER_STORAGE_ACCESOR = "django_tables2_column_shifter";
|
||||
|
||||
// Return storage structure for shifter
|
||||
// If structure does'n exist in web storage
|
||||
// will be return empty object
|
||||
var get_column_shifter_storage = function(){
|
||||
var storage = localStorage.getItem(COLUMN_SHIFTER_STORAGE_ACCESOR);
|
||||
if (storage === null) {
|
||||
storage = {
|
||||
"drilldown-table": {
|
||||
"date": "off",
|
||||
"time": "off",
|
||||
"id": "off",
|
||||
"host": "off",
|
||||
"ident": "off",
|
||||
"channel": "off",
|
||||
"net": "off",
|
||||
"num": "off",
|
||||
"channel_nsfw": "off",
|
||||
"channel_category": "off",
|
||||
"channel_category_id": "off",
|
||||
"channel_category_nsfw": "off",
|
||||
"channel_id": "off",
|
||||
"guild_member_count": "off",
|
||||
"bot": "off",
|
||||
"msg_id": "off",
|
||||
"user": "off",
|
||||
"net_id": "off",
|
||||
"user_id": "off",
|
||||
"nick_id": "off",
|
||||
"status": "off",
|
||||
"num_users": "off",
|
||||
"num_chans": "off",
|
||||
"exemption": "off",
|
||||
// "version_sentiment": "off",
|
||||
"sentiment": "off",
|
||||
"num": "off",
|
||||
"online": "off",
|
||||
"mtype": "off",
|
||||
"realname": "off",
|
||||
"server": "off",
|
||||
"mtype": "off",
|
||||
"hidden": "off",
|
||||
"filename": "off",
|
||||
"file_md5": "off",
|
||||
"file_ext": "off",
|
||||
"file_size": "off",
|
||||
"lang_code": "off",
|
||||
"tokens": "off",
|
||||
"rule_id": "off",
|
||||
"index": "off",
|
||||
"meta": "off",
|
||||
"match_ts": "off",
|
||||
"batch_id": "off"
|
||||
//"lang_name": "off",
|
||||
// "words_noun": "off",
|
||||
// "words_adj": "off",
|
||||
// "words_verb": "off",
|
||||
// "words_adv": "off"
|
||||
},
|
||||
};
|
||||
} else {
|
||||
storage = JSON.parse(storage);
|
||||
}
|
||||
return storage;
|
||||
};
|
||||
|
||||
// Save structure in web storage
|
||||
var set_column_shifter_storage = function(storage){
|
||||
var json_storage = JSON.stringify(storage)
|
||||
localStorage.setItem(COLUMN_SHIFTER_STORAGE_ACCESOR, json_storage);
|
||||
};
|
||||
|
||||
// Remember state for single button
|
||||
var save_btn_state = function($btn){
|
||||
|
||||
// Take css class for container with table
|
||||
var table_class_container = $btn.data("table-class-container");
|
||||
// Take html object with table
|
||||
var $table_class_container = $("#" + table_class_container);
|
||||
// Take single button statne ("on" / "off")
|
||||
var state = $btn.data("state");
|
||||
// td-class is a real column name in table
|
||||
var td_class = $btn.data("td-class");
|
||||
var storage = get_column_shifter_storage();
|
||||
// Table id
|
||||
var id = $table_class_container.attr("id");
|
||||
|
||||
// Checking if the ID is already in storage
|
||||
if (id in storage) {
|
||||
data = storage[id]
|
||||
} else {
|
||||
data = {}
|
||||
storage[id] = data;
|
||||
}
|
||||
|
||||
// Save state for table column in storage
|
||||
data[td_class] = state;
|
||||
set_column_shifter_storage(storage);
|
||||
};
|
||||
|
||||
// Load states for buttons from storage for single tabel
|
||||
var load_states = function($table_class_container) {
|
||||
var storage = get_column_shifter_storage();
|
||||
// Table id
|
||||
var id = $table_class_container.attr("id");
|
||||
var data = {};
|
||||
|
||||
// Checking if the ID is already in storage
|
||||
if (id in storage) {
|
||||
data = storage[id]
|
||||
|
||||
// For each shifter button set state
|
||||
$table_class_container.find(".btn-shift-column").each(function(){
|
||||
var $btn = $(this);
|
||||
var td_class = $btn.data("td-class");
|
||||
|
||||
// If name of column is in store then get state
|
||||
// and set state
|
||||
if (td_class in data) {
|
||||
var state = data[td_class]
|
||||
set_btn_state($btn, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Show table content and hide spiner
|
||||
var show_table_content = function($table_class_container){
|
||||
$table_class_container.find("#loader").hide();
|
||||
$table_class_container.find("#table-container").show();
|
||||
};
|
||||
|
||||
// Load buttons states for all button in page
|
||||
var load_state_for_all_containters = function(){
|
||||
$(".column-shifter-container").each(function(){
|
||||
$table_class_container = $(this);
|
||||
|
||||
// Load states for all buttons in single container
|
||||
load_states($table_class_container);
|
||||
|
||||
// When states was loaded then table must be show and
|
||||
// loader (spiner) must be hide
|
||||
show_table_content($table_class_container);
|
||||
});
|
||||
};
|
||||
|
||||
// change visibility column for single button
|
||||
// if button has state "on" then show column
|
||||
// else then column will be hide
|
||||
shift_column = function( $btn ){
|
||||
// button state
|
||||
var state = $btn.data("state");
|
||||
|
||||
// td-class is a real column name in table
|
||||
var td_class = $btn.data("td-class");
|
||||
var table_class_container = $btn.data("table-class-container");
|
||||
var $table_class_container = $("#" + table_class_container);
|
||||
var $table = $table_class_container.find("table");
|
||||
var $cels = $table.find("." + td_class);
|
||||
|
||||
if ( state === "on" ) {
|
||||
$cels.show();
|
||||
} else {
|
||||
$cels.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// Shift visibility for all columns
|
||||
shift_columns = function(){
|
||||
var cols = $(".btn-shift-column");
|
||||
var i, len = cols.length;
|
||||
for (i=0; i < len; i++) {
|
||||
shift_column($(cols[i]));
|
||||
}
|
||||
};
|
||||
|
||||
// Set icon imgae visibility for button state
|
||||
var set_icon_for_state = function( $btn, state ) {
|
||||
if (state === "on") {
|
||||
$btn.find("span.uncheck").hide();
|
||||
$btn.find("span.check").show();
|
||||
} else {
|
||||
$btn.find("span.check").hide();
|
||||
$btn.find("span.uncheck").show();
|
||||
}
|
||||
};
|
||||
|
||||
// Set state for single button
|
||||
var set_btn_state = function($btn, state){
|
||||
$btn.data('state', state);
|
||||
set_icon_for_state($btn, state);
|
||||
}
|
||||
|
||||
// Change state for single button
|
||||
var change_btn_state = function($btn){
|
||||
var state = $btn.data("state");
|
||||
|
||||
if (state === "on") {
|
||||
state = "off"
|
||||
} else {
|
||||
state = "on"
|
||||
}
|
||||
set_btn_state($btn, state);
|
||||
};
|
||||
|
||||
// Run show/hide when click on button
|
||||
$(".btn-shift-column").on("click", function(event){
|
||||
var $btn = $(this);
|
||||
event.stopPropagation();
|
||||
change_btn_state($btn);
|
||||
shift_column($btn);
|
||||
save_btn_state($btn);
|
||||
});
|
||||
|
||||
// Load saved states for all tables
|
||||
load_state_for_all_containters();
|
||||
|
||||
// show or hide columns based on data from web storage
|
||||
shift_columns();
|
||||
|
||||
// Add API method for retrieving non-visible cols for table
|
||||
// Pass the 0-based index of the table or leave the parameter
|
||||
// empty to return the hidden cols for the 1st table found
|
||||
$.django_tables2_column_shifter_hidden = function(idx) {
|
||||
if(idx==undefined) {
|
||||
idx = 0;
|
||||
}
|
||||
return $('#table-container').eq(idx).find('.btn-shift-column').filter(function(z) {
|
||||
return $(this).data('state')=='off'
|
||||
}).map(function(z) {
|
||||
return $(this).data('td-class')
|
||||
}).toArray();
|
||||
}
|
||||
const event = new Event('restore-scroll');
|
||||
document.dispatchEvent(event);
|
||||
const event2 = new Event('load-widget-results');
|
||||
document.dispatchEvent(event2);
|
||||
|
||||
});
|
||||
139
core/static/js/compose-panel-core.js
Normal file
139
core/static/js/compose-panel-core.js
Normal file
@@ -0,0 +1,139 @@
|
||||
(function () {
|
||||
if (window.GIAComposePanelCore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const PANEL_SELECTOR = ".compose-shell[data-compose-panel='1']";
|
||||
window.giaComposePanels = window.giaComposePanels || {};
|
||||
|
||||
const collectPanels = function (root) {
|
||||
const panels = [];
|
||||
if (!root) {
|
||||
return panels;
|
||||
}
|
||||
if (root.matches && root.matches(PANEL_SELECTOR)) {
|
||||
panels.push(root);
|
||||
}
|
||||
if (root.querySelectorAll) {
|
||||
root.querySelectorAll(PANEL_SELECTOR).forEach(function (panel) {
|
||||
panels.push(panel);
|
||||
});
|
||||
}
|
||||
return panels;
|
||||
};
|
||||
|
||||
const toInt = function (value) {
|
||||
const parsed = parseInt(value || "0", 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const parseJsonSafe = function (value, fallback) {
|
||||
try {
|
||||
return JSON.parse(String(value || ""));
|
||||
} catch (_err) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const createNode = function (tagName, className, text) {
|
||||
const node = document.createElement(tagName);
|
||||
if (className) {
|
||||
node.className = className;
|
||||
}
|
||||
if (text !== undefined && text !== null) {
|
||||
node.textContent = String(text);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const normalizeSnippet = function (value) {
|
||||
const compact = String(value || "").replace(/\s+/g, " ").trim();
|
||||
if (!compact) {
|
||||
return "(no text)";
|
||||
}
|
||||
if (compact.length <= 120) {
|
||||
return compact;
|
||||
}
|
||||
return compact.slice(0, 117).trimEnd() + "...";
|
||||
};
|
||||
|
||||
const titleCase = function (value) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
if (raw === "whatsapp") {
|
||||
return "WhatsApp";
|
||||
}
|
||||
if (raw === "xmpp") {
|
||||
return "XMPP";
|
||||
}
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
};
|
||||
|
||||
const normalizeIdentifierForService = function (service, identifier) {
|
||||
const serviceKey = String(service || "").trim().toLowerCase();
|
||||
const raw = String(identifier || "").trim();
|
||||
if (serviceKey === "whatsapp" && raw.includes("@")) {
|
||||
return raw.split("@", 1)[0].trim();
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
const buildComposeUrl = function (renderMode, service, identifier, personId) {
|
||||
const serviceKey = String(service || "").trim().toLowerCase();
|
||||
const identifierValue = normalizeIdentifierForService(serviceKey, identifier);
|
||||
if (!serviceKey || !identifierValue) {
|
||||
return "";
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set("service", serviceKey);
|
||||
params.set("identifier", identifierValue);
|
||||
if (personId) {
|
||||
params.set("person", String(personId || "").trim());
|
||||
}
|
||||
return (renderMode === "page" ? "/compose/page/" : "/compose/widget/")
|
||||
+ "?"
|
||||
+ params.toString();
|
||||
};
|
||||
|
||||
const parseServiceMap = function (optionNode) {
|
||||
const fallbackService = String(
|
||||
(optionNode && optionNode.dataset && optionNode.dataset.service) || ""
|
||||
).trim().toLowerCase();
|
||||
const fallbackIdentifier = String((optionNode && optionNode.value) || "").trim();
|
||||
const fallback = {};
|
||||
if (fallbackService && fallbackIdentifier) {
|
||||
fallback[fallbackService] = fallbackIdentifier;
|
||||
}
|
||||
if (!optionNode || !optionNode.dataset) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = parseJsonSafe(optionNode.dataset.serviceMap || "{}", fallback);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = {};
|
||||
Object.keys(parsed).forEach(function (key) {
|
||||
const serviceKey = String(key || "").trim().toLowerCase();
|
||||
const identifierValue = String(parsed[key] || "").trim();
|
||||
if (serviceKey && identifierValue) {
|
||||
normalized[serviceKey] = identifierValue;
|
||||
}
|
||||
});
|
||||
return Object.keys(normalized).length ? normalized : fallback;
|
||||
};
|
||||
|
||||
window.GIAComposePanelCore = {
|
||||
PANEL_SELECTOR: PANEL_SELECTOR,
|
||||
buildComposeUrl: buildComposeUrl,
|
||||
collectPanels: collectPanels,
|
||||
createNode: createNode,
|
||||
normalizeIdentifierForService: normalizeIdentifierForService,
|
||||
normalizeSnippet: normalizeSnippet,
|
||||
parseJsonSafe: parseJsonSafe,
|
||||
parseServiceMap: parseServiceMap,
|
||||
titleCase: titleCase,
|
||||
toInt: toInt,
|
||||
};
|
||||
})();
|
||||
321
core/static/js/compose-panel-send.js
Normal file
321
core/static/js/compose-panel-send.js
Normal file
@@ -0,0 +1,321 @@
|
||||
(function () {
|
||||
if (window.GIAComposePanelSend) {
|
||||
return;
|
||||
}
|
||||
|
||||
const core = window.GIAComposePanelCore;
|
||||
if (!core) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createController = function (config) {
|
||||
const panel = config.panel;
|
||||
const panelId = config.panelId;
|
||||
const state = config.state;
|
||||
const thread = config.thread;
|
||||
const form = config.form;
|
||||
const textarea = config.textarea;
|
||||
const statusBox = config.statusBox;
|
||||
const manualConfirm = config.manualConfirm;
|
||||
const armInput = config.armInput;
|
||||
const confirmInput = config.confirmInput;
|
||||
const sendButton = config.sendButton;
|
||||
const sendCapable = config.sendCapable;
|
||||
const csrfToken = config.csrfToken;
|
||||
const queryParams = config.queryParams;
|
||||
const poll = config.poll;
|
||||
const clearReplyTarget = config.clearReplyTarget;
|
||||
const autosize = config.autosize;
|
||||
const flashCompose = config.flashCompose;
|
||||
const setStatus = config.setStatus;
|
||||
|
||||
let transientCancelButton = null;
|
||||
let persistentCancelWrap = null;
|
||||
|
||||
const postFormJson = async function (url, params) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRFToken": csrfToken,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const cancelSendRequest = function (commandId) {
|
||||
return postFormJson(
|
||||
String(panel.dataset.cancelSendUrl || ""),
|
||||
queryParams({ command_id: String(commandId || "") })
|
||||
);
|
||||
};
|
||||
|
||||
const hideTransientCancelButton = function () {
|
||||
if (!transientCancelButton) {
|
||||
return;
|
||||
}
|
||||
transientCancelButton.remove();
|
||||
transientCancelButton = null;
|
||||
};
|
||||
|
||||
const showTransientCancelButton = function () {
|
||||
if (!statusBox || transientCancelButton) {
|
||||
return;
|
||||
}
|
||||
transientCancelButton = core.createNode(
|
||||
"button",
|
||||
"button is-danger is-light is-small compose-cancel-send-btn",
|
||||
"Cancel Send"
|
||||
);
|
||||
transientCancelButton.type = "button";
|
||||
transientCancelButton.addEventListener("click", async function () {
|
||||
try {
|
||||
await cancelSendRequest("");
|
||||
} catch (_err) {
|
||||
// Ignore cancel failures.
|
||||
} finally {
|
||||
hideTransientCancelButton();
|
||||
}
|
||||
});
|
||||
statusBox.appendChild(transientCancelButton);
|
||||
};
|
||||
|
||||
const hidePersistentCancelButton = function () {
|
||||
if (!persistentCancelWrap) {
|
||||
return;
|
||||
}
|
||||
persistentCancelWrap.remove();
|
||||
persistentCancelWrap = null;
|
||||
};
|
||||
|
||||
const stopPendingCommandPolling = function () {
|
||||
if (state.pendingCommandPoll) {
|
||||
clearInterval(state.pendingCommandPoll);
|
||||
state.pendingCommandPoll = null;
|
||||
}
|
||||
state.pendingCommandId = "";
|
||||
state.pendingCommandAttempts = 0;
|
||||
state.pendingCommandStartedAt = 0;
|
||||
state.pendingCommandInFlight = false;
|
||||
};
|
||||
|
||||
const showPersistentCancelButton = function (commandId) {
|
||||
hidePersistentCancelButton();
|
||||
if (!statusBox) {
|
||||
return;
|
||||
}
|
||||
persistentCancelWrap = core.createNode("div", "compose-persistent-cancel");
|
||||
const button = core.createNode(
|
||||
"button",
|
||||
"button is-danger is-light is-small compose-persistent-cancel-btn",
|
||||
"Cancel Queued Send"
|
||||
);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", async function () {
|
||||
try {
|
||||
await cancelSendRequest(commandId);
|
||||
stopPendingCommandPolling();
|
||||
hidePersistentCancelButton();
|
||||
setStatus("Send cancelled.", "warning");
|
||||
await poll(true);
|
||||
} catch (_err) {
|
||||
hidePersistentCancelButton();
|
||||
}
|
||||
});
|
||||
persistentCancelWrap.appendChild(button);
|
||||
statusBox.appendChild(persistentCancelWrap);
|
||||
};
|
||||
|
||||
const pollPendingCommandResult = async function (commandId) {
|
||||
const url = new URL(
|
||||
String(panel.dataset.commandResultUrl || ""),
|
||||
window.location.origin
|
||||
);
|
||||
url.searchParams.set("service", thread.dataset.service || "");
|
||||
url.searchParams.set("command_id", commandId);
|
||||
url.searchParams.set("format", "json");
|
||||
const response = await fetch(url.toString(), {
|
||||
credentials: "same-origin",
|
||||
headers: { "HX-Request": "true" },
|
||||
});
|
||||
if (!response.ok || response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const startPendingCommandPolling = function (commandId) {
|
||||
if (!commandId) {
|
||||
return;
|
||||
}
|
||||
stopPendingCommandPolling();
|
||||
state.pendingCommandId = commandId;
|
||||
state.pendingCommandStartedAt = Date.now();
|
||||
showPersistentCancelButton(commandId);
|
||||
state.pendingCommandPoll = setInterval(async function () {
|
||||
if (state.pendingCommandInFlight) {
|
||||
return;
|
||||
}
|
||||
state.pendingCommandAttempts += 1;
|
||||
if (
|
||||
state.pendingCommandAttempts > 14
|
||||
|| (Date.now() - state.pendingCommandStartedAt) > 45000
|
||||
) {
|
||||
stopPendingCommandPolling();
|
||||
hidePersistentCancelButton();
|
||||
setStatus(
|
||||
"Send timed out waiting for runtime result. Please retry.",
|
||||
"warning"
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.pendingCommandInFlight = true;
|
||||
const payload = await pollPendingCommandResult(commandId);
|
||||
if (!payload || payload.pending !== false) {
|
||||
return;
|
||||
}
|
||||
const result = payload.result || {};
|
||||
stopPendingCommandPolling();
|
||||
hidePersistentCancelButton();
|
||||
if (result.ok) {
|
||||
setStatus("", "success");
|
||||
textarea.value = "";
|
||||
clearReplyTarget();
|
||||
autosize();
|
||||
flashCompose("is-send-success");
|
||||
await poll(true);
|
||||
return;
|
||||
}
|
||||
setStatus(String(result.error || "Send failed."), "danger");
|
||||
flashCompose("is-send-fail");
|
||||
await poll(true);
|
||||
} catch (_err) {
|
||||
// Ignore transient failures; the next poll can recover.
|
||||
} finally {
|
||||
state.pendingCommandInFlight = false;
|
||||
}
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
const updateManualSafety = function () {
|
||||
const confirmed = !!(manualConfirm && manualConfirm.checked);
|
||||
if (armInput) {
|
||||
armInput.value = confirmed ? "1" : "0";
|
||||
}
|
||||
if (confirmInput) {
|
||||
confirmInput.value = confirmed ? "1" : "0";
|
||||
}
|
||||
if (sendButton) {
|
||||
sendButton.disabled = !sendCapable || !confirmed;
|
||||
}
|
||||
};
|
||||
|
||||
const bindSendEvents = function () {
|
||||
textarea.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter" || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (sendButton && sendButton.disabled) {
|
||||
setStatus("Enable send confirmation before sending.", "warning");
|
||||
return;
|
||||
}
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
if (sendButton && sendButton.disabled) {
|
||||
return;
|
||||
}
|
||||
showTransientCancelButton();
|
||||
});
|
||||
|
||||
form.addEventListener("htmx:afterRequest", function () {
|
||||
hideTransientCancelButton();
|
||||
textarea.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const bindDocumentEvents = function () {
|
||||
state.eventHandler = function (event) {
|
||||
const detail = (event && event.detail) || {};
|
||||
const sourcePanelId = String(detail.panel_id || "");
|
||||
if (sourcePanelId && sourcePanelId !== panelId) {
|
||||
return;
|
||||
}
|
||||
poll(true);
|
||||
};
|
||||
document.body.addEventListener("composeMessageSent", state.eventHandler);
|
||||
|
||||
state.sendResultHandler = function (event) {
|
||||
const detail = (event && event.detail) || {};
|
||||
const sourcePanelId = String(detail.panel_id || "");
|
||||
if (sourcePanelId && sourcePanelId !== panelId) {
|
||||
return;
|
||||
}
|
||||
hideTransientCancelButton();
|
||||
if (detail.ok) {
|
||||
flashCompose("is-send-success");
|
||||
textarea.value = "";
|
||||
clearReplyTarget();
|
||||
autosize();
|
||||
poll(true);
|
||||
} else {
|
||||
flashCompose("is-send-fail");
|
||||
if (detail.message) {
|
||||
setStatus(detail.message, detail.level || "danger");
|
||||
}
|
||||
}
|
||||
textarea.focus();
|
||||
};
|
||||
document.body.addEventListener("composeSendResult", state.sendResultHandler);
|
||||
|
||||
state.commandIdHandler = function (event) {
|
||||
const detail = (event && event.detail) || {};
|
||||
const commandId = String(
|
||||
detail.command_id
|
||||
|| (detail.composeSendCommandId && detail.composeSendCommandId.command_id)
|
||||
|| ""
|
||||
).trim();
|
||||
if (commandId) {
|
||||
startPendingCommandPolling(commandId);
|
||||
}
|
||||
};
|
||||
document.body.addEventListener(
|
||||
"composeSendCommandId",
|
||||
state.commandIdHandler
|
||||
);
|
||||
};
|
||||
|
||||
const init = function () {
|
||||
bindSendEvents();
|
||||
bindDocumentEvents();
|
||||
if (manualConfirm) {
|
||||
manualConfirm.addEventListener("change", updateManualSafety);
|
||||
manualConfirm.dispatchEvent(new Event("change"));
|
||||
} else {
|
||||
updateManualSafety();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
init: init,
|
||||
resetForContextSwitch: function () {
|
||||
stopPendingCommandPolling();
|
||||
hidePersistentCancelButton();
|
||||
hideTransientCancelButton();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
window.GIAComposePanelSend = {
|
||||
createController: createController,
|
||||
};
|
||||
})();
|
||||
504
core/static/js/compose-panel-thread.js
Normal file
504
core/static/js/compose-panel-thread.js
Normal file
@@ -0,0 +1,504 @@
|
||||
(function () {
|
||||
if (window.GIAComposePanelThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const core = window.GIAComposePanelCore;
|
||||
if (!core) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createController = function (config) {
|
||||
const panel = config.panel;
|
||||
const state = config.state;
|
||||
const thread = config.thread;
|
||||
const textarea = config.textarea;
|
||||
const typingNode = config.typingNode;
|
||||
const hiddenReplyTo = config.hiddenReplyTo;
|
||||
const replyBanner = config.replyBanner;
|
||||
const replyBannerText = config.replyBannerText;
|
||||
const replyClearBtn = config.replyClearBtn;
|
||||
const platformSelect = config.platformSelect;
|
||||
const contactSelect = config.contactSelect;
|
||||
const hiddenService = config.hiddenService;
|
||||
const hiddenIdentifier = config.hiddenIdentifier;
|
||||
const hiddenPerson = config.hiddenPerson;
|
||||
const metaLine = config.metaLine;
|
||||
const renderMode = config.renderMode;
|
||||
|
||||
let lastTs = core.toInt(thread.dataset.lastTs);
|
||||
let beforeContextReset = null;
|
||||
|
||||
const nearBottom = function () {
|
||||
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;
|
||||
};
|
||||
|
||||
const scrollToBottom = function (force) {
|
||||
if (force || nearBottom()) {
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const queryParams = function (extraParams) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("service", thread.dataset.service || "");
|
||||
params.set("identifier", thread.dataset.identifier || "");
|
||||
if (thread.dataset.person) {
|
||||
params.set("person", thread.dataset.person);
|
||||
}
|
||||
params.set("limit", thread.dataset.limit || "60");
|
||||
const extras =
|
||||
extraParams && typeof extraParams === "object" ? extraParams : {};
|
||||
Object.keys(extras).forEach(function (key) {
|
||||
const value = extras[key];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
params.set(String(key), String(value));
|
||||
});
|
||||
return params;
|
||||
};
|
||||
|
||||
const ensureEmptyState = function (messageText) {
|
||||
if (thread.querySelector(".compose-row")) {
|
||||
const empty = thread.querySelector(".compose-empty");
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let empty = thread.querySelector(".compose-empty");
|
||||
if (!empty) {
|
||||
empty = core.createNode("p", "compose-empty");
|
||||
thread.appendChild(empty);
|
||||
}
|
||||
empty.textContent = String(
|
||||
messageText || "No stored messages for this contact yet."
|
||||
);
|
||||
};
|
||||
|
||||
const rowByMessageId = function (messageId) {
|
||||
const targetId = String(messageId || "").trim();
|
||||
if (!targetId) {
|
||||
return null;
|
||||
}
|
||||
return thread.querySelector(
|
||||
'.compose-row[data-message-id="' + targetId + '"]'
|
||||
);
|
||||
};
|
||||
|
||||
const clearReplySelectionClass = function () {
|
||||
thread
|
||||
.querySelectorAll(".compose-row.compose-reply-selected")
|
||||
.forEach(function (row) {
|
||||
row.classList.remove("compose-reply-selected");
|
||||
});
|
||||
};
|
||||
|
||||
const flashReplyTarget = function (row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
row.classList.remove("is-target-flash");
|
||||
void row.offsetWidth;
|
||||
row.classList.add("is-target-flash");
|
||||
window.setTimeout(function () {
|
||||
row.classList.remove("is-target-flash");
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const clearReplyTarget = function () {
|
||||
if (hiddenReplyTo) {
|
||||
hiddenReplyTo.value = "";
|
||||
}
|
||||
if (replyBanner) {
|
||||
replyBanner.classList.add("is-hidden");
|
||||
}
|
||||
if (replyBannerText) {
|
||||
replyBannerText.textContent = "";
|
||||
}
|
||||
clearReplySelectionClass();
|
||||
};
|
||||
|
||||
const setReplyTarget = function (messageId, preview) {
|
||||
const targetId = String(messageId || "").trim();
|
||||
if (!targetId) {
|
||||
clearReplyTarget();
|
||||
return;
|
||||
}
|
||||
const row = rowByMessageId(targetId);
|
||||
const snippet = core.normalizeSnippet(
|
||||
(row && row.dataset ? row.dataset.replySnippet : "") || preview || ""
|
||||
);
|
||||
if (hiddenReplyTo) {
|
||||
hiddenReplyTo.value = targetId;
|
||||
}
|
||||
if (replyBannerText) {
|
||||
replyBannerText.textContent = snippet;
|
||||
}
|
||||
if (replyBanner) {
|
||||
replyBanner.classList.remove("is-hidden");
|
||||
}
|
||||
clearReplySelectionClass();
|
||||
if (row) {
|
||||
row.classList.add("compose-reply-selected");
|
||||
}
|
||||
};
|
||||
|
||||
const parseMessageRows = function (html) {
|
||||
const markup = String(html || "").trim();
|
||||
if (!markup) {
|
||||
return [];
|
||||
}
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = markup;
|
||||
return Array.from(template.content.querySelectorAll(".compose-row"));
|
||||
};
|
||||
|
||||
const insertRowByTs = function (row) {
|
||||
const newTs = core.toInt(row.dataset.ts);
|
||||
const rows = Array.from(thread.querySelectorAll(".compose-row"));
|
||||
if (!rows.length) {
|
||||
thread.appendChild(row);
|
||||
return;
|
||||
}
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
const existing = rows[index];
|
||||
if (core.toInt(existing.dataset.ts) <= newTs) {
|
||||
if (existing.nextSibling) {
|
||||
thread.insertBefore(row, existing.nextSibling);
|
||||
} else {
|
||||
thread.appendChild(row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
thread.insertBefore(row, rows[0]);
|
||||
};
|
||||
|
||||
const upsertMessageRow = function (row) {
|
||||
if (!row || !row.classList || !row.classList.contains("compose-row")) {
|
||||
return;
|
||||
}
|
||||
const messageId = String((row.dataset && row.dataset.messageId) || "").trim();
|
||||
if (messageId) {
|
||||
const existing = rowByMessageId(messageId);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
const empty = thread.querySelector(".compose-empty");
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
insertRowByTs(row);
|
||||
lastTs = Math.max(lastTs, core.toInt(row.dataset.ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
};
|
||||
|
||||
const appendMessageHtml = function (html, forceScroll) {
|
||||
const rows = parseMessageRows(html);
|
||||
const shouldStick = forceScroll || nearBottom();
|
||||
rows.forEach(function (msg) {
|
||||
upsertMessageRow(msg);
|
||||
});
|
||||
if (rows.length) {
|
||||
scrollToBottom(shouldStick);
|
||||
}
|
||||
ensureEmptyState();
|
||||
};
|
||||
|
||||
const applyTyping = function (payload) {
|
||||
if (!typingNode) {
|
||||
return;
|
||||
}
|
||||
const typingPayload =
|
||||
payload && typeof payload === "object" ? payload : {};
|
||||
if (!typingPayload.typing) {
|
||||
typingNode.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
const displayName = String(typingPayload.display_name || "").trim();
|
||||
typingNode.textContent = (displayName || "Contact") + " is typing...";
|
||||
typingNode.classList.remove("is-hidden");
|
||||
};
|
||||
|
||||
const closeSocket = function () {
|
||||
if (!state.socket) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.socket.close();
|
||||
} catch (_err) {
|
||||
// Ignore close failures.
|
||||
}
|
||||
state.socket = null;
|
||||
state.websocketReady = false;
|
||||
};
|
||||
|
||||
const poll = async function (forceScroll) {
|
||||
if (state.polling || state.websocketReady) {
|
||||
return;
|
||||
}
|
||||
state.polling = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
thread.dataset.pollUrl + "?" + queryParams({ after_ts: String(lastTs) }),
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
appendMessageHtml(payload.messages_html || "", forceScroll);
|
||||
applyTyping(payload.typing);
|
||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||
lastTs = Math.max(lastTs, core.toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("compose poll error", err);
|
||||
} finally {
|
||||
state.polling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setupWebSocket = function () {
|
||||
const wsPath = String(thread.dataset.wsUrl || "").trim();
|
||||
if (!wsPath || !window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const socket = new WebSocket(protocol + window.location.host + wsPath);
|
||||
state.socket = socket;
|
||||
socket.onopen = function () {
|
||||
state.websocketReady = true;
|
||||
try {
|
||||
socket.send(JSON.stringify({ kind: "sync", last_ts: lastTs }));
|
||||
} catch (_err) {
|
||||
// Ignore sync send errors.
|
||||
}
|
||||
};
|
||||
socket.onmessage = function (event) {
|
||||
const payload = core.parseJsonSafe(event.data || "{}", {});
|
||||
appendMessageHtml(payload.messages_html || "", false);
|
||||
applyTyping(payload.typing);
|
||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||
lastTs = Math.max(lastTs, core.toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
}
|
||||
};
|
||||
socket.onclose = function () {
|
||||
state.websocketReady = false;
|
||||
if (state.socket === socket) {
|
||||
state.socket = null;
|
||||
}
|
||||
};
|
||||
socket.onerror = function () {
|
||||
state.websocketReady = false;
|
||||
};
|
||||
} catch (_err) {
|
||||
state.websocketReady = false;
|
||||
state.socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
const switchThreadContext = function (
|
||||
nextService,
|
||||
nextIdentifier,
|
||||
nextPersonId,
|
||||
nextUrl
|
||||
) {
|
||||
const service = String(nextService || "").trim().toLowerCase();
|
||||
const identifier = core.normalizeIdentifierForService(
|
||||
service,
|
||||
nextIdentifier
|
||||
);
|
||||
const personId = String(nextPersonId || "").trim();
|
||||
if (!service || !identifier) {
|
||||
return;
|
||||
}
|
||||
if (renderMode === "page" && nextUrl) {
|
||||
window.location.assign(String(nextUrl));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
String(thread.dataset.service || "").toLowerCase() === service
|
||||
&& String(thread.dataset.identifier || "") === identifier
|
||||
&& String(thread.dataset.person || "") === personId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (typeof beforeContextReset === "function") {
|
||||
beforeContextReset();
|
||||
}
|
||||
thread.dataset.service = service;
|
||||
thread.dataset.identifier = identifier;
|
||||
if (personId) {
|
||||
thread.dataset.person = personId;
|
||||
} else {
|
||||
delete thread.dataset.person;
|
||||
}
|
||||
if (hiddenService) {
|
||||
hiddenService.value = service;
|
||||
}
|
||||
if (hiddenIdentifier) {
|
||||
hiddenIdentifier.value = identifier;
|
||||
}
|
||||
if (hiddenPerson) {
|
||||
hiddenPerson.value = personId;
|
||||
}
|
||||
if (metaLine) {
|
||||
metaLine.textContent = core.titleCase(service) + " · " + identifier;
|
||||
}
|
||||
clearReplyTarget();
|
||||
closeSocket();
|
||||
lastTs = 0;
|
||||
thread.dataset.lastTs = "0";
|
||||
thread.innerHTML = "";
|
||||
ensureEmptyState("Loading messages...");
|
||||
applyTyping({ typing: false });
|
||||
poll(true);
|
||||
setupWebSocket();
|
||||
};
|
||||
|
||||
const bindContextSelectors = function () {
|
||||
if (platformSelect) {
|
||||
platformSelect.addEventListener("change", function () {
|
||||
const selected = platformSelect.options[platformSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const targetUrl = core.buildComposeUrl(
|
||||
renderMode,
|
||||
selected.value || "",
|
||||
selected.dataset.identifier || "",
|
||||
selected.dataset.person || ""
|
||||
);
|
||||
switchThreadContext(
|
||||
selected.value || "",
|
||||
selected.dataset.identifier || "",
|
||||
selected.dataset.person || "",
|
||||
targetUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (contactSelect) {
|
||||
contactSelect.addEventListener("change", function () {
|
||||
const selected = contactSelect.options[contactSelect.selectedIndex];
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const serviceMap = core.parseServiceMap(selected);
|
||||
const currentService = String(thread.dataset.service || "").toLowerCase();
|
||||
const availableServices = Object.keys(serviceMap);
|
||||
let selectedService = currentService || String(selected.dataset.service || "");
|
||||
let selectedIdentifier = String(serviceMap[selectedService] || "").trim();
|
||||
if (!selectedIdentifier) {
|
||||
selectedService = String(
|
||||
selected.dataset.service || selectedService
|
||||
).trim().toLowerCase();
|
||||
selectedIdentifier = String(serviceMap[selectedService] || "").trim();
|
||||
}
|
||||
if (!selectedIdentifier && availableServices.length) {
|
||||
selectedService = availableServices[0];
|
||||
selectedIdentifier = String(serviceMap[selectedService] || "").trim();
|
||||
}
|
||||
const targetUrl = core.buildComposeUrl(
|
||||
renderMode,
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selected.dataset.person || ""
|
||||
);
|
||||
switchThreadContext(
|
||||
selectedService,
|
||||
selectedIdentifier,
|
||||
selected.dataset.person || "",
|
||||
targetUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bindThreadEvents = function () {
|
||||
if (replyClearBtn) {
|
||||
replyClearBtn.addEventListener("click", function () {
|
||||
clearReplyTarget();
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
thread.addEventListener("click", function (event) {
|
||||
const replyLink =
|
||||
event.target.closest && event.target.closest(".compose-reply-link");
|
||||
if (replyLink) {
|
||||
const replyRef = replyLink.closest(".compose-reply-ref");
|
||||
const targetId = String(
|
||||
(replyRef && replyRef.dataset && replyRef.dataset.replyTargetId) || ""
|
||||
).trim();
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
const targetRow = rowByMessageId(targetId);
|
||||
if (!targetRow) {
|
||||
return;
|
||||
}
|
||||
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
flashReplyTarget(targetRow);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyButton =
|
||||
event.target.closest && event.target.closest(".compose-reply-btn");
|
||||
if (!replyButton) {
|
||||
return;
|
||||
}
|
||||
const row = replyButton.closest(".compose-row");
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || "");
|
||||
textarea.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const init = function () {
|
||||
bindThreadEvents();
|
||||
bindContextSelectors();
|
||||
applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {}));
|
||||
ensureEmptyState();
|
||||
scrollToBottom(true);
|
||||
setupWebSocket();
|
||||
|
||||
state.pollTimer = setInterval(function () {
|
||||
if (!document.getElementById(config.panelId)) {
|
||||
if (window.GIAComposePanel) {
|
||||
window.GIAComposePanel.destroyPanel(config.panelId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
poll(false);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
return {
|
||||
clearReplyTarget: clearReplyTarget,
|
||||
init: init,
|
||||
poll: poll,
|
||||
queryParams: queryParams,
|
||||
setBeforeContextReset: function (callback) {
|
||||
beforeContextReset = callback;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
window.GIAComposePanelThread = {
|
||||
createController: createController,
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
16
core/static/js/gridstack.min.js
vendored
16
core/static/js/gridstack.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
core/static/js/hyperscript.min.js
vendored
1
core/static/js/hyperscript.min.js
vendored
File diff suppressed because one or more lines are too long
2
core/static/js/jquery.min.js
vendored
2
core/static/js/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
2
core/static/js/magnet.min.js
vendored
2
core/static/js/magnet.min.js
vendored
File diff suppressed because one or more lines are too long
1182
core/static/js/workspace-shell.js
Normal file
1182
core/static/js/workspace-shell.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user