427 lines
16 KiB
JavaScript
427 lines
16 KiB
JavaScript
// Fusion Chatter Enhance
|
|
// Copyright 2024-2026 Nexa Systems Inc.
|
|
// License OPL-1
|
|
//
|
|
// Resizable & collapsible chatter panel with per-user position preference.
|
|
//
|
|
// ARCHITECTURE:
|
|
// 1. On script load: read localStorage, inject <style> for anti-flash.
|
|
// 2. On DOMContentLoaded: read odoo.__session_info__ (server-injected),
|
|
// sync to localStorage, then apply the definitive layout.
|
|
// 3. MutationObserver re-applies layout on SPA navigations.
|
|
// 4. No RPC calls needed -- preferences come from session_info.
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var STORAGE_KEY_WIDTH = 'fce_chatter_width';
|
|
var STORAGE_KEY_HIDDEN = 'fce_chatter_hidden';
|
|
var STORAGE_KEY_POSITION = 'fce_chatter_position';
|
|
var MIN_CHATTER_PCT = 15;
|
|
var MAX_CHATTER_PCT = 50;
|
|
var DEFAULT_CHATTER_PCT = 20;
|
|
var STYLE_TAG_ID = 'fce-width-override';
|
|
|
|
var TOOLTIPS = {
|
|
'.o-mail-Chatter-sendMessage': 'Send Message',
|
|
'.o-mail-Chatter-logNote': 'Log Note',
|
|
'button[data-hotkey="shift+w"]': 'WhatsApp',
|
|
'.o-mail-Chatter-activity': 'Schedule Activity',
|
|
'.fusion-notes-mic-btn': 'Record Voice Note',
|
|
'.o-mail-Chatter-messageAuthorizer': 'Message Authorizer',
|
|
};
|
|
|
|
var _enhanceTimer = null;
|
|
var _sessionSynced = false;
|
|
|
|
// =========================================================================
|
|
// EARLY <style> INJECTION (runs before DOM is ready)
|
|
// =========================================================================
|
|
|
|
var SEL_RENDERER = '.o_action_manager .o_form_view .o_form_renderer';
|
|
var SEL_SHEET = SEL_RENDERER + ':not(.fce-chatter-bottom) > .o_form_sheet_bg';
|
|
var SEL_CHATTER = SEL_RENDERER + ':not(.fce-chatter-bottom) > .o-mail-ChatterContainer, ' +
|
|
SEL_RENDERER + ':not(.fce-chatter-bottom) > .o-mail-Form-chatter, ' +
|
|
SEL_RENDERER + ':not(.fce-chatter-bottom) > .o-aside';
|
|
|
|
function buildWidthCss(chatterPct) {
|
|
var formPct = 100 - chatterPct;
|
|
return '@media (min-width:992px){' +
|
|
SEL_SHEET + '{flex:0 0 ' + formPct + '% !important;width:' + formPct + '% !important;max-width:' + formPct + '% !important;}' +
|
|
SEL_CHATTER + '{flex:0 0 ' + chatterPct + '% !important;width:' + chatterPct + '% !important;}' +
|
|
'}';
|
|
}
|
|
|
|
function injectOrUpdateStyleTag(css) {
|
|
var tag = document.getElementById(STYLE_TAG_ID);
|
|
if (!tag) {
|
|
tag = document.createElement('style');
|
|
tag.id = STYLE_TAG_ID;
|
|
tag.setAttribute('type', 'text/css');
|
|
(document.head || document.documentElement).appendChild(tag);
|
|
}
|
|
tag.textContent = css;
|
|
}
|
|
|
|
// =========================================================================
|
|
// SESSION INFO READER
|
|
// =========================================================================
|
|
|
|
function getSessionInfo() {
|
|
try {
|
|
var o = window.odoo;
|
|
if (!o) return null;
|
|
return o.__session_info__ || o.session_info || null;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
function syncFromSessionInfo() {
|
|
if (_sessionSynced) return;
|
|
var si = getSessionInfo();
|
|
if (!si || !si.uid) return;
|
|
_sessionSynced = true;
|
|
if (si.chatter_position) {
|
|
localStorage.setItem(STORAGE_KEY_POSITION, si.chatter_position);
|
|
}
|
|
if (typeof si.chatter_hidden === 'boolean') {
|
|
localStorage.setItem(STORAGE_KEY_HIDDEN, si.chatter_hidden ? '1' : '0');
|
|
}
|
|
}
|
|
|
|
function applyEarlyStyles() {
|
|
syncFromSessionInfo();
|
|
var pos = localStorage.getItem(STORAGE_KEY_POSITION) || 'side';
|
|
if (pos === 'bottom') {
|
|
injectOrUpdateStyleTag('');
|
|
return;
|
|
}
|
|
if (localStorage.getItem(STORAGE_KEY_HIDDEN) === '1') {
|
|
injectOrUpdateStyleTag(buildWidthCss(0));
|
|
return;
|
|
}
|
|
var raw = localStorage.getItem(STORAGE_KEY_WIDTH);
|
|
var pct = raw ? parseFloat(raw) : NaN;
|
|
if (isNaN(pct) || pct < MIN_CHATTER_PCT || pct > MAX_CHATTER_PCT) pct = DEFAULT_CHATTER_PCT;
|
|
injectOrUpdateStyleTag(buildWidthCss(pct));
|
|
}
|
|
|
|
applyEarlyStyles();
|
|
|
|
// =========================================================================
|
|
// WRITE HELPERS (save to backend)
|
|
// =========================================================================
|
|
|
|
function getSessionUid() {
|
|
var si = getSessionInfo();
|
|
return si && si.uid ? si.uid : null;
|
|
}
|
|
|
|
function rpcCall(model, method, args, kwargs, callback) {
|
|
try {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/web/dataset/call_kw/' + model + '/' + method, true);
|
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
xhr.onreadystatechange = function () {
|
|
if (xhr.readyState !== 4) return;
|
|
if (xhr.status !== 200) { if (callback) callback(null); return; }
|
|
try {
|
|
var resp = JSON.parse(xhr.responseText);
|
|
if (resp && resp.error) { if (callback) callback(null); return; }
|
|
if (callback) callback(resp && resp.result);
|
|
} catch (_) {
|
|
if (callback) callback(null);
|
|
}
|
|
};
|
|
xhr.send(JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method: 'call',
|
|
params: { model: model, method: method, args: args, kwargs: kwargs || {} },
|
|
}));
|
|
} catch (_) {
|
|
if (callback) callback(null);
|
|
}
|
|
}
|
|
|
|
function saveHiddenPref(hidden) {
|
|
localStorage.setItem(STORAGE_KEY_HIDDEN, hidden ? '1' : '0');
|
|
var uid = getSessionUid();
|
|
if (!uid) return;
|
|
rpcCall('res.users', 'write', [[uid], { chatter_hidden: hidden }], {}, null);
|
|
}
|
|
|
|
// =========================================================================
|
|
// STORAGE HELPERS
|
|
// =========================================================================
|
|
|
|
function getStoredWidth() {
|
|
var v = localStorage.getItem(STORAGE_KEY_WIDTH);
|
|
var n = v ? parseFloat(v) : NaN;
|
|
if (isNaN(n) || n < MIN_CHATTER_PCT || n > MAX_CHATTER_PCT) return DEFAULT_CHATTER_PCT;
|
|
return n;
|
|
}
|
|
|
|
function getStoredPosition() {
|
|
return localStorage.getItem(STORAGE_KEY_POSITION) || 'side';
|
|
}
|
|
|
|
function isHidden() {
|
|
return localStorage.getItem(STORAGE_KEY_HIDDEN) === '1';
|
|
}
|
|
|
|
// =========================================================================
|
|
// DOM FINDERS
|
|
// =========================================================================
|
|
|
|
function findFormRenderer() {
|
|
return document.querySelector('.o_action_manager .o_form_view .o_form_renderer');
|
|
}
|
|
|
|
function findChatterEl(renderer) {
|
|
if (!renderer) return null;
|
|
return renderer.querySelector('.o-mail-ChatterContainer') ||
|
|
renderer.querySelector('.o-mail-Form-chatter') ||
|
|
renderer.querySelector('.o-aside');
|
|
}
|
|
|
|
function findSheetBg(renderer) {
|
|
if (!renderer) return null;
|
|
return renderer.querySelector('.o_form_sheet_bg');
|
|
}
|
|
|
|
// =========================================================================
|
|
// WIDTH APPLICATION
|
|
// =========================================================================
|
|
|
|
function applyWidths(renderer, chatterPct) {
|
|
injectOrUpdateStyleTag(buildWidthCss(chatterPct));
|
|
var sheet = findSheetBg(renderer);
|
|
var chatter = findChatterEl(renderer);
|
|
if (!sheet || !chatter) return;
|
|
var formPct = 100 - chatterPct;
|
|
sheet.style.flex = '0 0 ' + formPct + '%';
|
|
sheet.style.width = formPct + '%';
|
|
sheet.style.maxWidth = formPct + '%';
|
|
sheet.style.minWidth = '0';
|
|
chatter.style.flex = '0 0 ' + chatterPct + '%';
|
|
chatter.style.width = chatterPct + '%';
|
|
}
|
|
|
|
function clearInlineWidths(renderer) {
|
|
var sheet = findSheetBg(renderer);
|
|
var chatter = findChatterEl(renderer);
|
|
if (sheet) sheet.removeAttribute('style');
|
|
if (chatter) chatter.removeAttribute('style');
|
|
}
|
|
|
|
// =========================================================================
|
|
// RESIZE HANDLE
|
|
// =========================================================================
|
|
|
|
function injectResizeHandle(renderer) {
|
|
if (renderer.querySelector('.fce-resize-handle')) return;
|
|
var chatter = findChatterEl(renderer);
|
|
if (!chatter) return;
|
|
|
|
var handle = document.createElement('div');
|
|
handle.className = 'fce-resize-handle';
|
|
chatter.parentNode.insertBefore(handle, chatter);
|
|
|
|
var startX = 0;
|
|
var startWidth = 0;
|
|
|
|
function onMouseDown(e) {
|
|
e.preventDefault();
|
|
startX = e.clientX;
|
|
startWidth = chatter.getBoundingClientRect().width;
|
|
handle.classList.add('fce-dragging');
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
var delta = startX - e.clientX;
|
|
var rendererWidth = renderer.getBoundingClientRect().width;
|
|
var newWidth = startWidth + delta;
|
|
var pct = (newWidth / rendererWidth) * 100;
|
|
pct = Math.max(MIN_CHATTER_PCT, Math.min(MAX_CHATTER_PCT, pct));
|
|
applyWidths(renderer, pct);
|
|
}
|
|
|
|
function onMouseUp() {
|
|
handle.classList.remove('fce-dragging');
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
|
|
var rendererWidth = renderer.getBoundingClientRect().width;
|
|
var chatterWidth = chatter.getBoundingClientRect().width;
|
|
var pct = Math.round((chatterWidth / rendererWidth) * 100);
|
|
localStorage.setItem(STORAGE_KEY_WIDTH, String(pct));
|
|
injectOrUpdateStyleTag(buildWidthCss(pct));
|
|
}
|
|
|
|
handle.addEventListener('mousedown', onMouseDown);
|
|
}
|
|
|
|
// =========================================================================
|
|
// TOGGLE / EXPAND BUTTONS
|
|
// =========================================================================
|
|
|
|
function injectToggleBtn(renderer) {
|
|
if (renderer.querySelector('.fce-toggle-btn') || renderer.querySelector('.fce-expand-btn')) return;
|
|
var chatter = findChatterEl(renderer);
|
|
if (!chatter) return;
|
|
|
|
var btn = document.createElement('button');
|
|
btn.className = 'fce-toggle-btn';
|
|
btn.type = 'button';
|
|
btn.innerHTML = '<i class="fa fa-chevron-right"></i>';
|
|
btn.title = 'Hide chatter';
|
|
chatter.style.position = 'relative';
|
|
chatter.appendChild(btn);
|
|
|
|
btn.addEventListener('click', function () {
|
|
saveHiddenPref(true);
|
|
renderer.classList.add('fce-chatter-hidden');
|
|
injectOrUpdateStyleTag(buildWidthCss(0));
|
|
btn.remove();
|
|
injectExpandBtn(renderer);
|
|
});
|
|
}
|
|
|
|
function injectExpandBtn(renderer) {
|
|
if (renderer.querySelector('.fce-expand-btn')) return;
|
|
|
|
var btn = document.createElement('button');
|
|
btn.className = 'fce-expand-btn';
|
|
btn.type = 'button';
|
|
btn.innerHTML = '<i class="fa fa-chevron-left"></i>';
|
|
btn.title = 'Show chatter';
|
|
renderer.appendChild(btn);
|
|
|
|
btn.addEventListener('click', function () {
|
|
saveHiddenPref(false);
|
|
renderer.classList.remove('fce-chatter-hidden');
|
|
btn.remove();
|
|
var savedPct = getStoredWidth();
|
|
applyWidths(renderer, savedPct);
|
|
injectResizeHandle(renderer);
|
|
injectToggleBtn(renderer);
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// TOOLTIPS
|
|
// =========================================================================
|
|
|
|
function applyTooltips() {
|
|
var keys = Object.keys(TOOLTIPS);
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var selector = keys[i];
|
|
var title = TOOLTIPS[selector];
|
|
var els = document.querySelectorAll(selector);
|
|
for (var j = 0; j < els.length; j++) {
|
|
if (!els[j].getAttribute('title')) els[j].setAttribute('title', title);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// LAYOUT APPLICATION
|
|
// =========================================================================
|
|
|
|
function applyLayout(renderer) {
|
|
var pos = getStoredPosition();
|
|
var hidden = isHidden();
|
|
|
|
if (pos === 'bottom') {
|
|
renderer.classList.add('fce-chatter-bottom');
|
|
renderer.classList.remove('fce-chatter-hidden');
|
|
injectOrUpdateStyleTag('');
|
|
removeEl(renderer, '.fce-resize-handle');
|
|
removeEl(renderer, '.fce-toggle-btn');
|
|
removeEl(renderer, '.fce-expand-btn');
|
|
renderer.style.display = 'block';
|
|
var sheet = findSheetBg(renderer);
|
|
var chatter = findChatterEl(renderer);
|
|
if (sheet) {
|
|
sheet.style.flex = 'none';
|
|
sheet.style.width = '100%';
|
|
sheet.style.maxWidth = '100%';
|
|
sheet.style.minWidth = '100%';
|
|
}
|
|
if (chatter) {
|
|
chatter.style.flex = 'none';
|
|
chatter.style.width = '100%';
|
|
chatter.style.maxWidth = '100%';
|
|
chatter.style.minWidth = '100%';
|
|
}
|
|
return;
|
|
}
|
|
|
|
renderer.classList.remove('fce-chatter-bottom');
|
|
renderer.style.display = '';
|
|
|
|
if (hidden) {
|
|
renderer.classList.add('fce-chatter-hidden');
|
|
injectOrUpdateStyleTag(buildWidthCss(0));
|
|
removeEl(renderer, '.fce-resize-handle');
|
|
removeEl(renderer, '.fce-toggle-btn');
|
|
injectExpandBtn(renderer);
|
|
return;
|
|
}
|
|
|
|
renderer.classList.remove('fce-chatter-hidden');
|
|
removeEl(renderer, '.fce-expand-btn');
|
|
var savedPct = getStoredWidth();
|
|
applyWidths(renderer, savedPct);
|
|
injectResizeHandle(renderer);
|
|
injectToggleBtn(renderer);
|
|
applyTooltips();
|
|
}
|
|
|
|
function removeEl(renderer, selector) {
|
|
var el = renderer.querySelector(selector);
|
|
if (el) el.remove();
|
|
}
|
|
|
|
// =========================================================================
|
|
// MAIN ORCHESTRATOR
|
|
// =========================================================================
|
|
|
|
function enhance() {
|
|
var renderer = findFormRenderer();
|
|
if (!renderer) return;
|
|
var chatter = findChatterEl(renderer);
|
|
if (!chatter) return;
|
|
if (window.innerWidth < 992) return;
|
|
|
|
syncFromSessionInfo();
|
|
applyLayout(renderer);
|
|
}
|
|
|
|
function debouncedEnhance() {
|
|
if (_enhanceTimer) clearTimeout(_enhanceTimer);
|
|
_enhanceTimer = setTimeout(enhance, 150);
|
|
}
|
|
|
|
var observer = new MutationObserver(debouncedEnhance);
|
|
|
|
function boot() {
|
|
syncFromSessionInfo();
|
|
applyEarlyStyles();
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
enhance();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', boot);
|
|
} else {
|
|
boot();
|
|
}
|
|
})();
|