domma-cms 1.6.57 → 1.6.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -362,3 +362,92 @@ export function holdEscapeOn(so) {
|
|
|
362
362
|
if (hold.count === 0) so.options.closeOnEscape = hold.original;
|
|
363
363
|
};
|
|
364
364
|
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Edition extensions
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Menu items an edition contributed (`extensions.folderItems` /
|
|
372
|
+
* `mailboxItems`), made safe to merge into a core menu.
|
|
373
|
+
*
|
|
374
|
+
* Whatever came back is checked rather than trusted: anything that is not a
|
|
375
|
+
* divider or a labelled item with a function action is dropped, leading,
|
|
376
|
+
* trailing and doubled dividers go, and every action is wrapped by `wrap` -
|
|
377
|
+
* which the view uses to run it on the next task (an item picked with Enter
|
|
378
|
+
* that opens a dialog must not hand that dialog the same Enter) and to turn
|
|
379
|
+
* a throw into a toast. `disabled` may be a boolean or a function.
|
|
380
|
+
*
|
|
381
|
+
* @param {*} list - what the extension returned
|
|
382
|
+
* @param {(action: Function) => Function} [wrap]
|
|
383
|
+
* @returns {object[]} Domma context-menu items
|
|
384
|
+
*/
|
|
385
|
+
export function extensionMenuItems(list, wrap = (fn) => fn) {
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
388
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
389
|
+
if (entry.type === 'divider') {
|
|
390
|
+
if (out.length && out.at(-1).type !== 'divider') out.push({type: 'divider'});
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (typeof entry.label !== 'string' || !entry.label.trim() || typeof entry.action !== 'function') continue;
|
|
394
|
+
const item = {label: entry.label, action: wrap(entry.action)};
|
|
395
|
+
if (typeof entry.icon === 'string' && entry.icon) item.icon = entry.icon;
|
|
396
|
+
if (entry.danger === true) item.danger = true;
|
|
397
|
+
if (typeof entry.disabled === 'function') item.disabled = entry.disabled;
|
|
398
|
+
else if (entry.disabled === true) item.disabled = () => true;
|
|
399
|
+
out.push(item);
|
|
400
|
+
}
|
|
401
|
+
while (out.length && out[0].type === 'divider') out.shift();
|
|
402
|
+
while (out.length && out.at(-1).type === 'divider') out.pop();
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Pane-head buttons an edition contributed (`extensions.paneActions`), checked
|
|
408
|
+
* the same way: each needs an id (a button id and a de-duplication key), a
|
|
409
|
+
* label (its tooltip and accessible name) and an onClick. An id seen twice
|
|
410
|
+
* keeps the first.
|
|
411
|
+
*
|
|
412
|
+
* @param {*} list
|
|
413
|
+
* @returns {{id: string, label: string, icon: string, onClick: Function, haspopup?: string}[]}
|
|
414
|
+
*/
|
|
415
|
+
export function extensionPaneActions(list) {
|
|
416
|
+
const out = [];
|
|
417
|
+
const seen = new Set();
|
|
418
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
419
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
420
|
+
const id = typeof entry.id === 'string' ? entry.id.replace(/[^a-z0-9_-]/gi, '') : '';
|
|
421
|
+
if (!id || seen.has(id)) continue;
|
|
422
|
+
if (typeof entry.label !== 'string' || !entry.label.trim() || typeof entry.onClick !== 'function') continue;
|
|
423
|
+
seen.add(id);
|
|
424
|
+
const action = {id, label: entry.label, icon: typeof entry.icon === 'string' && entry.icon ? entry.icon : 'plus', onClick: entry.onClick};
|
|
425
|
+
if (['dialog', 'menu'].includes(entry.haspopup)) action.haspopup = entry.haspopup;
|
|
426
|
+
out.push(action);
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Put an edition's items into a core menu: above the menu's last group when
|
|
433
|
+
* that group holds a destructive item (Remove, Delete) - so the edition's
|
|
434
|
+
* entries never sit under the dangerous one - and at the end otherwise.
|
|
435
|
+
* Separated by a divider either way. Unchanged when there is nothing to add.
|
|
436
|
+
*
|
|
437
|
+
* @param {object[]} items - the core menu
|
|
438
|
+
* @param {object[]} extra - from extensionMenuItems()
|
|
439
|
+
* @returns {object[]}
|
|
440
|
+
*/
|
|
441
|
+
export function mergeExtensionItems(items, extra) {
|
|
442
|
+
if (!extra?.length) return items;
|
|
443
|
+
const out = [...items];
|
|
444
|
+
let last = -1;
|
|
445
|
+
for (let i = out.length - 1; i >= 0; i -= 1) {
|
|
446
|
+
if (out[i]?.type === 'divider') { last = i; break; }
|
|
447
|
+
}
|
|
448
|
+
if (last > 0 && out.slice(last + 1).some(item => item?.danger === true)) {
|
|
449
|
+
out.splice(last, 0, {type: 'divider'}, ...extra);
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
return out.length ? [...out, {type: 'divider'}, ...extra] : [...extra];
|
|
453
|
+
}
|
|
@@ -57,7 +57,7 @@ const {
|
|
|
57
57
|
esc, mailboxDraftsKey, mailboxDraftId, mailboxDraftData, mailboxFingerprint, normaliseDraftMap,
|
|
58
58
|
pruneDraftMap, mailboxDraftDecision, mailboxRestoreQuestion, RESTORE_BUTTONS, settingsDiscardQuestion,
|
|
59
59
|
onceOnly, accountMenuModel, folderReadMenuModel, mailboxRowMenuModel, menuItems, sidebarUrl, hasNewMail,
|
|
60
|
-
holdEscapeOn
|
|
60
|
+
holdEscapeOn, extensionMenuItems, extensionPaneActions, mergeExtensionItems
|
|
61
61
|
} = await import(versioned('reader-kit.js'));
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -183,7 +183,18 @@ function formatSize(bytes) {
|
|
|
183
183
|
/**
|
|
184
184
|
* Build the reader view for one plugin.
|
|
185
185
|
*
|
|
186
|
-
* @param {{pluginName: string, assetVersion?: string, settingsSections?: object[]
|
|
186
|
+
* @param {{pluginName: string, assetVersion?: string, settingsSections?: object[],
|
|
187
|
+
* features?: object, extensions?: object}} options
|
|
188
|
+
* `extensions` lets an edition add actions to the reader itself, each a
|
|
189
|
+
* function given the reader's `view` handle (see `extensionView()`):
|
|
190
|
+
* - `paneActions(view)` -> `[{id, label, icon, onClick, haspopup?}]`, buttons
|
|
191
|
+
* in the Folders pane head beside New folder;
|
|
192
|
+
* - `folderItems(folder, view)` -> Domma menu items, added to a folder's
|
|
193
|
+
* menu (right-click, ⋮, Shift+F10);
|
|
194
|
+
* - `mailboxItems(account, view)` -> the same, on a mailbox row's menu.
|
|
195
|
+
* Optional, checked and feature-detected: an edition that passes it to an
|
|
196
|
+
* older reader just does not get the entries. Item actions run on the next
|
|
197
|
+
* task, so one picked with Enter cannot hand its own Enter to a dialog.
|
|
187
198
|
* `settingsSections` lets an edition contribute settings without forking the
|
|
188
199
|
* view: each is `{id, label, render(container, context)}`, and the shell
|
|
189
200
|
* handles the tabs, the lazy drawing and a section that throws.
|
|
@@ -193,7 +204,8 @@ function formatSize(bytes) {
|
|
|
193
204
|
* module is imported by a fixed path and carries no stamp of its own.
|
|
194
205
|
* @returns {object} a Domma view
|
|
195
206
|
*/
|
|
196
|
-
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {}
|
|
207
|
+
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {},
|
|
208
|
+
extensions = {}}) {
|
|
197
209
|
const BASE = `/api/plugins/${pluginName}`;
|
|
198
210
|
|
|
199
211
|
// Deliberately NOT keyed by plugin. The two editions never run together - the
|
|
@@ -3418,6 +3430,8 @@ return {
|
|
|
3418
3430
|
searchInputEl?.focus();
|
|
3419
3431
|
break;
|
|
3420
3432
|
case 'escape':
|
|
3433
|
+
// A panel on top owns Escape: it closes that, and only that.
|
|
3434
|
+
if (openPanels.size) break;
|
|
3421
3435
|
if (selected.size) clearSelection();
|
|
3422
3436
|
else if (searchQuery) clearSearch();
|
|
3423
3437
|
break;
|
|
@@ -4795,6 +4809,184 @@ return {
|
|
|
4795
4809
|
}
|
|
4796
4810
|
}
|
|
4797
4811
|
|
|
4812
|
+
// ---------------------------------------------------------------
|
|
4813
|
+
// Edition extensions (createReaderView's `extensions` option)
|
|
4814
|
+
// ---------------------------------------------------------------
|
|
4815
|
+
|
|
4816
|
+
/**
|
|
4817
|
+
* What an extension is handed: enough to open a panel of its own
|
|
4818
|
+
* against this mailbox and to tell the reader something changed.
|
|
4819
|
+
* Read through getters, so a handle kept by an open panel still sees
|
|
4820
|
+
* the current mailbox and folders.
|
|
4821
|
+
*
|
|
4822
|
+
* @type {object}
|
|
4823
|
+
*/
|
|
4824
|
+
const extensionView = Object.freeze({
|
|
4825
|
+
pluginName,
|
|
4826
|
+
base: BASE,
|
|
4827
|
+
api,
|
|
4828
|
+
assetVersion,
|
|
4829
|
+
/** The mailbox on screen (a mailbox row's menu passes its own). */
|
|
4830
|
+
get account() { return mailboxes.find(b => b.id === currentAccountId) ?? null; },
|
|
4831
|
+
get mailboxes() { return [...mailboxes]; },
|
|
4832
|
+
get folders() { return [...folders]; },
|
|
4833
|
+
get currentFolder() { return currentFolder; },
|
|
4834
|
+
/** False once the screen has gone - check it after every await. */
|
|
4835
|
+
here,
|
|
4836
|
+
/**
|
|
4837
|
+
* Run `fn` once when the screen goes (or is mounted again).
|
|
4838
|
+
*
|
|
4839
|
+
* @param {Function} fn
|
|
4840
|
+
* @returns {Function} cancel it
|
|
4841
|
+
*/
|
|
4842
|
+
onLeave(fn) {
|
|
4843
|
+
if (typeof fn !== 'function') return () => {};
|
|
4844
|
+
let live = true;
|
|
4845
|
+
lifetime.add(() => { if (live) { live = false; fn(); } });
|
|
4846
|
+
return () => { live = false; };
|
|
4847
|
+
},
|
|
4848
|
+
/**
|
|
4849
|
+
* Have the reader look after a slideover the extension opened:
|
|
4850
|
+
* Escape belongs to it while it is on top (the reader's own
|
|
4851
|
+
* Escape - clear the selection, the search - does not also
|
|
4852
|
+
* answer), it is closed when the screen goes, and it is taken
|
|
4853
|
+
* out of the page once closed. Call before `open()`.
|
|
4854
|
+
*
|
|
4855
|
+
* @param {object} slideover - from E.slideover
|
|
4856
|
+
* @param {{onClosed?: Function}} [options]
|
|
4857
|
+
* @returns {object} the slideover
|
|
4858
|
+
*/
|
|
4859
|
+
panel(slideover, {onClosed} = {}) {
|
|
4860
|
+
return guardPanel(slideover, () => false, '', typeof onClosed === 'function' ? onClosed : () => {});
|
|
4861
|
+
},
|
|
4862
|
+
/** Read the folder list again, staying in the folder that is open. */
|
|
4863
|
+
refreshFolders: () => refreshFolders(),
|
|
4864
|
+
/**
|
|
4865
|
+
* The same as a settings section's `changed`, so a panel written
|
|
4866
|
+
* for one works from the other: reload means the folders (and the
|
|
4867
|
+
* open list) of the mailbox on screen.
|
|
4868
|
+
*
|
|
4869
|
+
* @param {{reload?: boolean}} [options]
|
|
4870
|
+
* @returns {Promise<void>}
|
|
4871
|
+
*/
|
|
4872
|
+
async changed({reload = false} = {}) {
|
|
4873
|
+
profileFor = null;
|
|
4874
|
+
refreshOutbox();
|
|
4875
|
+
if (reload) await refreshFolders();
|
|
4876
|
+
}
|
|
4877
|
+
});
|
|
4878
|
+
|
|
4879
|
+
/**
|
|
4880
|
+
* The folder list again, keeping the open folder when it still
|
|
4881
|
+
* exists (a full loadFolders goes back to the inbox), and the open
|
|
4882
|
+
* list with it - an import into the folder on screen shows at once.
|
|
4883
|
+
*
|
|
4884
|
+
* @returns {Promise<void>}
|
|
4885
|
+
*/
|
|
4886
|
+
async function refreshFolders() {
|
|
4887
|
+
if (!here() || !currentAccountId) return;
|
|
4888
|
+
const account = currentAccountId;
|
|
4889
|
+
const seq = ++foldersSeq;
|
|
4890
|
+
try {
|
|
4891
|
+
const params = new URLSearchParams({account, counts: '1'});
|
|
4892
|
+
const result = await api(`${BASE}/folders?${params}`);
|
|
4893
|
+
if (!here() || seq !== foldersSeq || currentAccountId !== account) return;
|
|
4894
|
+
if (!result.folders?.some(f => f.path === currentFolder)) {
|
|
4895
|
+
await loadFolders({quiet: true});
|
|
4896
|
+
return;
|
|
4897
|
+
}
|
|
4898
|
+
folders = result.folders;
|
|
4899
|
+
renderFolders();
|
|
4900
|
+
if (!searchQuery) await loadMessages({fresh: true, quiet: true});
|
|
4901
|
+
if (here()) refreshBadge();
|
|
4902
|
+
} catch (err) {
|
|
4903
|
+
if (here()) handleError(err);
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
/**
|
|
4908
|
+
* Call one of the edition's extension functions, surviving one that
|
|
4909
|
+
* is missing, throws or returns rubbish.
|
|
4910
|
+
*
|
|
4911
|
+
* @param {string} name
|
|
4912
|
+
* @param {...*} args
|
|
4913
|
+
* @returns {*}
|
|
4914
|
+
*/
|
|
4915
|
+
function callExtension(name, ...args) {
|
|
4916
|
+
const fn = extensions?.[name];
|
|
4917
|
+
if (typeof fn !== 'function') return [];
|
|
4918
|
+
try {
|
|
4919
|
+
return fn(...args, extensionView) ?? [];
|
|
4920
|
+
} catch (err) {
|
|
4921
|
+
console.warn(`[mail] extensions.${name} failed:`, err);
|
|
4922
|
+
return [];
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
/**
|
|
4927
|
+
* An extension's action, run on the next task - a menu item picked
|
|
4928
|
+
* with Enter that opens a dialog must not hand it that same Enter -
|
|
4929
|
+
* and only while the screen is still here, with a throw shown.
|
|
4930
|
+
*
|
|
4931
|
+
* @param {Function} fn
|
|
4932
|
+
* @returns {Function}
|
|
4933
|
+
*/
|
|
4934
|
+
function deferExtension(fn) {
|
|
4935
|
+
return (...args) => {
|
|
4936
|
+
setTimeout(() => {
|
|
4937
|
+
if (!here()) return;
|
|
4938
|
+
Promise.resolve()
|
|
4939
|
+
.then(() => fn(...args))
|
|
4940
|
+
.catch(err => E.toast(err?.message || 'That could not be done.', {type: 'error'}));
|
|
4941
|
+
}, 0);
|
|
4942
|
+
};
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4945
|
+
/**
|
|
4946
|
+
* The menu items an extension adds for one row.
|
|
4947
|
+
*
|
|
4948
|
+
* @param {'folderItems'|'mailboxItems'} name
|
|
4949
|
+
* @param {object} subject - the folder or mailbox
|
|
4950
|
+
* @returns {object[]}
|
|
4951
|
+
*/
|
|
4952
|
+
function extensionItems(name, subject) {
|
|
4953
|
+
return extensionMenuItems(callExtension(name, subject), deferExtension);
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
/**
|
|
4957
|
+
* The extension's buttons in the Folders pane head, beside New folder.
|
|
4958
|
+
*
|
|
4959
|
+
* @returns {void}
|
|
4960
|
+
*/
|
|
4961
|
+
function addPaneActions() {
|
|
4962
|
+
const actions = extensionPaneActions(callExtension('paneActions'));
|
|
4963
|
+
if (!actions.length) return;
|
|
4964
|
+
const head = foldersEl?.closest('.mail-folders-pane')?.querySelector('.mail-pane-head');
|
|
4965
|
+
if (!head) return;
|
|
4966
|
+
const before = head.querySelector('#mail-settings-btn');
|
|
4967
|
+
for (const action of actions) {
|
|
4968
|
+
const id = `mail-ext-${action.id}`;
|
|
4969
|
+
if (head.querySelector(`#${id}`)) continue;
|
|
4970
|
+
const button = document.createElement('button');
|
|
4971
|
+
button.type = 'button';
|
|
4972
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
4973
|
+
button.id = id;
|
|
4974
|
+
button.title = action.label;
|
|
4975
|
+
button.setAttribute('aria-label', action.label);
|
|
4976
|
+
if (action.haspopup) button.setAttribute('aria-haspopup', action.haspopup);
|
|
4977
|
+
const icon = document.createElement('span');
|
|
4978
|
+
icon.setAttribute('data-icon', action.icon);
|
|
4979
|
+
icon.setAttribute('data-icon-size', '14');
|
|
4980
|
+
button.appendChild(icon);
|
|
4981
|
+
// A click needs no deferring, but it costs nothing and keeps
|
|
4982
|
+
// every entry point the same.
|
|
4983
|
+
button.addEventListener('click', deferExtension(() => action.onClick(extensionView)));
|
|
4984
|
+
if (before?.parentNode === head) head.insertBefore(button, before);
|
|
4985
|
+
else head.appendChild(button);
|
|
4986
|
+
}
|
|
4987
|
+
try { Domma.icons?.scan?.(head); } catch { /* the title still names it */ }
|
|
4988
|
+
}
|
|
4989
|
+
|
|
4798
4990
|
/**
|
|
4799
4991
|
* Right-click a folder to manage it.
|
|
4800
4992
|
*
|
|
@@ -4844,7 +5036,7 @@ return {
|
|
|
4844
5036
|
* @returns {object[]}
|
|
4845
5037
|
*/
|
|
4846
5038
|
function accountMenuItemsFor(box) {
|
|
4847
|
-
return menuItems(accountMenuModel({
|
|
5039
|
+
return mergeExtensionItems(menuItems(accountMenuModel({
|
|
4848
5040
|
isCurrent: box.id === currentAccountId,
|
|
4849
5041
|
isDefault: box.isDefault === true,
|
|
4850
5042
|
count: mailboxes.length
|
|
@@ -4854,7 +5046,7 @@ return {
|
|
|
4854
5046
|
edit: () => openMailboxForm(box),
|
|
4855
5047
|
default: () => toggleDefaultMailbox(box),
|
|
4856
5048
|
remove: () => removeMailbox(box)
|
|
4857
|
-
});
|
|
5049
|
+
}), extensionItems('mailboxItems', box));
|
|
4858
5050
|
}
|
|
4859
5051
|
|
|
4860
5052
|
/**
|
|
@@ -4895,8 +5087,9 @@ return {
|
|
|
4895
5087
|
const locked = protectedUse || isInbox;
|
|
4896
5088
|
|
|
4897
5089
|
const read = folderReadItems(folder);
|
|
4898
|
-
|
|
4899
|
-
return
|
|
5090
|
+
const extra = extensionItems('folderItems', folder);
|
|
5091
|
+
if (!features.folders) return mergeExtensionItems(read, extra);
|
|
5092
|
+
return mergeExtensionItems([
|
|
4900
5093
|
...read,
|
|
4901
5094
|
{type: 'divider'},
|
|
4902
5095
|
{
|
|
@@ -4934,7 +5127,7 @@ return {
|
|
|
4934
5127
|
disabled: () => locked,
|
|
4935
5128
|
action: () => deleteFolder(folder)
|
|
4936
5129
|
}
|
|
4937
|
-
];
|
|
5130
|
+
], extra);
|
|
4938
5131
|
}
|
|
4939
5132
|
|
|
4940
5133
|
/**
|
|
@@ -5872,6 +6065,7 @@ return {
|
|
|
5872
6065
|
newFolder.addEventListener('click', () => createFolder(null, {pickParent: true}));
|
|
5873
6066
|
}
|
|
5874
6067
|
}
|
|
6068
|
+
addPaneActions();
|
|
5875
6069
|
|
|
5876
6070
|
// A real <input> here, unlike the row ticks: this one is not inside a
|
|
5877
6071
|
// button, so it can be what it is.
|