diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index 9acb2114e..bb7a734db 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.js
@@ -39,7 +39,7 @@ import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js'
import { icons } from '../../helpers/actionIcons.js';
import list_all_shared from '../../helpers/list_all_shared.js';
-import { remember_shared_roots } from '../../helpers/shared_access.js';
+import { can_share, remember_shared_roots } from '../../helpers/shared_access.js';
import { parent_path_for, shared_crumbs_for } from '../../helpers/share_paths.js';
const { html_encode, SelectionArea } = window;
@@ -123,6 +123,7 @@ const TabFiles = {
+
@@ -1430,6 +1431,11 @@ const TabFiles = {
}
});
+ // Share button
+ $actions.find('.share-btn').on('click', function () {
+ _this.openShareModal(document.querySelectorAll('.files-tab .row.selected'));
+ });
+
// Cut button
$actions.find('.cut-btn').on('click', function () {
const selectedRows = document.querySelectorAll('.files-tab .row.selected');
@@ -1520,6 +1526,54 @@ const TabFiles = {
$actions.find('.copy-btn').show();
$actions.find('.delete-btn span').text(i18n('delete'));
}
+
+ // Whether the whole selection may be shared can need a lookup, so the
+ // button stays hidden until the answer is in. A selection changed in
+ // the meantime owns the bar, and this answer is discarded.
+ const token = (this._shareCheckToken = {});
+ $actions.find('.share-btn').hide();
+ if ( ! anyTrashed ) {
+ this.canShareRows(selectedRows).then((may_share) => {
+ if ( this._shareCheckToken !== token ) return;
+ $actions.find('.share-btn').toggle(may_share);
+ });
+ }
+ },
+
+ /**
+ * Whether every selected row may be shared with someone else. A selection
+ * mixing your own items with someone else's read-only ones can't be, and
+ * offering the action would only produce a failure per item.
+ *
+ * @param {NodeList|Array} rows - The selected row elements
+ * @returns {Promise}
+ */
+ async canShareRows (rows) {
+ const list = Array.from(rows);
+ if ( ! list.length ) return false;
+ const answers = await Promise.all(list.map((row) => can_share(
+ $(row).attr('data-path'),
+ $(row).attr('data-share_mode'),
+ )));
+ return answers.every(Boolean);
+ },
+
+ /**
+ * Opens the share modal on a selection of rows, folding their access into
+ * one list. The row's rendered icon comes along so the header can show
+ * what is being shared without re-resolving it.
+ *
+ * @param {NodeList|Array} rows - The row elements to share
+ * @returns {void}
+ */
+ openShareModal (rows) {
+ const items = Array.from(rows).map((row) => ({
+ path: $(row).attr('data-path'),
+ name: $(row).attr('data-name'),
+ icon: $(row).find('.item-icon img').attr('src'),
+ }));
+ if ( ! items.length ) return;
+ UIShareModal({ items, $container: this.$el_window });
},
/**
@@ -2891,12 +2945,15 @@ const TabFiles = {
e.stopPropagation();
const selectedRows = document.querySelectorAll('.files-tab .row.selected');
- let items;
- if ( selectedRows.length > 1 && el_item.classList.contains('selected') ) {
- items = await _this.generateMultiSelectContextMenu(selectedRows);
- } else {
- items = await _this.generateContextMenuItems(el_item, file);
- }
+ const isMultiSelection = selectedRows.length > 1 && el_item.classList.contains('selected');
+ const items = isMultiSelection
+ ? await _this.generateMultiSelectContextMenu(selectedRows)
+ : await _this.generateContextMenuItems(el_item, file);
+ // The sheet's title names what the menu acts on — the whole
+ // selection, not just the row the long-press landed on.
+ const menuTitle = isMultiSelection
+ ? i18n('items_count_other', { count: selectedRows.length }, false)
+ : file.name;
// The touch sheet is for touch interactions and touch-first
// devices. A mouse right-click gets the desktop menu at the
@@ -2906,7 +2963,7 @@ const TabFiles = {
const touchInvoked = e.type === 'taphold' || lastPointerType === 'touch';
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() || touchInvoked ) {
const modal = new ContextMenuModal();
- modal.show(items, el_item.getBoundingClientRect(), { title: file.name });
+ modal.show(items, el_item.getBoundingClientRect(), { title: menuTitle });
} else {
// Keep the row visually active while its menu is open — the
// pointer moves onto the menu, so :hover alone would drop it.
@@ -3804,14 +3861,10 @@ const TabFiles = {
async handleMoreClick (rowElement, file, targetElement, fromTouch) {
const selectedRows = document.querySelectorAll('.files-tab .row.selected');
-
- let items;
- if ( selectedRows.length > 1 && rowElement.classList.contains('selected') ) {
- items = await this.generateMultiSelectContextMenu(selectedRows);
- }
- else {
- items = await this.generateContextMenuItems(rowElement, file);
- }
+ const isMultiSelection = selectedRows.length > 1 && rowElement.classList.contains('selected');
+ const items = isMultiSelection
+ ? await this.generateMultiSelectContextMenu(selectedRows)
+ : await this.generateContextMenuItems(rowElement, file);
// The touch sheet for touch taps and touch-first devices; a mouse
// click gets the desktop menu anchored to the button, also on
@@ -3819,7 +3872,11 @@ const TabFiles = {
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() || fromTouch ) {
const targetRect = targetElement.getBoundingClientRect();
const modal = new ContextMenuModal();
- modal.show(items, targetRect, { title: file.name });
+ modal.show(items, targetRect, {
+ title: isMultiSelection
+ ? i18n('items_count_other', { count: selectedRows.length }, false)
+ : file.name,
+ });
} else {
// The '⋯' click doesn't select the row, so without this class the
// row would lose all visual state the moment the pointer moves
@@ -3950,6 +4007,17 @@ const TabFiles = {
window.zipItems(Array.from(selectedRows), _this.currentPath, true);
},
});
+
+ // Share
+ if ( await _this.canShareRows(selectedRows) ) {
+ items.push({
+ html: i18n('share_ellipsis'),
+ onClick: function () {
+ _this.openShareModal(selectedRows);
+ },
+ });
+ }
+
items.push('-');
}
diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js
index 9925f0308..94f42099f 100644
--- a/src/gui/src/UI/Dashboard/UIShareModal.js
+++ b/src/gui/src/UI/Dashboard/UIShareModal.js
@@ -25,28 +25,71 @@ import { icons } from '../../helpers/actionIcons.js';
import { mode_label, options_for } from '../../helpers/share_modes.js';
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import { avatarHue, avatarInitial } from './shareAvatar.js';
+import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js';
const { html_encode } = window;
const closeIcon = ``;
+const chevronIcon = ``;
+
+// How many item icons the header fans out before it stops adding to the pile.
+const MAX_STACKED_ICONS = 3;
+
+// What one /share, /share/revoke or listing pass may cover, matching the
+// backend's documented cap (see doc: rate limits and quotas). Bigger
+// selections are shared in several requests, and skip the access list rather
+// than firing a listing per item.
+const MAX_ITEMS_PER_REQUEST = 50;
+
+/**
+ * Splits `values` into runs of at most `size`.
+ *
+ * @template T
+ * @param {T[]} values
+ * @param {number} size
+ * @returns {T[][]}
+ */
+const chunk = (values, size) => {
+ const out = [];
+ for ( let i = 0; i < values.length; i += size ) out.push(values.slice(i, i + size));
+ return out;
+};
const avatar_html = (name) => {
return `${html_encode(avatarInitial(name))}`;
};
+// Distinguishes one open dialog's item list from another's, since the
+// disclosure button points at it by id.
+let modal_seq = 0;
+
+/** "1 item" / "4 items", HTML-safe. */
+const count_label = (count) => (count === 1
+ ? i18n('items_count_one')
+ : i18n('items_count_other', { count }));
+
/**
* A responsive, from-scratch sharing modal for the Dashboard's Files tab.
* Unlike UIWindowShare (which spawns a desktop UIWindow), this renders a
* self-contained overlay that behaves as a centered card on desktop and a
* bottom sheet on mobile, styled with the dashboard's design tokens.
*
+ * Shares one item or a whole selection. With several items the access list is
+ * one row per person folded across them ({@link aggregateShares}), so every
+ * action — grant, change, revoke — reads as a single decision about a person
+ * rather than a chore repeated per file. A person who holds only some of the
+ * selection says so, and can be extended to the rest in one click.
+ *
* Feature-equivalent to UIWindowShare: grant access by email or username,
* list who has access (owner, direct grants, grants inherited from an
* ancestor folder), change a grant's mode, and revoke — with the revoke
* confirmation inlined into the row instead of a stacked alert window.
*
* @param {Object} opts
- * @param {string} opts.path - Full path of the item to share
+ * @param {Array