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 ``; }; +// 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} [opts.items] - The items to share, each + * `{ path, name?, owner?, fsentry?, icon? }`. Takes the place of the + * single-item fields below. + * @param {string} [opts.path] - Full path of the one item to share * @param {string} [opts.name] - Display name; defaults to the path's basename * @param {string} [opts.owner] - Owner's username; defaults to the first path * segment, which is not the current user when a `manage` recipient opens this @@ -54,25 +97,60 @@ const avatar_html = (name) => { * @param {jQuery} [opts.$container] - Element to append the overlay to (defaults to ) * @returns {{ close: () => void }} */ -export default function UIShareModal ({ path: item_path, name, owner, fsentry, $container }) { +export default function UIShareModal ({ items, path: item_path, name, owner, fsentry, $container }) { const $root = $container && $container.length ? $container : $('body'); - const item_name = name ?? path.basename(item_path); - const item_owner = owner ?? owner_of_path(item_path) ?? window.user.username; + + const targets = (items?.length ? items : [{ path: item_path, name, owner, fsentry }]) + .filter((item) => typeof item?.path === 'string' && item.path !== '') + .map((item) => ({ + path: item.path, + name: item.name ?? path.basename(item.path), + owner: item.owner ?? owner_of_path(item.path) ?? window.user.username, + fsentry: item.fsentry ?? null, + icon: item.icon ?? null, + })); + const target_paths = targets.map((item) => item.path); + const total = targets.length; + const is_multi = total > 1; + // Nothing to share: an empty selection is a caller's mistake, not a dialog. + if ( total === 0 ) return { close: () => {} }; + const items_list_id = `share-modal-items-${++modal_seq}`; + + // The header names the one item, or the size of the pile with the names + // folded into an expandable list below it. + const title_name = is_multi ? count_label(total) : html_encode(targets[0].name); + const names_summary = html_encode(targets.map((item) => item.name).join(', ')); + + const header_sub = is_multi + ? `` + : ``; + + const items_list = is_multi + ? `` + : ''; const $overlay = $(`