Share a whole selection from the Dashboard's Files tab
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

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:
jelveh
2026-08-24 14:08:37 -07:00
parent 0527bcd98e
commit 34af8d18d0
12 changed files with 1069 additions and 139 deletions
+85 -17
View File
@@ -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('-');
}
+376 -111
View File
@@ -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();
}
});
+190
View File
@@ -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 },
]);
});
});
+145
View File
@@ -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,
+1
View File
@@ -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 () {
+11 -3
View File
@@ -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}`;
};
+12
View File
@@ -67,4 +67,16 @@ describe('options_for', () => {
expect(options_for('read')).toContain('>Can edit &amp; share</option>');
expect(options_for('read')).not.toContain('&amp;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));
});
});
+20
View File
@@ -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`?
*
+37 -1
View File
@@ -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);
+27
View File
@@ -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 isnt 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 — theyll 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',