mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
Share a whole selection from the Dashboard's Files tab
Sharing was one item at a time: the only way to give someone four files
was to open the dialog four times. A multi-selection now offers Share in
its context menu and in the mobile selection bar, and UIShareModal takes
a list of items.
With several items the access list folds into one row per person rather
than repeating per file, so a grant, a mode change or a revoke is one
decision about a person. A row says what it can't otherwise show: how
much of the selection the person reaches ("On 2 of 4 items", with an
"Add to all" that extends them), a mode select that rests on a
placeholder when their grants disagree rather than presenting one item's
mode as the batch's, and inherited grants left uncontrollable where they
belong. Changing a mode touches only the items the person already holds
— nothing here widens access without saying so.
Requests are chunked to the documented 50-items-per-request cap, and a
selection past it skips the per-item listing instead of firing one
request per file on every refresh.
The eligibility rule the Share entry already used moves into a shared
can_share() helper, since the multi-select menu needs the same answer
for every row.
This commit is contained in:
@@ -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 = {
|
||||
<div class="files-selection-actions">
|
||||
<button class="selection-action-btn restore-btn" title="${i18n('restore')}">${icons.restore}<span>${i18n('restore')}</span></button>
|
||||
<button class="selection-action-btn download-btn" title="${i18n('download')}">${icons.download}<span>${i18n('download')}</span></button>
|
||||
<button class="selection-action-btn share-btn" title="${i18n('share')}">${icons.share}<span>${i18n('share')}</span></button>
|
||||
<button class="selection-action-btn cut-btn" title="${i18n('cut')}">${icons.cut}<span>${i18n('cut')}</span></button>
|
||||
<button class="selection-action-btn copy-btn" title="${i18n('copy')}">${icons.copy}<span>${i18n('copy')}</span></button>
|
||||
<button class="selection-action-btn delete-btn" title="${i18n('delete')}">${icons.trash}<span>${i18n('delete')}</span></button>
|
||||
@@ -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<HTMLElement>} rows - The selected row elements
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
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<HTMLElement>} 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('-');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
const chevronIcon = `<svg class="share-modal-chevron" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>`;
|
||||
|
||||
// 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 `<span class="share-modal-avatar" style="--share-avatar-hue: ${avatarHue(name)}" aria-hidden="true">${html_encode(avatarInitial(name))}</span>`;
|
||||
};
|
||||
|
||||
// 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<Object>} [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 <body>)
|
||||
* @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
|
||||
? `<button type="button" class="share-modal-title-sub share-modal-items-toggle" aria-expanded="false"
|
||||
aria-controls="${items_list_id}" title="${names_summary}">
|
||||
<span class="share-modal-items-summary">${names_summary}</span>${chevronIcon}
|
||||
</button>`
|
||||
: `<span class="share-modal-title-sub">${i18n('share')}</span>`;
|
||||
|
||||
const items_list = is_multi
|
||||
? `<ul class="share-modal-items" id="${items_list_id}" hidden>${targets.map((item) => `
|
||||
<li class="share-modal-item">
|
||||
<span class="share-modal-item-icon"></span>
|
||||
<span class="share-modal-item-name enable-user-select">${html_encode(item.name)}</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: '';
|
||||
|
||||
const $overlay = $(`
|
||||
<div class="share-modal-overlay">
|
||||
<div class="share-modal" role="dialog" aria-modal="true" tabindex="-1" aria-label="${html_encode(item_name)} ${i18n('share')}">
|
||||
<div class="share-modal" role="dialog" aria-modal="true" tabindex="-1" aria-label="${title_name} ${i18n('share')}">
|
||||
<div class="share-modal-header">
|
||||
<div class="share-modal-title">
|
||||
<span class="share-modal-title-icon"></span>
|
||||
<span class="share-modal-title-icon${is_multi ? ' share-modal-title-stack' : ''}"></span>
|
||||
<div class="share-modal-title-text">
|
||||
<span class="share-modal-title-name enable-user-select">${html_encode(item_name)}</span>
|
||||
<span class="share-modal-title-sub">${i18n('share')}</span>
|
||||
<span class="share-modal-title-name enable-user-select">${title_name}</span>
|
||||
${header_sub}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="share-modal-close" aria-label="${i18n('close')}" title="${i18n('close')}">${closeIcon}</button>
|
||||
</div>
|
||||
<div class="share-modal-body">
|
||||
${items_list}
|
||||
<form class="share-modal-add" novalidate>
|
||||
<div class="share-modal-add-row">
|
||||
<input type="text" class="share-modal-recipient" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"
|
||||
@@ -130,18 +208,18 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
}
|
||||
};
|
||||
|
||||
// The last successfully fetched share list, so canceling an inline revoke
|
||||
// confirmation can restore the row without another network round-trip.
|
||||
let last_shares = [];
|
||||
// The last successfully aggregated access list, so canceling an inline
|
||||
// revoke confirmation can restore the row without another round-trip.
|
||||
let last_groups = [];
|
||||
const group_for = (key) => last_groups.find((group) => group.key === key);
|
||||
|
||||
// Re-rendering the list replaces its nodes wholesale, and disabling a
|
||||
// focused control drops focus onto <body> — either would strand a
|
||||
// keyboard user outside the dialog, past the reach of the Tab trap.
|
||||
// Every action that does one of those puts focus back explicitly:
|
||||
// on the same holder's control when it survives, else on the dialog.
|
||||
const focus_list_control = (selector, holder) => {
|
||||
const $el = $list.find(selector)
|
||||
.filter((_, el) => $(el).attr('data-holder') === holder);
|
||||
// on the same person's control when it survives, else on the dialog.
|
||||
const focus_list_control = (selector, key) => {
|
||||
const $el = $list.find(selector).filter((_, el) => $(el).attr('data-key') === key);
|
||||
if ( !$el.length ) return false;
|
||||
$el.get(0).focus({ preventScroll: true });
|
||||
return true;
|
||||
@@ -165,69 +243,156 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
.html(html);
|
||||
};
|
||||
|
||||
const error_html = (err) => (err?.message ? html_encode(err.message) : i18n('share_failed'));
|
||||
const error_html = (err) => (err?.message
|
||||
? html_encode(err.message)
|
||||
: (is_multi ? i18n('share_failed_items') : i18n('share_failed')));
|
||||
|
||||
const clear_status = () => {
|
||||
$status.removeClass('share-modal-status-error share-modal-status-success').empty();
|
||||
};
|
||||
|
||||
const render = (shares) => {
|
||||
last_shares = shares;
|
||||
let rows = '';
|
||||
// -- Access list --
|
||||
|
||||
// The owner's access comes from owning the item, so it can't be revoked
|
||||
rows += '<div class="share-modal-row">';
|
||||
rows += avatar_html(item_owner);
|
||||
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${html_encode(item_owner)}${item_owner === window.user.username ? ` (${i18n('share_you')})` : ''}</span></span>`;
|
||||
rows += `<span class="share-modal-row-tag">${i18n('share_owner')}</span>`;
|
||||
rows += '</div>';
|
||||
/**
|
||||
* The muted second line under a person's name: how much of the selection
|
||||
* they reach, and anything about the grants the controls can't change.
|
||||
*/
|
||||
const notes_for = (group) => {
|
||||
const notes = [];
|
||||
if ( group.accessCount < total ) {
|
||||
notes.push(i18n('share_coverage', { count: group.accessCount, total }));
|
||||
}
|
||||
if ( group.pendingPaths.length ) {
|
||||
notes.push(i18n('share_awaiting_signup'));
|
||||
}
|
||||
if ( group.inheritedFrom ) {
|
||||
notes.push(i18n('share_inherited_via', { folder: path.basename(group.inheritedFrom) }));
|
||||
} else if ( group.inheritedPaths.length ) {
|
||||
notes.push(i18n('share_inherited_via_count', { count: group.inheritedPaths.length }));
|
||||
}
|
||||
return notes;
|
||||
};
|
||||
|
||||
for ( const share of shares ) {
|
||||
const holder = html_encode(share.holder ?? '');
|
||||
const you_suffix = share.holder === window.user.username ? ` (${i18n('share_you')})` : '';
|
||||
if ( share.inheritedFrom ) {
|
||||
// Granted on an ancestor, so it can only be changed there
|
||||
rows += '<div class="share-modal-row share-modal-row-inherited">';
|
||||
rows += avatar_html(share.holder ?? '');
|
||||
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${holder}${you_suffix}</span><span class="share-modal-row-via">${i18n('share_inherited_via', { folder: path.basename(share.inheritedFrom) })}</span></span>`;
|
||||
rows += `<span class="share-modal-row-tag">${mode_label(share.mode)}</span>`;
|
||||
rows += '</div>';
|
||||
continue;
|
||||
const group_row_html = (group) => {
|
||||
const key = html_encode(group.key);
|
||||
const who = html_encode(group.name);
|
||||
const you = ! group.pending && group.name === window.user.username
|
||||
? ` (${i18n('share_you')})`
|
||||
: '';
|
||||
// Only direct grants live on the items themselves; an inherited one
|
||||
// belongs to the ancestor folder and has to be changed there.
|
||||
const can_change = group.directPaths.length > 0;
|
||||
const can_revoke = can_change || group.pendingPaths.length > 0;
|
||||
// Extending someone needs a mode to extend; a person whose grants
|
||||
// disagree levels them with the select first.
|
||||
const missing = missingPathsFor(target_paths, group).length;
|
||||
const can_add_to_all = can_change && missing > 0 && group.mode !== null;
|
||||
|
||||
// The clauses read as one muted line; the action keeps its own box on
|
||||
// it so a narrow sheet wraps it whole instead of orphaning a separator.
|
||||
const notes = notes_for(group);
|
||||
let via = '';
|
||||
if ( notes.length || can_add_to_all ) {
|
||||
via += `<span class="share-modal-row-via${can_add_to_all ? ' share-modal-row-via-wrap' : ''}">`;
|
||||
if ( notes.length ) via += `<span>${notes.join(' · ')}</span>`;
|
||||
if ( can_add_to_all ) {
|
||||
via += `<button type="button" class="share-modal-row-link" data-key="${key}"
|
||||
aria-label="${i18n('share_add_to_all_for', { recipient: group.name, count: missing })}"
|
||||
>${i18n('share_add_to_all')}</button>`;
|
||||
}
|
||||
if ( share.pending ) {
|
||||
// Invited by email with no account yet: nothing to change until they join.
|
||||
const invited = share.recipientEmail ?? '';
|
||||
rows += '<div class="share-modal-row share-modal-row-pending">';
|
||||
rows += avatar_html(invited);
|
||||
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${html_encode(invited)}</span><span class="share-modal-row-via">${i18n('share_awaiting_signup')}</span></span>`;
|
||||
rows += `<span class="share-modal-row-tag">${mode_label(share.mode)}</span>`;
|
||||
rows += `<button type="button" class="share-modal-revoke" data-holder="${html_encode(invited)}" title="${i18n('share_cancel_invite')}" aria-label="${i18n('share_cancel_invite_for', { recipient: invited })}">${icons.trash}</button>`;
|
||||
rows += '</div>';
|
||||
continue;
|
||||
}
|
||||
rows += '<div class="share-modal-row">';
|
||||
rows += avatar_html(share.holder ?? '');
|
||||
rows += `<span class="share-modal-row-who"><span class="share-modal-row-name enable-user-select">${holder}${you_suffix}</span></span>`;
|
||||
// The accessible names carry the holder: a list where every row
|
||||
via += '</span>';
|
||||
}
|
||||
|
||||
let row = `<div class="share-modal-row${group.pending ? ' share-modal-row-pending' : ''}${can_change || group.pending ? '' : ' share-modal-row-inherited'}">`;
|
||||
row += avatar_html(group.name);
|
||||
row += '<span class="share-modal-row-who">';
|
||||
row += `<span class="share-modal-row-name enable-user-select">${who}${you}</span>`;
|
||||
row += via;
|
||||
row += '</span>';
|
||||
|
||||
if ( can_change ) {
|
||||
// The accessible names carry the person: a list where every row
|
||||
// reads as bare "Access level" / "Remove access" leaves a screen
|
||||
// reader user unable to tell whose grant a control changes.
|
||||
rows += `<select class="share-modal-row-mode" data-holder="${holder}" aria-label="${i18n('share_access_level_for', { recipient: share.holder ?? '' })}">${options_for(share.mode)}</select>`;
|
||||
rows += `<button type="button" class="share-modal-revoke" data-holder="${holder}" title="${i18n('share_remove_access')}" aria-label="${i18n('share_remove_access_for', { recipient: share.holder ?? '' })}">${icons.trash}</button>`;
|
||||
row += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">${options_for(group.mode)}</select>`;
|
||||
} else {
|
||||
const fixed_mode = group.pending ? group.pendingMode : group.inheritedMode;
|
||||
row += `<span class="share-modal-row-tag">${fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed')}</span>`;
|
||||
}
|
||||
|
||||
if ( can_revoke ) {
|
||||
const label = group.pending ? i18n('share_cancel_invite') : i18n('share_remove_access');
|
||||
const aria = group.pending
|
||||
? i18n('share_cancel_invite_for', { recipient: group.name })
|
||||
: i18n('share_remove_access_for', { recipient: group.name });
|
||||
row += `<button type="button" class="share-modal-revoke" data-key="${key}" title="${label}" aria-label="${aria}">${icons.trash}</button>`;
|
||||
}
|
||||
row += '</div>';
|
||||
return row;
|
||||
};
|
||||
|
||||
const render = (groups) => {
|
||||
last_groups = groups;
|
||||
let rows = '';
|
||||
|
||||
// Ownership comes from owning the item, so it can't be revoked here.
|
||||
// A selection made in the Shared view can span owners.
|
||||
for ( const item_owner of aggregateOwners(targets.map((item) => item.owner)) ) {
|
||||
rows += '<div class="share-modal-row">';
|
||||
rows += avatar_html(item_owner.name);
|
||||
rows += '<span class="share-modal-row-who">';
|
||||
rows += `<span class="share-modal-row-name enable-user-select">${html_encode(item_owner.name)}${item_owner.name === window.user.username ? ` (${i18n('share_you')})` : ''}</span>`;
|
||||
if ( item_owner.count < total ) {
|
||||
rows += `<span class="share-modal-row-via">${i18n('share_coverage', { count: item_owner.count, total })}</span>`;
|
||||
}
|
||||
rows += '</span>';
|
||||
rows += `<span class="share-modal-row-tag">${i18n('share_owner')}</span>`;
|
||||
rows += '</div>';
|
||||
}
|
||||
if ( !shares.length ) {
|
||||
rows += `<p class="share-modal-empty">${i18n('share_no_one')}</p>`;
|
||||
|
||||
for ( const group of groups ) {
|
||||
rows += group_row_html(group);
|
||||
}
|
||||
if ( !groups.length ) {
|
||||
rows += `<p class="share-modal-empty">${is_multi ? i18n('share_no_one_items') : i18n('share_no_one')}</p>`;
|
||||
}
|
||||
$list.attr('aria-busy', 'false').html(rows);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
render(await puter.fs.getShares(item_path));
|
||||
} catch (e) {
|
||||
$list.attr('aria-busy', 'false').empty();
|
||||
show_error(error_html(e));
|
||||
// A listing per item stops being reasonable past the request cap, and
|
||||
// every action would re-run it. Say so instead of firing hundreds.
|
||||
if ( total > MAX_ITEMS_PER_REQUEST ) {
|
||||
last_groups = [];
|
||||
$list.attr('aria-busy', 'false')
|
||||
.html(`<p class="share-modal-empty">${i18n('share_access_list_too_many', { total })}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
// One listing per item: a slow or failed item must not hide the rest.
|
||||
const settled = await Promise.allSettled(
|
||||
target_paths.map((target) => puter.fs.getShares(target)),
|
||||
);
|
||||
if ( closed ) return;
|
||||
|
||||
/** @type {Map<string, Object[]>} */
|
||||
const by_path = new Map();
|
||||
let failure = null;
|
||||
settled.forEach((result, index) => {
|
||||
if ( result.status === 'fulfilled' ) {
|
||||
by_path.set(target_paths[index], result.value);
|
||||
} else {
|
||||
failure ??= result.reason;
|
||||
}
|
||||
});
|
||||
|
||||
if ( by_path.size === 0 ) {
|
||||
$list.attr('aria-busy', 'false').empty();
|
||||
show_error(error_html(failure));
|
||||
return;
|
||||
}
|
||||
render(aggregateShares(target_paths, by_path));
|
||||
if ( failure ) show_error(i18n('share_load_partial_failed'));
|
||||
};
|
||||
|
||||
// -- Dismissal wiring --
|
||||
@@ -247,9 +412,9 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
// An open revoke confirmation swallows the first Escape.
|
||||
const $confirm = $list.find('.share-modal-row-confirm');
|
||||
if ( $confirm.length ) {
|
||||
const holder = $confirm.attr('data-holder');
|
||||
render(last_shares);
|
||||
focus_list_control('.share-modal-revoke', holder) || focus_dialog();
|
||||
const key = $confirm.attr('data-key');
|
||||
render(last_groups);
|
||||
focus_list_control('.share-modal-revoke', key) || focus_dialog();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
@@ -279,20 +444,60 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
}
|
||||
});
|
||||
|
||||
// -- Item icon (best-effort) --
|
||||
if ( fsentry ) {
|
||||
(async () => {
|
||||
// -- Which items this is (best-effort icons, expandable list) --
|
||||
$overlay.on('click', '.share-modal-items-toggle', function () {
|
||||
const expanded = $(this).attr('aria-expanded') === 'true';
|
||||
$(this).attr('aria-expanded', expanded ? 'false' : 'true');
|
||||
$overlay.find('.share-modal-items').prop('hidden', expanded);
|
||||
});
|
||||
|
||||
const paint_icons = () => {
|
||||
const stacked = targets.filter((item) => item.icon).slice(0, MAX_STACKED_ICONS);
|
||||
if ( stacked.length ) {
|
||||
$overlay.find('.share-modal-title-icon').html(
|
||||
stacked.map((item) => `<img src="${html_encode(item.icon)}" alt="">`).join(''),
|
||||
);
|
||||
}
|
||||
$overlay.find('.share-modal-item').each(function (index) {
|
||||
const icon = targets[index]?.icon;
|
||||
if ( icon ) $(this).find('.share-modal-item-icon').html(`<img src="${html_encode(icon)}" alt="">`);
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
await Promise.all(targets.map(async (item) => {
|
||||
if ( item.icon || ! item.fsentry ) return;
|
||||
try {
|
||||
const icon = await item_icon(fsentry);
|
||||
if ( !closed && icon?.image ) {
|
||||
$overlay.find('.share-modal-title-icon')
|
||||
.html(`<img src="${html_encode(icon.image)}" alt="">`);
|
||||
}
|
||||
item.icon = (await item_icon(item.fsentry))?.image ?? null;
|
||||
} catch { /* icon is best-effort */ }
|
||||
})();
|
||||
}
|
||||
}));
|
||||
if ( ! closed ) paint_icons();
|
||||
})();
|
||||
|
||||
// -- Grant access --
|
||||
|
||||
// One request per run of MAX_ITEMS_PER_REQUEST, in series so a large
|
||||
// selection can't burst through the share rate limit. Resolves with every
|
||||
// grant that landed, the way a single call would.
|
||||
const grant_access = async (recipient, mode, paths) => {
|
||||
const created = [];
|
||||
for ( const run of chunk(paths, MAX_ITEMS_PER_REQUEST) ) {
|
||||
created.push(...(await puter.fs.share({ paths: run, recipient, mode }) ?? []));
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
const revoke_access = async (recipient, paths) => {
|
||||
for ( const run of chunk(paths, MAX_ITEMS_PER_REQUEST) ) {
|
||||
await puter.fs.unshare({ paths: run, recipient });
|
||||
}
|
||||
};
|
||||
|
||||
/** "Shared with ann" / "Shared with ann on 4 items". */
|
||||
const shared_message = (recipient, count) => (count === 1
|
||||
? i18n('share_shared_with', { recipient })
|
||||
: i18n('share_shared_with_items', { recipient, count }));
|
||||
|
||||
$recipient.on('input', function () {
|
||||
$submit.prop('disabled', $(this).val().trim() === '');
|
||||
// Typing again retires a stale success/error message.
|
||||
@@ -306,20 +511,30 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
|
||||
$submit.prop('disabled', true).addClass('share-modal-btn-busy');
|
||||
try {
|
||||
const created = await puter.fs.share({
|
||||
path: item_path,
|
||||
const created = await grant_access(
|
||||
recipient,
|
||||
mode: $overlay.find('.share-modal-mode').val(),
|
||||
});
|
||||
$overlay.find('.share-modal-mode').val(),
|
||||
target_paths,
|
||||
);
|
||||
// Clear only what we sent; a name typed mid-flight shouldn't vanish.
|
||||
if ( $recipient.val().trim() === recipient ) $recipient.val('');
|
||||
$submit.prop('disabled', $recipient.val().trim() === '');
|
||||
// A pair the backend refused doesn't fail the others, so say how
|
||||
// many items actually landed rather than implying all of them did.
|
||||
const granted = created?.length ?? 0;
|
||||
// "Shared with" would claim access an invite does not grant.
|
||||
show_success(
|
||||
created?.some((share) => share.pending)
|
||||
const invited = created?.some((share) => share.pending);
|
||||
if ( ! is_multi ) {
|
||||
show_success(invited
|
||||
? i18n('share_invited', { recipient })
|
||||
: i18n('share_shared_with', { recipient }),
|
||||
);
|
||||
: i18n('share_shared_with', { recipient }));
|
||||
} else if ( granted < total ) {
|
||||
show_success(i18n('share_shared_with_partial', { recipient, count: granted, total }));
|
||||
} else {
|
||||
show_success(invited
|
||||
? i18n('share_invited_items', { recipient, count: total })
|
||||
: shared_message(recipient, total));
|
||||
}
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
$recipient.get(0)?.focus({ preventScroll: true });
|
||||
@@ -334,45 +549,87 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
}
|
||||
});
|
||||
|
||||
// -- Change a grant's mode --
|
||||
// -- Change a grant's mode, on every item the person already holds --
|
||||
$overlay.on('change', '.share-modal-row-mode', async function () {
|
||||
const holder = $(this).attr('data-holder');
|
||||
const key = $(this).attr('data-key');
|
||||
const group = group_for(key);
|
||||
const mode = $(this).val();
|
||||
if ( ! group || ! mode ) return;
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await puter.fs.share({ path: item_path, recipient: holder, mode });
|
||||
show_success(i18n('share_access_updated', { recipient: holder }));
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
await grant_access(group.name, mode, group.directPaths);
|
||||
show_success(group.directPaths.length > 1
|
||||
? i18n('share_access_updated_items', { recipient: group.name, count: group.directPaths.length })
|
||||
: i18n('share_access_updated', { recipient: group.name }));
|
||||
} catch (err) {
|
||||
show_error(error_html(err));
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
}
|
||||
focus_list_control('.share-modal-row-mode', holder) || focus_dialog();
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
focus_list_control('.share-modal-row-mode', key) || focus_dialog();
|
||||
});
|
||||
|
||||
// -- Extend a partial grant to the rest of the selection --
|
||||
$overlay.on('click', '.share-modal-row-link', async function () {
|
||||
const key = $(this).attr('data-key');
|
||||
const group = group_for(key);
|
||||
if ( ! group || group.mode === null ) return;
|
||||
const missing = missingPathsFor(target_paths, group);
|
||||
if ( ! missing.length ) return;
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
const created = await grant_access(group.name, group.mode, missing);
|
||||
const granted = created?.length ?? 0;
|
||||
show_success(granted < missing.length
|
||||
? i18n('share_shared_with_partial', { recipient: group.name, count: granted, total: missing.length })
|
||||
: shared_message(group.name, missing.length));
|
||||
} catch (err) {
|
||||
show_error(error_html(err));
|
||||
}
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
// The link is gone once they hold everything; the mode select is the
|
||||
// nearest surviving control for the same person.
|
||||
focus_list_control('.share-modal-row-mode', key) || focus_dialog();
|
||||
});
|
||||
|
||||
// -- Revoke, confirmed inline in the row --
|
||||
$overlay.on('click', '.share-modal-revoke', function () {
|
||||
const holder = $(this).attr('data-holder');
|
||||
const enc = html_encode(holder);
|
||||
// Withdrawing an invitation isn't taking access away; the prompt must say which.
|
||||
const is_pending = $(this).closest('.share-modal-row').hasClass('share-modal-row-pending');
|
||||
const key = $(this).attr('data-key');
|
||||
const group = group_for(key);
|
||||
if ( ! group ) return;
|
||||
// An invitation is withdrawn, access is taken away; the prompt must
|
||||
// say which, and over how many items.
|
||||
const affected = group.pending ? group.pendingPaths.length : group.directPaths.length;
|
||||
let confirm_text;
|
||||
if ( group.pending ) {
|
||||
confirm_text = affected > 1
|
||||
? i18n('share_confirm_cancel_invite_items', { recipient: group.name, count: affected })
|
||||
: i18n('share_confirm_cancel_invite', { recipient: group.name });
|
||||
} else if ( affected > 1 ) {
|
||||
confirm_text = i18n('share_confirm_remove_items', { recipient: group.name, count: affected });
|
||||
} else {
|
||||
// "this item" only reads right when the dialog is about one item.
|
||||
confirm_text = is_multi
|
||||
? i18n('share_confirm_remove_plain', { recipient: group.name })
|
||||
: i18n('share_confirm_remove', { recipient: group.name });
|
||||
}
|
||||
|
||||
// One confirmation at a time: opening a second one restores the first
|
||||
// row. The re-render replaces every row, so re-find this holder's
|
||||
// row. The re-render replaces every row, so re-find this person's
|
||||
// instead of using the (now detached) clicked button.
|
||||
if ( $list.find('.share-modal-row-confirm').length ) {
|
||||
render(last_shares);
|
||||
render(last_groups);
|
||||
}
|
||||
$list.find('.share-modal-revoke')
|
||||
.filter((_, el) => $(el).attr('data-holder') === holder)
|
||||
.filter((_, el) => $(el).attr('data-key') === key)
|
||||
.closest('.share-modal-row')
|
||||
.replaceWith(`
|
||||
<div class="share-modal-row share-modal-row-confirm" data-holder="${enc}">
|
||||
<span class="share-modal-confirm-text">${is_pending ? i18n('share_confirm_cancel_invite', { recipient: holder }) : i18n('share_confirm_remove', { recipient: holder })}</span>
|
||||
<div class="share-modal-row share-modal-row-confirm" data-key="${html_encode(key)}">
|
||||
<span class="share-modal-confirm-text">${confirm_text}</span>
|
||||
<span class="share-modal-confirm-actions">
|
||||
<button type="button" class="share-modal-btn-quiet share-modal-confirm-cancel">${i18n('cancel')}</button>
|
||||
<button type="button" class="share-modal-btn-danger share-modal-confirm-remove" data-holder="${enc}" data-pending="${is_pending}">${i18n('share_remove')}</button>
|
||||
<button type="button" class="share-modal-btn-danger share-modal-confirm-remove" data-key="${html_encode(key)}">${i18n('share_remove')}</button>
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
@@ -380,20 +637,28 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
});
|
||||
|
||||
$overlay.on('click', '.share-modal-confirm-cancel', function () {
|
||||
const holder = $(this).closest('.share-modal-row-confirm').attr('data-holder');
|
||||
render(last_shares);
|
||||
focus_list_control('.share-modal-revoke', holder) || focus_dialog();
|
||||
const key = $(this).closest('.share-modal-row-confirm').attr('data-key');
|
||||
render(last_groups);
|
||||
focus_list_control('.share-modal-revoke', key) || focus_dialog();
|
||||
});
|
||||
|
||||
$overlay.on('click', '.share-modal-confirm-remove', async function () {
|
||||
const holder = $(this).attr('data-holder');
|
||||
const is_pending = $(this).attr('data-pending') === 'true';
|
||||
const key = $(this).attr('data-key');
|
||||
const group = group_for(key);
|
||||
if ( ! group ) return;
|
||||
// Only where they actually hold something: an inherited grant belongs
|
||||
// to the ancestor folder and isn't this dialog's to withdraw.
|
||||
const revoke_paths = group.pending ? group.pendingPaths : group.directPaths;
|
||||
$(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true);
|
||||
try {
|
||||
await puter.fs.unshare(item_path, holder);
|
||||
show_success(is_pending
|
||||
? i18n('share_invite_cancelled', { recipient: holder })
|
||||
: i18n('share_access_removed', { recipient: holder }));
|
||||
await revoke_access(group.name, revoke_paths);
|
||||
if ( group.pending ) {
|
||||
show_success(i18n('share_invite_cancelled', { recipient: group.name }));
|
||||
} else {
|
||||
show_success(revoke_paths.length > 1
|
||||
? i18n('share_access_removed_items', { recipient: group.name, count: revoke_paths.length })
|
||||
: i18n('share_access_removed', { recipient: group.name }));
|
||||
}
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
// The focused row is gone; the dialog itself takes focus (the
|
||||
@@ -401,8 +666,8 @@ export default function UIShareModal ({ path: item_path, name, owner, fsentry, $
|
||||
focus_dialog();
|
||||
} catch (err) {
|
||||
show_error(error_html(err));
|
||||
render(last_shares);
|
||||
focus_list_control('.share-modal-revoke', holder) || focus_dialog();
|
||||
render(last_groups);
|
||||
focus_list_control('.share-modal-revoke', key) || focus_dialog();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Folds the per-item share listings behind the share dialog into one row per
|
||||
// person, so a selection of many items reads as a single access list. Pure, so
|
||||
// the rules that decide what a row may change are testable without a DOM.
|
||||
|
||||
/**
|
||||
* One person's standing across every item the dialog covers.
|
||||
*
|
||||
* A person's grants split three ways, and only the first is changeable here:
|
||||
* `direct` are grants on the items themselves, `inherited` come through an
|
||||
* ancestor folder and belong to that folder, `pending` are invitations to an
|
||||
* email with no account behind it yet. A key is either a username or an
|
||||
* invited email, never both, so `directPaths` and `pendingPaths` never both
|
||||
* have entries.
|
||||
*
|
||||
* @typedef {Object} ShareGroup
|
||||
* @property {string} key - Row identity: `user:<username>` or `invite:<email>`
|
||||
* @property {string} name - Username, or the invited email address
|
||||
* @property {boolean} pending - Invitation with no account behind it yet
|
||||
* @property {string[]} directPaths - Items whose grant this dialog can change
|
||||
* @property {string[]} pendingPaths - Items the invitation covers
|
||||
* @property {string[]} inheritedPaths - Items reached through an ancestor
|
||||
* @property {string|null} mode - Mode of the direct grants, null when they disagree
|
||||
* @property {string|null} pendingMode - Mode of the invitations, null when they disagree
|
||||
* @property {string|null} inheritedMode - Mode of the inherited grants, null when they disagree
|
||||
* @property {string|null} inheritedFrom - The one ancestor every inherited grant
|
||||
* comes through, null when there is more than one
|
||||
* @property {number} accessCount - Items the person can reach, by any of the three
|
||||
*/
|
||||
|
||||
/**
|
||||
* The one value every entry shares, or null when they disagree (or there are none).
|
||||
*
|
||||
* @param {Array<string|null|undefined>} values
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const uniform = (values) => {
|
||||
if ( values.length === 0 ) return null;
|
||||
const first = values[0] ?? null;
|
||||
return values.every((value) => (value ?? null) === first) ? first : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which of the three buckets a share row belongs to. Pending wins over
|
||||
* inherited: an invitation carries no username to hang an inherited row on.
|
||||
*
|
||||
* @param {Object} share
|
||||
* @returns {'pending'|'inherited'|'direct'}
|
||||
*/
|
||||
const bucket_of = (share) => {
|
||||
if ( share.pending ) return 'pending';
|
||||
if ( share.inheritedFrom ) return 'inherited';
|
||||
return 'direct';
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapses per-item share listings into one {@link ShareGroup} per person.
|
||||
*
|
||||
* Groups come back in the order the listings first mention each person, which
|
||||
* keeps the list stable across refreshes. Items with no listing (a request
|
||||
* that failed, say) simply contribute nothing.
|
||||
*
|
||||
* @param {string[]} paths - The items the dialog covers, in display order
|
||||
* @param {Map<string, Object[]>} sharesByPath - Each item's `getShares` result
|
||||
* @returns {ShareGroup[]}
|
||||
*/
|
||||
export const aggregateShares = (paths, sharesByPath) => {
|
||||
/** @type {Map<string, Object>} */
|
||||
const groups = new Map();
|
||||
|
||||
for ( const item_path of paths ) {
|
||||
// The same person can hold more than one grant on an item (different
|
||||
// issuers), which must not count as reaching it twice.
|
||||
const counted = new Set();
|
||||
|
||||
for ( const share of sharesByPath.get(item_path) ?? [] ) {
|
||||
const bucket = bucket_of(share);
|
||||
const name = bucket === 'pending'
|
||||
? (share.recipientEmail ?? '')
|
||||
: (share.holder ?? '');
|
||||
if ( name === '' ) continue;
|
||||
|
||||
const key = `${bucket === 'pending' ? 'invite' : 'user'}:${name}`;
|
||||
if ( counted.has(`${key}|${bucket}`) ) continue;
|
||||
counted.add(`${key}|${bucket}`);
|
||||
|
||||
if ( ! groups.has(key) ) {
|
||||
groups.set(key, {
|
||||
key,
|
||||
name,
|
||||
pending: bucket === 'pending',
|
||||
directPaths: [],
|
||||
pendingPaths: [],
|
||||
inheritedPaths: [],
|
||||
_directModes: [],
|
||||
_pendingModes: [],
|
||||
_inheritedModes: [],
|
||||
_inheritedFroms: [],
|
||||
});
|
||||
}
|
||||
const group = groups.get(key);
|
||||
|
||||
if ( bucket === 'pending' ) {
|
||||
group.pendingPaths.push(item_path);
|
||||
group._pendingModes.push(share.mode);
|
||||
} else if ( bucket === 'inherited' ) {
|
||||
group.inheritedPaths.push(item_path);
|
||||
group._inheritedModes.push(share.mode);
|
||||
group._inheritedFroms.push(share.inheritedFrom);
|
||||
} else {
|
||||
group.directPaths.push(item_path);
|
||||
group._directModes.push(share.mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()].map((group) => {
|
||||
const reached = new Set([
|
||||
...group.directPaths,
|
||||
...group.pendingPaths,
|
||||
...group.inheritedPaths,
|
||||
]);
|
||||
return {
|
||||
key: group.key,
|
||||
name: group.name,
|
||||
pending: group.pending,
|
||||
directPaths: group.directPaths,
|
||||
pendingPaths: group.pendingPaths,
|
||||
inheritedPaths: group.inheritedPaths,
|
||||
mode: uniform(group._directModes),
|
||||
pendingMode: uniform(group._pendingModes),
|
||||
inheritedMode: uniform(group._inheritedModes),
|
||||
inheritedFrom: uniform(group._inheritedFroms),
|
||||
accessCount: reached.size,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The items a person cannot reach at all — what "add to all" would grant.
|
||||
*
|
||||
* @param {string[]} paths - The items the dialog covers
|
||||
* @param {ShareGroup} group
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const missingPathsFor = (paths, group) => {
|
||||
const reached = new Set([
|
||||
...group.directPaths,
|
||||
...group.pendingPaths,
|
||||
...group.inheritedPaths,
|
||||
]);
|
||||
return paths.filter((item_path) => ! reached.has(item_path));
|
||||
};
|
||||
|
||||
/**
|
||||
* Distinct owners of a selection, in first-seen order, with how many items each
|
||||
* one owns. A multi-item selection made in the Shared view can span owners.
|
||||
*
|
||||
* @param {Array<string|null>} owners - One owner per item, in display order
|
||||
* @returns {Array<{ name: string, count: number }>}
|
||||
*/
|
||||
export const aggregateOwners = (owners) => {
|
||||
/** @type {Map<string, {name: string, count: number}>} */
|
||||
const seen = new Map();
|
||||
for ( const owner of owners ) {
|
||||
if ( ! owner ) continue;
|
||||
const entry = seen.get(owner) ?? { name: owner, count: 0 };
|
||||
entry.count++;
|
||||
seen.set(owner, entry);
|
||||
}
|
||||
return [...seen.values()];
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js';
|
||||
|
||||
const grant = (holder, mode, extra = {}) => ({ holder, mode, ...extra });
|
||||
|
||||
describe('aggregateShares', () => {
|
||||
it('gives a person one row across the items they hold', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
['/me/b', [grant('ann', 'read')]],
|
||||
]));
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toMatchObject({
|
||||
key: 'user:ann',
|
||||
name: 'ann',
|
||||
directPaths: ['/me/a', '/me/b'],
|
||||
mode: 'read',
|
||||
accessCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a mode of null when the grants disagree', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
['/me/b', [grant('ann', 'write')]],
|
||||
]));
|
||||
|
||||
// The dialog opens on a placeholder rather than presenting one item's
|
||||
// mode as the whole selection's.
|
||||
expect(groups[0].mode).toBe(null);
|
||||
});
|
||||
|
||||
it('counts a person who holds only some of the items', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b', '/me/c'], new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
['/me/b', []],
|
||||
['/me/c', [grant('ann', 'read')]],
|
||||
]));
|
||||
|
||||
expect(groups[0].accessCount).toBe(2);
|
||||
expect(groups[0].directPaths).toEqual(['/me/a', '/me/c']);
|
||||
});
|
||||
|
||||
it('keeps inherited grants out of what the dialog can change', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
['/me/b', [grant('ann', 'read', { inheritedFrom: '/me/Documents' })]],
|
||||
]));
|
||||
|
||||
expect(groups[0].directPaths).toEqual(['/me/a']);
|
||||
expect(groups[0].inheritedPaths).toEqual(['/me/b']);
|
||||
expect(groups[0].inheritedFrom).toBe('/me/Documents');
|
||||
// Reachable either way, so both items count as shared with them.
|
||||
expect(groups[0].accessCount).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the single-ancestor label when inherited grants come from several', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('ann', 'read', { inheritedFrom: '/me/Documents' })]],
|
||||
['/me/b', [grant('ann', 'read', { inheritedFrom: '/me/Pictures' })]],
|
||||
]));
|
||||
|
||||
expect(groups[0].inheritedFrom).toBe(null);
|
||||
expect(groups[0].inheritedMode).toBe('read');
|
||||
});
|
||||
|
||||
it('keys an invitation by its email, apart from any username', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant(null, 'read', { pending: true, recipientEmail: 'ann@example.com' })]],
|
||||
['/me/b', [grant('ann', 'write')]],
|
||||
]));
|
||||
|
||||
expect(groups.map((g) => g.key)).toEqual(['invite:ann@example.com', 'user:ann']);
|
||||
expect(groups[0]).toMatchObject({
|
||||
pending: true,
|
||||
pendingPaths: ['/me/a'],
|
||||
pendingMode: 'read',
|
||||
directPaths: [],
|
||||
});
|
||||
expect(groups[1].directPaths).toEqual(['/me/b']);
|
||||
});
|
||||
|
||||
it('counts an item once when two grants on it name the same person', () => {
|
||||
// Two holders can grant the same access; the item is still one item.
|
||||
const groups = aggregateShares(['/me/a'], new Map([
|
||||
['/me/a', [
|
||||
grant('ann', 'read', { issuer: 'me' }),
|
||||
grant('ann', 'read', { issuer: 'bob' }),
|
||||
]],
|
||||
]));
|
||||
|
||||
expect(groups[0].directPaths).toEqual(['/me/a']);
|
||||
expect(groups[0].accessCount).toBe(1);
|
||||
});
|
||||
|
||||
it('lists people in the order the items first mention them', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('zed', 'read')]],
|
||||
['/me/b', [grant('ann', 'read'), grant('zed', 'read')]],
|
||||
]));
|
||||
|
||||
expect(groups.map((g) => g.name)).toEqual(['zed', 'ann']);
|
||||
});
|
||||
|
||||
it('ignores items with no listing behind them', () => {
|
||||
// A getShares that failed contributes nothing rather than throwing.
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
]));
|
||||
|
||||
expect(groups[0].accessCount).toBe(1);
|
||||
});
|
||||
|
||||
it('skips a grant that names nobody', () => {
|
||||
const groups = aggregateShares(['/me/a'], new Map([
|
||||
['/me/a', [grant(null, 'read'), grant('ann', 'read')]],
|
||||
]));
|
||||
|
||||
expect(groups.map((g) => g.name)).toEqual(['ann']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missingPathsFor', () => {
|
||||
it('returns the items the person cannot reach at all', () => {
|
||||
const paths = ['/me/a', '/me/b', '/me/c'];
|
||||
const [group] = aggregateShares(paths, new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
['/me/b', [grant('ann', 'read', { inheritedFrom: '/me/Documents' })]],
|
||||
]));
|
||||
|
||||
// Inherited access still counts as reaching the item.
|
||||
expect(missingPathsFor(paths, group)).toEqual(['/me/c']);
|
||||
});
|
||||
|
||||
it('returns nothing when the person already has every item', () => {
|
||||
const paths = ['/me/a'];
|
||||
const [group] = aggregateShares(paths, new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
]));
|
||||
|
||||
expect(missingPathsFor(paths, group)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateOwners', () => {
|
||||
it('counts each owner once, in first-seen order', () => {
|
||||
expect(aggregateOwners(['ann', 'bob', 'ann'])).toEqual([
|
||||
{ name: 'ann', count: 2 },
|
||||
{ name: 'bob', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips items whose path names no owner', () => {
|
||||
expect(aggregateOwners([null, 'ann', undefined])).toEqual([
|
||||
{ name: 'ann', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2349,6 +2349,101 @@ p.myapps-add-error {
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
/* Several items: their icons fan out from the same 34px slot one item's
|
||||
icon occupies, each ringed in the card color so the pile reads as a pile. */
|
||||
.share-modal-title-icon.share-modal-title-stack {
|
||||
width: 50px;
|
||||
}
|
||||
/* Two rings, card then border: without the outer one, two items with the
|
||||
same icon overlap into one wide smudge instead of a pile. */
|
||||
.share-modal-title-icon.share-modal-title-stack img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--dashboard-card-background),
|
||||
0 0 0 3px var(--dashboard-border);
|
||||
}
|
||||
.share-modal-title-stack {
|
||||
position: relative;
|
||||
}
|
||||
.share-modal-title-stack img:nth-child(1) { left: 0; z-index: 3; }
|
||||
.share-modal-title-stack img:nth-child(2) { left: 12px; z-index: 2; }
|
||||
.share-modal-title-stack img:nth-child(3) { left: 24px; z-index: 1; }
|
||||
|
||||
/* The names of what's being shared, folding out into the full list. */
|
||||
button.share-modal-title-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.share-modal-items-summary {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.share-modal-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.share-modal-items-toggle[aria-expanded='true'] .share-modal-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
button.share-modal-title-sub:hover {
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
button.share-modal-title-sub:focus-visible {
|
||||
outline: none;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
.share-modal-items {
|
||||
margin: 0 0 14px;
|
||||
padding: 0 0 12px;
|
||||
/* Long selections scroll here rather than pushing the access list out of
|
||||
reach behind the dialog's own scroll. */
|
||||
max-height: 170px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--dashboard-border);
|
||||
}
|
||||
.share-modal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.share-modal-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.share-modal-item-icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.share-modal-item-name {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dashboard-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.share-modal-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -2613,6 +2708,40 @@ select.share-modal-row-mode:disabled {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* An inline action can't be ellipsed away, so that line wraps instead —
|
||||
as whole boxes, so a narrow sheet never leaves a separator dangling. */
|
||||
.share-modal-row-via-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 8px;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
.share-modal-row-link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--select-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.share-modal-row-link:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
.share-modal-row-link:focus-visible {
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
.share-modal-row-link:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Owner badge and the fixed mode of an inherited grant */
|
||||
.share-modal-row-tag {
|
||||
flex-shrink: 0;
|
||||
@@ -2751,6 +2880,22 @@ select.share-modal-row-mode:focus {
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
/* Text controls grow their hit area downwards and upwards only: widening
|
||||
them would reach under the row's select. */
|
||||
.share-modal-items-toggle,
|
||||
.share-modal-row-link {
|
||||
position: relative;
|
||||
}
|
||||
.share-modal-items-toggle::before,
|
||||
.share-modal-row-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 44px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
input.share-modal-recipient,
|
||||
input.share-modal-recipient:focus,
|
||||
select.share-modal-mode,
|
||||
|
||||
@@ -39,5 +39,6 @@ export const icons = {
|
||||
sort: `<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 -960 960 960" width="18" fill="currentcolor"><path d="M140-260v-60h215v60H140Zm0-190v-60h447.31v60H140Zm0-190v-60h680v60H140Z"/></svg>`,
|
||||
select: `<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 -960 960 960" width="18" fill="currentcolor"><path d="m424-325.85 268.92-268.92-42.15-42.15L424-410.15l-114-114L267.85-482 424-325.85ZM212.31-140Q182-140 161-161q-21-21-21-51.31v-535.38Q140-778 161-799q21-21 51.31-21h535.38Q778-820 799-799q21 21 21 51.31v535.38Q820-182 799-161q-21 21-51.31 21H212.31Zm0-60h535.38q4.62 0 8.46-3.85 3.85-3.84 3.85-8.46v-535.38q0-4.62-3.85-8.46-3.84-3.85-8.46-3.85H212.31q-4.62 0-8.46 3.85-3.85 3.84-3.85 8.46v535.38q0 4.62 3.85 8.46 3.84 3.85 8.46 3.85ZM200-760v560-560Z"/></svg>`,
|
||||
done: `<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 -960 960 960" width="18" fill="currentcolor"><path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z"/></svg>`,
|
||||
share: `<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 -960 960 960" width="18" fill="currentcolor"><path d="M720-80q-50 0-85-35t-35-85q0-7 1-14.5t3-13.5L322-392q-17 15-38 23.5t-44 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q23 0 44 8.5t38 23.5l282-164q-2-6-3-13.5t-1-14.5q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-23 0-44-8.5T638-672L356-508q2 6 3 13.5t1 14.5q0 7-1 14.5t-3 13.5l282 164q17-15 38-23.5t44-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Z"/></svg>`,
|
||||
worker: `<svg xmlns="http://www.w3.org/2000/svg" color="#455a64" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-zap-icon lucide-zap"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import launch_app from './launch_app.js';
|
||||
import path from '../lib/path.js';
|
||||
import { isWeblinkName, weblinkChangeIconMenuItem } from './weblink.js';
|
||||
import { is_owned_by_me } from './path_owner.js';
|
||||
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from './shared_access.js';
|
||||
import { can_rename, can_restructure, can_share, invalidate_shared_roots } from './shared_access.js';
|
||||
|
||||
/**
|
||||
* Generates context menu items for file/folder operations
|
||||
@@ -59,11 +59,10 @@ const generate_file_context_menu = async function (options) {
|
||||
// Someone else's, however we got here — including items reached by opening
|
||||
// a shared folder, which carry no share markers of their own.
|
||||
const is_not_mine = !is_owned_by_me($(options.element).attr('data-path'));
|
||||
// `manage` inherits downwards, so a file inside a folder you manage
|
||||
// counts too — the row itself only carries a mode at a shared root.
|
||||
const can_manage_share =
|
||||
$(options.element).attr('data-share_mode') === 'manage'
|
||||
|| (await shared_mode_for($(options.element).attr('data-path'))) === 'manage';
|
||||
const may_share = await can_share(
|
||||
$(options.element).attr('data-path'),
|
||||
$(options.element).attr('data-share_mode'),
|
||||
);
|
||||
// Moving and deleting go by the holding folder, not by the item.
|
||||
const may_restructure = !is_not_mine
|
||||
|| await can_restructure($(options.element).attr('data-path'));
|
||||
@@ -320,7 +319,7 @@ const generate_file_context_menu = async function (options) {
|
||||
// -------------------------------------------
|
||||
// Share
|
||||
// -------------------------------------------
|
||||
if ( !is_trash && !is_trashed && (!is_not_mine || can_manage_share) ) {
|
||||
if ( !is_trash && !is_trashed && may_share ) {
|
||||
menu_items.push({
|
||||
html: i18n('share_ellipsis'),
|
||||
onClick: async function () {
|
||||
|
||||
@@ -44,15 +44,23 @@ export const mode_label = (mode) => {
|
||||
* A mode outside `MODES` is listed first rather than dropped, so opening a
|
||||
* dialog on a `see`/`list` grant doesn't silently rewrite it.
|
||||
*
|
||||
* @param {string} current
|
||||
* Pass `null` for a selection whose grants disagree: the `<select>` rests on an
|
||||
* unselectable placeholder, so a batch of mixed modes can't be read as one of
|
||||
* them, and picking a real mode is what levels them.
|
||||
*
|
||||
* @param {string|null} current
|
||||
* @returns {string} HTML-safe markup
|
||||
*/
|
||||
export const options_for = (current) => {
|
||||
const modes = MODES.includes(current) ? MODES : [current, ...MODES];
|
||||
return modes
|
||||
const listed = MODES
|
||||
.map(
|
||||
(mode) =>
|
||||
`<option value="${html_encode(mode)}"${mode === current ? ' selected' : ''}>${mode_label(mode)}</option>`,
|
||||
)
|
||||
.join('');
|
||||
if ( current === null || current === undefined ) {
|
||||
return `<option value="" selected disabled>${i18n('share_access_mixed')}</option>${listed}`;
|
||||
}
|
||||
if ( MODES.includes(current) ) return listed;
|
||||
return `<option value="${html_encode(current)}" selected>${mode_label(current)}</option>${listed}`;
|
||||
};
|
||||
|
||||
@@ -67,4 +67,16 @@ describe('options_for', () => {
|
||||
expect(options_for('read')).toContain('>Can edit & share</option>');
|
||||
expect(options_for('read')).not.toContain('&amp;');
|
||||
});
|
||||
|
||||
it('rests on an unselectable placeholder when the grants disagree', () => {
|
||||
// A batch of mixed modes must not read as any one of them.
|
||||
const html = options_for(null);
|
||||
expect(values(html)).toEqual(['', ...MODES]);
|
||||
expect(html).toContain('<option value="" selected disabled>Mixed</option>');
|
||||
expect(html).not.toContain('<option value="read" selected>');
|
||||
});
|
||||
|
||||
it('treats a missing mode the same as a mixed one', () => {
|
||||
expect(options_for(undefined)).toBe(options_for(null));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,26 @@ export const can_rename = async (item_path, is_dir = false) => {
|
||||
return can_restructure(item_path);
|
||||
};
|
||||
|
||||
/**
|
||||
* May you share the item at `item_path` with someone else?
|
||||
*
|
||||
* Yours always is. Someone else's needs `manage`, which inherits downwards —
|
||||
* so a file inside a folder you manage counts, even though the row itself
|
||||
* carries a mode only at a shared root. Trashed items are never shareable.
|
||||
*
|
||||
* @param {string} item_path
|
||||
* @param {string} [row_mode] - The `data-share_mode` a Shared listing put on the
|
||||
* row, which answers without a lookup when the item is a share root
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const can_share = async (item_path, row_mode) => {
|
||||
if ( typeof item_path !== 'string' || item_path === '' ) return false;
|
||||
if ( item_path === window.trash_path || item_path.startsWith(`${window.trash_path}/`) ) return false;
|
||||
if ( is_owned_by_me(item_path) ) return true;
|
||||
if ( row_mode === 'manage' ) return true;
|
||||
return (await shared_mode_for(item_path)) === 'manage';
|
||||
};
|
||||
|
||||
/**
|
||||
* May you move or delete the item at `item_path`?
|
||||
*
|
||||
|
||||
@@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
can_rename,
|
||||
can_restructure,
|
||||
can_share,
|
||||
invalidate_shared_roots,
|
||||
remember_shared_roots,
|
||||
shared_mode_for,
|
||||
@@ -34,7 +35,7 @@ const REPORT = '44444444-4444-4444-4444-444444444444';
|
||||
describe('shared_access', () => {
|
||||
beforeEach(() => {
|
||||
invalidate_shared_roots();
|
||||
globalThis.window = { user: { username: 'sharemate' } };
|
||||
globalThis.window = { user: { username: 'sharemate' }, trash_path: '/sharemate/Trash' };
|
||||
// Shared roots arrive masked: `/{owner}/{uid}/{name}`.
|
||||
remember_shared_roots([
|
||||
{ path: `/jf/${CONTENTS}/Contents`, mode: 'write' },
|
||||
@@ -112,6 +113,41 @@ describe('shared_access', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('can_share', () => {
|
||||
it('allows your own items', async () => {
|
||||
expect(await can_share('/sharemate/Documents/a.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses someone else\u2019s item held short of manage', async () => {
|
||||
expect(await can_share(`/jf/${CONTENTS}/Contents/a.txt`)).toBe(false);
|
||||
expect(await can_share(`/jf/${PHOTOS}/Photos`, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows an item held with manage', async () => {
|
||||
expect(await can_share(`/jf/${BUDGET}/Budget`)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an item inside a folder held with manage', async () => {
|
||||
// The row itself carries a mode only at a shared root.
|
||||
expect(await can_share(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
|
||||
});
|
||||
|
||||
it('takes the row\u2019s own mode without a lookup', async () => {
|
||||
invalidate_shared_roots();
|
||||
expect(await can_share('/jf/whatever/a.txt', 'manage')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses trashed items, yours included', async () => {
|
||||
expect(await can_share('/sharemate/Trash')).toBe(false);
|
||||
expect(await can_share('/sharemate/Trash/a.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an empty or non-string path', async () => {
|
||||
expect(await can_share('')).toBe(false);
|
||||
expect(await can_share(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('can_rename', () => {
|
||||
it('allows a file shared directly for writing', async () => {
|
||||
expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
|
||||
|
||||
@@ -198,6 +198,8 @@ const en = {
|
||||
incorrect_password: 'Incorrect password',
|
||||
invite_link: 'Invite Link',
|
||||
item: 'item',
|
||||
items_count_one: '1 item',
|
||||
items_count_other: '{{count}} items',
|
||||
items_in_trash_cannot_be_renamed: 'This item can\'t be renamed because it\'s in the trash. To rename this item, first drag it out of the Trash.',
|
||||
jpeg_image: 'JPEG image',
|
||||
keep_both: 'Keep Both',
|
||||
@@ -407,7 +409,32 @@ const en = {
|
||||
share_invite_cancelled: 'Invitation to {{recipient}} cancelled',
|
||||
share_access_removed: 'Removed {{recipient}}',
|
||||
share_confirm_remove: 'Remove {{recipient}}’s access to this item?',
|
||||
share_confirm_remove_plain: 'Remove {{recipient}}’s access?',
|
||||
share_confirm_remove_items:
|
||||
'Remove {{recipient}}’s access to {{count}} items?',
|
||||
share_confirm_cancel_invite_items:
|
||||
'Cancel the invitation sent to {{recipient}} for {{count}} items?',
|
||||
share_remove: 'Remove',
|
||||
share_access_mixed: 'Mixed',
|
||||
share_coverage: 'On {{count}} of {{total}} items',
|
||||
share_add_to_all: 'Add to all',
|
||||
share_add_to_all_for:
|
||||
'Give {{recipient}} access to the other {{count}} items',
|
||||
share_inherited_via_count: 'via {{count}} folders',
|
||||
share_no_one_items: 'None of these items are shared yet.',
|
||||
share_failed_items: 'Could not share these items.',
|
||||
share_load_partial_failed:
|
||||
'Could not load who has access to every item.',
|
||||
share_access_list_too_many:
|
||||
'Who already has access isn’t listed for this many items. Anyone you add gets all {{total}}.',
|
||||
share_shared_with_items: 'Shared with {{recipient}} on {{count}} items',
|
||||
share_shared_with_partial:
|
||||
'Shared with {{recipient}} on {{count}} of {{total}} items',
|
||||
share_invited_items:
|
||||
'Invited {{recipient}} to {{count}} items — they’ll get access once they join',
|
||||
share_access_updated_items:
|
||||
'Updated access for {{recipient}} on {{count}} items',
|
||||
share_access_removed_items: 'Removed {{recipient}} from {{count}} items',
|
||||
block: 'Block',
|
||||
unblock: 'Unblock',
|
||||
manage: 'Manage',
|
||||
|
||||
Reference in New Issue
Block a user