feat(dashboard): show who shared each item in Files → Shared (#4327)

* feat(dashboard): show who shared each item in the Files tab's Shared view

The Shared view now names the person behind every item:

- List view gets a sortable, resizable "Shared by" column with the
  share modal's initial-and-color avatar. The tooltip names the owner
  too when someone else re-shared the item.
- Grid tiles show the sharer under the item name.
- Narrow listings lead each row's second line with the sharer.
- A people filter above the listing (one chip per sharer, with counts)
  narrows the view to one person. It shows once two or more people have
  shared something, and scrolls sideways on small screens.

Sorting by "Shared by" applies only in the Shared view; other folders
keep sorting by name while it is the saved sort.

* fix(dashboard): keep the name column readable beside "Shared by"

In a listing between the phone layout and about 700px wide, the fixed
160px "Shared by" column squeezed an auto-width name down to a few
characters. Beside an auto-width name, "Shared by" now takes at most 40%
of the room the two split, and resizing it stops at the same width.

* fix(dashboard): keep the "Shared by" header on one line

Narrowed, its two-word label wrapped onto a second line that spilled out
of the 32px header row. It now stays on one line and ends in an
ellipsis, as longer translations need too.

* fix(dashboard): fit "Shared by" to its longest name on double-click

Auto-fitting the column measured names in a font the cell doesn't use
and left out part of its padding, so the longest name still ended in an
ellipsis. It now reads the font, padding, avatar and gap off a rendered
cell.

* fix(dashboard): keep the people filter on coming back to Shared

Opening a folder from a filtered Shared view and going Back reset the
filter to Everyone, so going through one person's folders meant picking
them again each time. The filter now lasts while the user browses what
was shared with them, and clears once they go back to their own files.

* fix(dashboard): update Shared after Remove from Shared

Remove from Shared only took the row away, leaving the people filter
counting the item. Removing the last item of the person filtered to left
a blank pane under a chip that still claimed one. Shared is now listed
again afterwards, which updates the filter, the empty notice and the
footer.

* fix(dashboard): show a notified share past the people filter

Opening a "shared with you" notification, or a share link, while Shared
was filtered to someone else picked out nothing: the item was filtered
away. The filter now gives way to everyone's when it hides an item the
notification or link names.

* fix(dashboard): truncate a long sharer name on phone-width rows

In the two-line row layout, a long username grew the details line past
the row, pushing the date off screen and under the ⋯ button. The name
now ends in an ellipsis, and the line keeps clear of the button as the
name line does.

* perf(dashboard): sort the Shared view by sharer with one collator

Each sharer comparison called localeCompare with options, which builds
a collator per call: sorting 2,000 rows took about 25ms in Chrome. One
shared Intl.Collator orders them the same in under 1ms.

* fix(dashboard): make share avatar initials readable for every name

The initial-and-color avatar drew its letter at too little contrast for
nearly half the hues a username can map to: yellows and greens came out
near 3:1 in light mode, and lower still on a selected row. The letter is
now darker in light mode and lighter in dark mode, reaching 4.5:1 for
every hue on the page, selected rows and the share suggestions' hover
row. The share dialog and the Shared listing change together, so a
person keeps one look.

* fix(dashboard): show every folder when a drag springs into Shared

Dragging an item out of a shared folder and holding it over Up, Back or
the Shared crumb opens Shared to drop into a folder there. The people
filter, which now lasts while browsing shared items, could hide those
folders, and its chips can't be pressed mid-drag. A drag arriving in
Shared now sees everyone's folders.

* fix(dashboard): keep filtering Shared when it can't be listed again

A chip click lists Shared again so its rows are current. When that
request failed, the listing was replaced with "This folder couldn't be
opened" and the chips disappeared, though only a filter had changed. A
filter change now applies to the rows already loaded when the listing
can't be fetched.

* fix(dashboard): return a drag let go over Shared to where it began

A drag that springs into Shared and is let go over the listing rather
than a folder tried to move the items into Shared itself, which is a
query and not a folder: nothing moved, nothing said so, and the user was
left in Shared. Let go over a listing that takes no items, the drag now
goes back to the folder it started in, as the cancel zone does.

* fix(dashboard): don't offer read-only shared folders as drop targets

Folders shared read-only lit up under a drag and took the drop, which
the server then refused with "Forbidden". They no longer light up, and
a drop on one sends the drag back where it began; they still open on a
dwell.

* fix(dashboard): slide a drag back when a read-only folder refuses it

A drop a read-only shared folder refused left the drag chip to vanish
where it was let go, as if something had happened. It now slides back
to where the drag began, as any drop that doesn't land does.
This commit is contained in:
Nariman Jelveh authored and GitHub committed 2026-10-24 18:10:27 -07:00
1 parent 7759f7afe9
commit 0bdc2197b0
8 files changed
+971 -29

No files matched your search

+214 -25
View File
@@ -46,6 +46,7 @@ import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
import { is_owned_by_me } from '../../helpers/pathOwner.js';
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
const { html_encode, SelectionArea } = window;
@@ -168,10 +169,13 @@ const TabFiles = {
<button class="path-action-btn upload-btn" title="${i18n('upload')}">${icons.upload}</button>
</div>
</div>
<div class="shared-people-filter" role="group" aria-label="${i18n('shared_filter_people')}" hidden></div>
<div class="columns">
<div class="item-icon"></div>
<div class="item-name sortable" data-sort="name">${i18n('name')}</div>
<div class="col-resize-handle" data-resize="name"></div>
<div class="item-shared-by sortable" data-sort="shared_by"><span class="sortable-label">${i18n('shared_by')}</span></div>
<div class="col-resize-handle" data-resize="sharedBy"></div>
<div class="item-size sortable" data-sort="size">${i18n('size')}</div>
<div class="col-resize-handle" data-resize="size"></div>
<div class="item-modified sortable" data-sort="modified">${i18n('modified')}</div>
@@ -356,6 +360,8 @@ const TabFiles = {
this.uploadTargetPath = null;
// Trash item path → the name it had before it was trashed (renderItem).
this.trashedNames = new Map();
// The Shared view's people filter: a username, or null for everyone.
this.sharerFilter = null;
this.folderDwellTimer = null;
this.folderDwellTarget = null;
this.springLoadedActive = false;
@@ -373,7 +379,7 @@ const TabFiles = {
this.currentView = 'list';
this.sortColumn = 'name';
this.sortDirection = 'asc';
this.columnWidths = { name: null, size: 100, modified: 120 };
this.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 120 };
const [savedView, savedSortColumn, savedSortDirection, savedWidths] = await Promise.all([
puter.kv.get('view_mode').catch(() => null),
puter.kv.get('sort_column').catch(() => null),
@@ -384,7 +390,7 @@ const TabFiles = {
this.currentView = ['list', 'grid', 'grid-sm'].includes(savedView) ? savedView : 'list';
// Sorting state
this.sortColumn = ['name', 'size', 'modified'].includes(savedSortColumn) ? savedSortColumn : 'name';
this.sortColumn = ['name', 'size', 'modified', 'shared_by'].includes(savedSortColumn) ? savedSortColumn : 'name';
this.sortDirection = savedSortDirection === 'desc' ? 'desc' : 'asc';
// Column widths state (for resizing). A corrupt saved value falls back
@@ -1610,6 +1616,10 @@ const TabFiles = {
this.toggleSelectMode();
};
this.$el_window.find('.header .shared-people-filter').on('click', '.sharer-chip', (e) => {
this.setSharerFilter(e.currentTarget.getAttribute('data-sharer') || null);
});
// Column header sorting
this.$el_window.find('.header .columns .sortable').on('click', (e) => {
const column = $(e.currentTarget).attr('data-sort');
@@ -1928,6 +1938,8 @@ const TabFiles = {
let $targetColumn;
if ( column === 'name' ) {
$targetColumn = $header.find('.item-name');
} else if ( column === 'sharedBy' ) {
$targetColumn = $header.find('.item-shared-by');
} else if ( column === 'size' ) {
$targetColumn = $header.find('.item-size');
} else if ( column === 'modified' ) {
@@ -1988,6 +2000,20 @@ const TabFiles = {
maxWidth = Math.max(maxWidth, textWidth);
}
});
} else if ( column === 'sharedBy' ) {
const $names = $filesTab.find('.files.files-list-view .row:not(.header) .item-shared-by-name');
if ( $names.length ) {
// Read off a rendered cell: its font, and the padding,
// avatar and gap beside the name.
const nameStyle = getComputedStyle($names[0]);
const font = `${nameStyle.fontWeight} ${nameStyle.fontSize} ${nameStyle.fontFamily}`;
const cellStyle = getComputedStyle($names[0].parentElement);
const beside = parseFloat(cellStyle.paddingLeft) + parseFloat(cellStyle.paddingRight)
+ (parseFloat(cellStyle.columnGap) || 0) + ($names[0].previousElementSibling?.offsetWidth ?? 0);
$names.each(function () {
maxWidth = Math.max(maxWidth, measureTextWidth(this.textContent, font) + beside);
});
}
} else if ( column === 'size' ) {
$filesTab.find('.files.files-list-view .row:not(.header) .item-size').each(function () {
const text = $(this).text();
@@ -2026,24 +2052,35 @@ const TabFiles = {
const $container = $filesTab.find('.files');
const containerWidth = $container.width();
// Only the Shared view has a "Shared by" column.
const hasSharedBy = this.currentPath === window.shared_path;
let nameWidth = this.columnWidths.name;
let sharedByWidth = this.columnWidths.sharedBy || 160;
let sizeWidth = this.columnWidths.size || 100;
let modifiedWidth = this.columnWidths.modified || 120;
// Columns that don't fit lay out at the defaults, but the saved
// widths stay so they come back once the listing is wide enough.
if ( containerWidth > 0 && nameWidth ) {
const totalWidth = COLUMN_FIXED_WIDTH + nameWidth + sizeWidth + modifiedWidth;
const totalWidth = COLUMN_FIXED_WIDTH + nameWidth + sizeWidth + modifiedWidth
+ (hasSharedBy ? sharedByWidth + 4 : 0);
if ( totalWidth > containerWidth ) {
nameWidth = null;
sharedByWidth = 160;
sizeWidth = 100;
modifiedWidth = 120;
}
}
this.appliedColumnWidths = { name: nameWidth, size: sizeWidth, modified: modifiedWidth };
this.appliedColumnWidths = { name: nameWidth, sharedBy: sharedByWidth, size: sizeWidth, modified: modifiedWidth };
// Narrowed beside an auto-width name on every pass, so a resize of
// another column doesn't save the narrowed width.
if ( hasSharedBy && ! nameWidth && containerWidth > 0 ) {
sharedByWidth = fitSharedByWidth(sharedByWidth, containerWidth - COLUMN_FIXED_WIDTH - 4 - sizeWidth - modifiedWidth);
}
const nameCol = nameWidth ? `${nameWidth}px` : 'auto';
const gridTemplate = `24px ${nameCol} 4px ${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`;
const sharedByCols = hasSharedBy ? `${sharedByWidth}px 4px ` : '';
const gridTemplate = `24px ${nameCol} 4px ${sharedByCols}${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`;
this.columnGridTemplate = gridTemplate;
$filesTab.find('.header .columns').css('grid-template-columns', gridTemplate);
@@ -2086,7 +2123,7 @@ const TabFiles = {
* `width` held between the column's minimum and the widest it can be
* without the columns overflowing the listing, given the other columns.
*
* @param {'name'|'size'|'modified'} column
* @param {'name'|'sharedBy'|'size'|'modified'} column
* @param {number} width
* @returns {number}
*/
@@ -2099,8 +2136,22 @@ const TabFiles = {
size: this.columnWidths.size || 100,
modified: this.columnWidths.modified || 120,
};
let fixed = COLUMN_FIXED_WIDTH;
// Beside an auto-width name, "Shared by" is narrowed as applyColumnWidths lays it out.
const narrowsSharedBy = this.currentPath === window.shared_path && ! this.columnWidths.name;
if ( this.currentPath === window.shared_path ) {
fixed += 4;
widths.sharedBy = this.columnWidths.sharedBy || 160;
if ( narrowsSharedBy ) {
widths.sharedBy = fitSharedByWidth(widths.sharedBy, containerWidth - fixed - widths.size - widths.modified);
}
}
const others = Object.keys(widths).filter(c => c !== column).reduce((sum, c) => sum + widths[c], 0);
return Math.max(min, Math.min(width, containerWidth - COLUMN_FIXED_WIDTH - others));
const clamped = Math.max(min, Math.min(width, containerWidth - fixed - others));
if ( column === 'sharedBy' && narrowsSharedBy ) {
return fitSharedByWidth(clamped, containerWidth - fixed - widths.size - widths.modified);
}
return clamped;
},
/**
@@ -2281,9 +2332,13 @@ const TabFiles = {
{ column: 'size', label: i18n('size') },
{ column: 'modified', label: i18n('date_modified') },
];
if ( this.currentPath === window.shared_path ) {
sortOptions.splice(1, 0, { column: 'shared_by', label: i18n('shared_by') });
}
const sortColumn = this.activeSortColumn();
const items = sortOptions.map(opt => {
const isActive = _this.sortColumn === opt.column;
const isActive = sortColumn === opt.column;
const directionIcon = _this.sortDirection === 'asc' ? ' ↑' : ' ↓';
return {
@@ -2301,11 +2356,22 @@ const TabFiles = {
});
},
/**
* The column the listing on screen is sorted by. "Shared by" orders only
* the Shared view; other folders sort by name until it is changed.
*
* @returns {'name'|'size'|'modified'|'shared_by'}
*/
activeSortColumn () {
if ( this.sortColumn === 'shared_by' && this.currentPath !== window.shared_path ) return 'name';
return this.sortColumn;
},
/**
* Sorts an array of files according to current sort settings.
*
* Folders are always sorted before files. Within each group, items are
* sorted by the selected column (name, size, or modified date).
* sorted by the selected column (name, size, modified date, or sharer).
*
* @param {Array<Object>} files - Array of file/folder objects to sort
* @returns {Array<Object>} Sorted array with folders first, then files
@@ -2323,15 +2389,19 @@ const TabFiles = {
}
};
const sortColumn = this.activeSortColumn();
const sortFn = (a, b) => {
let comparison = 0;
const aName = getDisplayName(a);
const bName = getDisplayName(b);
switch ( this.sortColumn ) {
switch ( sortColumn ) {
case 'name':
comparison = aName.localeCompare(bName);
break;
case 'shared_by':
comparison = compareSharers(a, b) || aName.localeCompare(bName);
break;
case 'size':
comparison = (a.size || 0) - (b.size || 0);
break;
@@ -2373,7 +2443,7 @@ const TabFiles = {
const newName = ($newRow.attr('data-name') || file.name || '').toLowerCase();
const newSize = file.size || 0;
const newModified = file.modified || 0;
const sortColumn = this.sortColumn;
const sortColumn = this.activeSortColumn();
const sortDirection = this.sortDirection;
let inserted = false;
@@ -2629,16 +2699,18 @@ const TabFiles = {
* Clicking the same column toggles direction; clicking a new column
* sets ascending order. Persists settings and re-renders the directory.
*
* @param {string} column - Column name to sort by ('name', 'size', or 'modified')
* @param {string} column - Column name to sort by ('name', 'size', 'modified', or 'shared_by')
* @returns {void}
*/
handleSort (column) {
if ( this.sortColumn === column ) {
// Against the column on screen: outside Shared, a saved "Shared by"
// sort shows as name, so clicking Name flips that order.
if ( this.activeSortColumn() === column ) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'asc';
}
this.sortColumn = column;
this.updateSortIndicators();
this.refreshKeepingSelection();
@@ -2663,10 +2735,76 @@ const TabFiles = {
$columns.find('.sortable').removeClass('sort-asc sort-desc');
const $activeColumn = $columns.find(`.sortable[data-sort="${this.sortColumn}"]`);
const $activeColumn = $columns.find(`.sortable[data-sort="${this.activeSortColumn()}"]`);
$activeColumn.addClass(this.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
},
/**
* Shows the "Shared by" column and people filter in the Shared view only,
* and re-lays the columns when the listing moves into or out of it.
*
* @returns {void}
*/
syncSharedViewChrome () {
const $filesTab = this.$el_window.find('.files-tab');
const isSharedView = this.currentPath === window.shared_path;
if ( $filesTab.hasClass('shared-view') === isSharedView ) return;
$filesTab.toggleClass('shared-view', isSharedView);
if ( ! isSharedView ) $filesTab.find('.shared-people-filter').empty().prop('hidden', true);
this.updateSortIndicators();
this.applyColumnWidths();
},
/**
* In the Shared view, rebuilds the people filter from the full listing
* and returns the rows of the person filtered to. Other folders pass
* through unchanged.
*
* @param {Array<Object>} rows
* @returns {Array<Object>}
*/
applySharerFilter (rows) {
if ( this.currentPath !== window.shared_path ) return rows;
const sharers = summarizeSharers(rows);
// The person's last item went away (unshared, or removed from Shared).
if ( ! sharers.some(({ name }) => name === this.sharerFilter) ) this.sharerFilter = null;
const $bar = this.$el_window.find('.files-tab .shared-people-filter');
const bar = $bar[0];
// Rebuilding the chips drops focus and scroll; carry both across.
const focused = $bar.find('.sharer-chip:focus').attr('data-sharer');
const scrollLeft = bar.scrollLeft;
const html = sharerFilterHtml(sharers, this.sharerFilter);
$bar.html(html).prop('hidden', ! html);
bar.scrollLeft = scrollLeft;
if ( focused !== undefined ) {
$bar.find('.sharer-chip').filter((_, el) => el.getAttribute('data-sharer') === focused)[0]
?.focus({ preventScroll: true });
}
return filterBySharer(rows, this.sharerFilter);
},
/**
* Narrows the Shared view to what one person shared, or back to everyone.
*
* @param {string | null} sharer - Username, or null for everyone
* @returns {Promise<void>}
*/
async setSharerFilter (sharer) {
if ( this.currentPath !== window.shared_path || this.sharerFilter === sharer ) return;
this.sharerFilter = sharer;
// The chip answers at once; the rows follow when the listing is back.
this.$el_window.find('.files-tab .shared-people-filter .sharer-chip').each(function () {
const pressed = (this.getAttribute('data-sharer') || null) === sharer;
this.classList.toggle('active', pressed);
this.setAttribute('aria-pressed', String(pressed));
});
await this.renderDirectory(window.shared_path, { filterChange: true });
if ( this.currentPath === window.shared_path ) {
this.$el_window.find('.files-tab .files').scrollTop(0);
}
},
/**
* Renders the contents of a directory.
*
@@ -2678,6 +2816,7 @@ const TabFiles = {
* @param {boolean} [options.skipUrlUpdate] - If true, don't update browser URL
* @param {boolean} [options.skipNavHistory] - If true, don't add to navigation history
* @param {boolean} [options.force] - Supersede a navigation that is still loading
* @param {boolean} [options.filterChange] - A people filter change: if Shared can't be listed, filter the rows already loaded
* @returns {Promise<void>}
*/
async renderDirectory (target, options = {}) {
@@ -2742,10 +2881,17 @@ const TabFiles = {
directoryContents = isSharedView
? await this.listSharedView()
: await window.puter.fs.readdir(readdirArg);
if ( isSharedView ) this.sharedRows = directoryContents;
} catch ( err ) {
console.error('Failed to read directory:', err);
readFailed = true;
directoryContents = [];
// Filtering needs no server: a filter change still applies to
// the rows already loaded.
if ( isSharedView && options.filterChange && this.sharedRows ) {
directoryContents = this.sharedRows;
} else {
readFailed = true;
directoryContents = [];
}
}
if ( seq !== this.renderSeq ) return;
if ( ! directoryContents ) {
@@ -2772,6 +2918,13 @@ const TabFiles = {
this.currentPath = path || target;
}
this.loadingFolder = false;
// The people filter lasts while the user browses what was shared with
// them, so Back out of a shared folder finds it still set. A drag
// springing into Shared sees every folder: chips can't be pressed mid-drag.
if ( is_owned_by_me(this.currentPath) || (window.an_item_is_being_dragged && ! isSameDirRefresh) ) {
this.sharerFilter = null;
}
this.syncSharedViewChrome();
// Update browser URL to reflect current file path (only when Files tab is active)
if ( !options.skipUrlUpdate && window.is_dashboard_mode && this.isDashboardFilesActive() ) {
@@ -2790,6 +2943,7 @@ const TabFiles = {
if ( file.path === window.trash_path ) return false;
return true;
});
directoryContents = this.applySharerFilter(directoryContents);
const isTrashFolder = this.currentPath === window.trash_path;
this.updateActionButtons(isTrashFolder);
@@ -3205,6 +3359,7 @@ const TabFiles = {
</div>
<div class="col-spacer"></div>
<div class="item-metadata">
${this.currentPath === window.shared_path ? `${sharedByCellHtml(file)}<div class="col-spacer"></div>` : ''}
${file.is_dir ? '<div class="item-size"></div>' : `<div class="item-size">${this.formatFileSize(file.size)}</div>`}
<div class="col-spacer"></div>
<div class="item-modified">${html_encode(formatRelativeTime(file.modified * 1000, Date.now(), window.locale))}</div>
@@ -3701,7 +3856,13 @@ const TabFiles = {
// obscures the drop target under the cursor.
cursorAt: { left: -14, top: -12 },
cursor: 'grabbing',
revert: 'invalid',
// As 'invalid', and also when a folder refused the drop (a
// read-only share), so the chip slides back rather than vanishing.
revert: function (dropped) {
const refused = $(this).data('dropRefused');
$(this).removeData('dropRefused');
return ! dropped || !! refused;
},
zIndex: 999999999,
scroll: false,
distance: 5,
@@ -3801,7 +3962,13 @@ const TabFiles = {
? _this.renderTarget
: _this.currentPath;
void _this.dropRowsInto(itemsToMove, destPath, event);
// Shared, or a share the user can only read, takes
// nothing: the drag goes back to where it began.
if ( _this.acceptsNewItems(destPath) ) {
void _this.dropRowsInto(itemsToMove, destPath, event);
} else {
_this.navigateBackFromSpringLoad();
}
} else {
// Dropped outside file list → cancel, navigate back
_this.navigateBackFromSpringLoad();
@@ -3844,6 +4011,14 @@ const TabFiles = {
// — quietly rename the dragged file to "New Folder".
if ( isPending() ) return;
// A folder shared read-only takes nothing; the server
// would refuse each item. The drag goes back instead.
if ( _this.isReadOnlyShare($(el_item).attr('data-path')) ) {
ui.helper.data('cancelled', true);
$(ui.draggable).data('dropRefused', true);
return;
}
const draggedPath = $(ui.draggable).attr('data-path');
if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) {
return;
@@ -3871,9 +4046,12 @@ const TabFiles = {
// not exist yet (see the drop handler above).
if ( isPending() ) return;
$(el_item).addClass('selected');
const _this = TabFiles;
// Not a drop target, but it still opens on a dwell.
if ( ! _this.isReadOnlyShare($(el_item).attr('data-path')) ) {
$(el_item).addClass('selected');
}
const targetPath = $(el_item).attr('data-path');
// Don't auto-open the current directory or trash
@@ -4725,6 +4903,11 @@ const TabFiles = {
await _this.restoreRows([el]);
},
onPasteInto: (destPath) => _this.pasteClipboardInto(destPath),
// The people filter and its counts, the empty notice and the
// footer all come from the listing.
onRemovedFromShared: () => {
if ( _this.currentPath === window.shared_path ) _this.refreshKeepingSelection();
},
onOpen: (el, fsentry) => {
// Custom open handler for Dashboard (avoids window_nav_history issues)
if ( fsentry.is_dir ) {
@@ -5554,15 +5737,21 @@ const TabFiles = {
* Selects the rows for the items a share link names, replacing the
* current selection. Matched by uid — the link's `/<owner>/<uid>/<name>`
* form carries it, and unlike the name it survives a rename. Values that
* aren't shared paths, or items no longer shared, match nothing.
* aren't shared paths, or items no longer shared, match nothing. A
* people filter that hides one of the items gives way to everyone's.
*
* @param {string[]} sharedPaths - The link's `?shared=` values
* @returns {void}
* @returns {Promise<void>}
*/
selectSharedRows (sharedPaths) {
async selectSharedRows (sharedPaths) {
const wanted = new Set(shared_uids_from_paths(sharedPaths));
if ( wanted.size === 0 ) return;
this.selectRowsWhere((row) => wanted.has(String(row.getAttribute('data-uid') ?? '').toLowerCase()));
const uidOf = (row) => String(row.getAttribute('data-uid') ?? '').toLowerCase();
if ( this.sharerFilter && this.currentPath === window.shared_path ) {
const shown = new Set(this.$el_window.find('.files-tab .files .row').map((_, row) => uidOf(row)).get());
if ( [...wanted].some((uid) => ! shown.has(uid)) ) await this.setSharerFilter(null);
}
this.selectRowsWhere((row) => wanted.has(uidOf(row)));
},
/**
+195 -1
View File
@@ -18,7 +18,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
// The tab's window and dialog plumbing isn't what these tests exercise.
@@ -26,6 +26,7 @@ vi.mock('../UIWindow.js', () => ({ default: vi.fn() }));
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
vi.mock('../../helpers/generateFileContextMenu.js', () => ({ default: vi.fn(async () => []) }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
@@ -36,6 +37,7 @@ globalThis.i18n = globalThis.i18n ?? (key => key);
window.icons = {};
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
const { default: generateFileContextMenu } = await import('../../helpers/generateFileContextMenu.js');
const TRASH = '/alice/Trash';
@@ -535,3 +537,195 @@ describe('TabFiles.applyHiddenRule', () => {
expect($('.files .row').first().hasClass('item-revealed')).toBe(false);
});
});
describe('TabFiles "Shared by" column width', () => {
const SHARED = 'puter://shared';
beforeEach(() => {
// Earlier suites stub applyColumnWidths out.
vi.restoreAllMocks();
window.shared_path = SHARED;
window.trash_path = TRASH;
mountListing([], SHARED);
TabFiles.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 120 };
// jsdom lays nothing out; give the listing a width.
vi.spyOn($.fn, 'width').mockImplementation(function () {
return this.hasClass('files') ? 530 : 0;
});
vi.spyOn(TabFiles, 'fitItemNames').mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
it('leaves the auto-width name the larger part in a narrow listing', () => {
TabFiles.applyColumnWidths();
expect(TabFiles.columnGridTemplate).toBe('24px auto 4px 92px 4px 100px 4px 120px 4px 20px');
});
it('keeps its own width when another column is resized', () => {
TabFiles.applyColumnWidths();
TabFiles.adoptAppliedColumnWidths();
expect(TabFiles.columnWidths.sharedBy).toBe(160);
});
it('leaves other columns room to resize as it is laid out', () => {
expect(TabFiles.clampColumnWidth('size', 90)).toBe(90);
});
it('is resized only as far as it is laid out', () => {
expect(TabFiles.clampColumnWidth('sharedBy', 200)).toBe(92);
});
});
describe('TabFiles people filter across navigation', () => {
const SHARED = 'puter://shared';
const share = (name, sharer) => ({
uid: name, name, path: `/${sharer}/${name}-uid/${name}`, is_dir: true, size: 0, modified: 0,
shared_with_me: true, shared_by: sharer, owner: sharer,
});
const listed = () => $('.files .row').map((_, r) => r.getAttribute('data-name')).get().sort();
beforeEach(() => {
vi.restoreAllMocks();
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.shared_path = SHARED;
window.byte_format = bytes => `${bytes} B`;
window.puter = { fs: { readdir: vi.fn(async () => []) } };
mountListing([], '/alice');
$('.files-tab').prepend('<div class="shared-people-filter" hidden></div>');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
TabFiles.sharerFilter = null;
// jsdom has no layout to scroll.
Element.prototype.scrollIntoView ??= () => {};
vi.spyOn(TabFiles, 'listSharedView').mockImplementation(async () => [share('a', 'ann'), share('b', 'bob')]);
vi.spyOn(TabFiles, 'applyColumnWidths').mockImplementation(() => {});
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}" data-name="${file.name}"></div>`);
});
});
afterEach(() => vi.restoreAllMocks());
it('is still set on coming back out of a shared folder', async () => {
await TabFiles.renderDirectory(SHARED);
await TabFiles.setSharerFilter('bob');
expect(listed()).toEqual(['b']);
await TabFiles.renderDirectory('/bob/b-uid/b');
await TabFiles.renderDirectory(SHARED);
expect(TabFiles.sharerFilter).toBe('bob');
expect(listed()).toEqual(['b']);
expect($('.sharer-chip.active').attr('data-sharer')).toBe('bob');
});
it('gives way to show an item a share link or notification names', async () => {
const uid = '5298098f-d8a2-41da-828f-867b9a7f0cce';
TabFiles.listSharedView.mockImplementation(async () => [{ ...share('a', 'ann'), uid }, share('b', 'bob')]);
await TabFiles.renderDirectory(SHARED);
await TabFiles.setSharerFilter('bob');
await TabFiles.selectSharedRows([`/ann/${uid}/a`]);
expect(TabFiles.sharerFilter).toBeNull();
expect(listed()).toEqual(['a', 'b']);
expect($('.files .row.selected').attr('data-name')).toBe('a');
});
it('stays put when the named item is already shown', async () => {
const uid = '5298098f-d8a2-41da-828f-867b9a7f0cce';
TabFiles.listSharedView.mockImplementation(async () => [share('a', 'ann'), { ...share('b', 'bob'), uid }]);
await TabFiles.renderDirectory(SHARED);
await TabFiles.setSharerFilter('bob');
await TabFiles.selectSharedRows([`/bob/${uid}/b`]);
expect(TabFiles.sharerFilter).toBe('bob');
expect($('.files .row.selected').attr('data-name')).toBe('b');
});
it('shows every folder to a drag that springs into Shared', async () => {
await TabFiles.renderDirectory(SHARED);
await TabFiles.setSharerFilter('bob');
await TabFiles.renderDirectory('/bob/b-uid/b');
window.an_item_is_being_dragged = true;
try {
await TabFiles.renderDirectory(SHARED);
} finally {
window.an_item_is_being_dragged = false;
}
expect(TabFiles.sharerFilter).toBeNull();
expect(listed()).toEqual(['a', 'b']);
});
it('still filters when Shared fails to list again', async () => {
await TabFiles.renderDirectory(SHARED);
vi.spyOn(console, 'error').mockImplementation(() => {});
TabFiles.listSharedView.mockImplementation(async () => {
throw new Error('offline');
});
await TabFiles.setSharerFilter('bob');
expect(listed()).toEqual(['b']);
expect($('.sharer-chip.active').attr('data-sharer')).toBe('bob');
expect($('.files').text()).not.toContain("couldn't be opened");
});
it('still says a Shared it was asked to open could not be listed', async () => {
await TabFiles.renderDirectory(SHARED);
await TabFiles.renderDirectory('/alice/Documents');
vi.spyOn(console, 'error').mockImplementation(() => {});
TabFiles.listSharedView.mockImplementation(async () => {
throw new Error('offline');
});
await TabFiles.renderDirectory(SHARED);
expect($('.files').text()).toContain("couldn't be opened");
});
it("clears once the user goes back to their own files", async () => {
await TabFiles.renderDirectory(SHARED);
await TabFiles.setSharerFilter('bob');
await TabFiles.renderDirectory('/alice/Documents');
await TabFiles.renderDirectory(SHARED);
expect(TabFiles.sharerFilter).toBeNull();
expect(listed()).toEqual(['a', 'b']);
});
});
describe('TabFiles Remove from Shared', () => {
const SHARED = 'puter://shared';
beforeEach(() => {
vi.restoreAllMocks();
window.shared_path = SHARED;
window.trash_path = TRASH;
vi.spyOn(TabFiles, 'refreshKeepingSelection').mockImplementation(async () => {});
});
const removeFromShared = async () => {
await TabFiles.generateContextMenuItems($('.files .row')[0], {});
generateFileContextMenu.mock.lastCall[0].onRemovedFromShared();
};
it('lists Shared again, for the people filter and the empty notice', async () => {
mountListing(['a'], SHARED);
await removeFromShared();
expect(TabFiles.refreshKeepingSelection).toHaveBeenCalledTimes(1);
});
it('leaves another folder the user has since opened alone', async () => {
mountListing(['a'], '/alice/Documents');
await removeFromShared();
expect(TabFiles.refreshKeepingSelection).not.toHaveBeenCalled();
});
});
+160
View File
@@ -0,0 +1,160 @@
/*
* 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/>.
*/
// Who shared each item in the Files tab's Shared view: the row's "Shared by"
// cell and the people filter above the listing.
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
/**
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
* @typedef {{ name: string, count: number }} Sharer
*/
/**
* The person who shared a Shared-view row with the user. A grant recorded
* without an issuer falls back to the item's owner.
*
* @param {SharedRow} [row]
* @returns {string} username, or '' when the row names nobody
*/
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
/**
* Everyone who shared something in `rows`, with how many items each, most
* items first and ties by name.
*
* @param {SharedRow[]} rows
* @returns {Sharer[]}
*/
export const summarizeSharers = (rows) => {
const counts = new Map();
for ( const row of rows ) {
const name = sharerOf(row);
if ( name ) counts.set(name, (counts.get(name) ?? 0) + 1);
}
return [...counts]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || compareNames(a.name, b.name));
};
/**
* The rows `sharer` shared; every row when `sharer` is empty.
*
* @template {SharedRow} T
* @param {T[]} rows
* @param {string | null} [sharer]
* @returns {T[]}
*/
export const filterBySharer = (rows, sharer) => (sharer
? rows.filter((row) => sharerOf(row) === sharer)
: rows);
/**
* Orders two rows by who shared them, case-insensitively.
*
* @param {SharedRow} a
* @param {SharedRow} b
* @returns {number}
*/
export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
// One collator for every comparison: localeCompare with options builds a new
// one per call, which made sorting a large Shared listing over 20x slower.
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
/**
* The "Shared by" column's width beside an auto-width name column: `width`,
* held to 40% of the `room` the two split so the name keeps the larger part,
* and never under the 60px every resizable column keeps.
*
* @param {number} width
* @param {number} room
* @returns {number}
*/
export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Math.floor(room * 0.4)));
/**
* The initial-and-color avatar the share modal uses, sized by `className`.
*
* @param {string} name
* @param {string} className
* @returns {string} markup
*/
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}" aria-hidden="true">${window.html_encode(avatarInitial(name))}</span>`;
/**
* Tooltip for a row's sharer, naming the owner too when someone else re-shared it.
*
* @param {SharedRow} row
* @returns {string} plain text
*/
export const sharedByTitle = (row) => {
const username = sharerOf(row);
if ( ! username ) return '';
return row.owner && row.owner !== username
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
: i18n('shared_by_user', { username }, false);
};
/**
* A row's "Shared by" cell. Rendered empty when the row names nobody, so the
* list view's columns still line up.
*
* @param {SharedRow} row
* @returns {string} markup
*/
export const sharedByCellHtml = (row) => {
const name = sharerOf(row);
if ( ! name ) return '<div class="item-shared-by"></div>';
return `<div class="item-shared-by" title="${window.html_encode(sharedByTitle(row))}">`
+ avatarHtml(name, 'item-shared-by-avatar')
+ `<span class="item-shared-by-name">${window.html_encode(name)}</span>`
+ '</div>';
};
/**
* The people filter's chips: "Everyone", then one per sharer. Empty with
* fewer than two sharers, where filtering would change nothing.
*
* @param {Sharer[]} sharers - From summarizeSharers
* @param {string | null} active - The sharer filtered to, or null for everyone
* @returns {string} markup
*/
export const sharerFilterHtml = (sharers, active) => {
if ( sharers.length < 2 ) return '';
const total = sharers.reduce((sum, sharer) => sum + sharer.count, 0);
const chip = (value, inner, count, title) => {
const pressed = (active ?? '') === value;
return `<button type="button" class="sharer-chip${pressed ? ' active' : ''}" data-sharer="${window.html_encode(value)}" aria-pressed="${pressed}"${title ? ` title="${window.html_encode(title)}"` : ''}>`
+ inner
+ `<span class="sharer-chip-count">${count}</span>`
+ '</button>';
};
let h = chip('', `<span class="sharer-chip-label">${i18n('shared_filter_everyone')}</span>`, total);
for ( const { name, count } of sharers ) {
h += chip(
name,
avatarHtml(name, 'sharer-chip-avatar') + `<span class="sharer-chip-label">${window.html_encode(name)}</span>`,
count,
i18n('shared_by_user', { username: name }, false),
);
}
return h;
};
+176
View File
@@ -0,0 +1,176 @@
/*
* 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/>.
*/
import { describe, expect, it } from 'vitest';
// Mirrors the real i18n: named placeholders, encoded unless the third argument is false.
globalThis.i18n = (key, args, encode = true) => {
const raw = `${key}:${Object.values(args ?? {}).join(',')}`;
return encode ? raw.replace(/&/g, '&amp;').replace(/</g, '&lt;') : raw;
};
globalThis.window = {
html_encode: (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'),
};
const {
compareSharers,
filterBySharer,
fitSharedByWidth,
sharedByCellHtml,
sharedByTitle,
sharerFilterHtml,
sharerOf,
summarizeSharers,
} = await import('./sharedBy.js');
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
describe('sharerOf', () => {
it('names whoever granted the share', () => {
expect(sharerOf(row('ann', 'bob'))).toBe('ann');
});
it('falls back to the owner for a grant with no issuer', () => {
expect(sharerOf({ shared_by: null, owner: 'bob' })).toBe('bob');
});
it('is empty when the row names nobody', () => {
expect(sharerOf({})).toBe('');
expect(sharerOf(undefined)).toBe('');
});
});
describe('summarizeSharers', () => {
it('counts each sharer, most items first', () => {
const sharers = summarizeSharers([row('ann'), row('bob'), row('bob'), row('cy')]);
expect(sharers).toEqual([
{ name: 'bob', count: 2 },
{ name: 'ann', count: 1 },
{ name: 'cy', count: 1 },
]);
});
it('orders ties by name, ignoring case', () => {
expect(summarizeSharers([row('Zed'), row('amy'), row('Bo')]).map((s) => s.name))
.toEqual(['amy', 'Bo', 'Zed']);
});
it('skips rows that name nobody', () => {
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
});
});
describe('filterBySharer', () => {
const rows = [row('ann', 'ann', 'a'), row('bob', 'bob', 'b'), row(null, 'ann', 'c')];
it('keeps only what that person shared, owner fallback included', () => {
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
});
it('keeps everything without a filter', () => {
expect(filterBySharer(rows, null)).toBe(rows);
expect(filterBySharer(rows, '')).toBe(rows);
});
});
describe('compareSharers', () => {
it('sorts by sharer, ignoring case', () => {
const sorted = [row('carl'), row('Ann'), row('bob')].sort(compareSharers);
expect(sorted.map(sharerOf)).toEqual(['Ann', 'bob', 'carl']);
});
it('treats accented and plain letters alike', () => {
expect(compareSharers(row('José'), row('jose'))).toBe(0);
});
});
describe('fitSharedByWidth', () => {
it('keeps the width while the name has the larger part', () => {
expect(fitSharedByWidth(160, 1200)).toBe(160);
});
it('yields to the name in a narrow listing', () => {
// A 530px listing leaves 230px for the name and "Shared by" together.
expect(fitSharedByWidth(160, 230)).toBe(92);
});
it('never goes under the column minimum', () => {
expect(fitSharedByWidth(160, 50)).toBe(60);
});
});
describe('sharedByTitle', () => {
it('names the sharer', () => {
expect(sharedByTitle(row('ann'))).toBe('shared_by_user:ann');
});
it('names the owner too when someone else re-shared it', () => {
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
});
});
describe('sharedByCellHtml', () => {
it('shows an avatar and the sharer', () => {
const h = sharedByCellHtml(row('ann'));
expect(h).toContain('class="item-shared-by"');
expect(h).toContain('>A</span>');
expect(h).toContain('<span class="item-shared-by-name">ann</span>');
});
it('encodes the username', () => {
const h = sharedByCellHtml(row('<b>"x"'));
expect(h).not.toContain('<b>');
expect(h).toContain('&lt;b>&quot;x&quot;');
});
it('still renders an empty cell, so the columns line up', () => {
expect(sharedByCellHtml({})).toBe('<div class="item-shared-by"></div>');
});
});
describe('sharerFilterHtml', () => {
it('is empty with fewer than two sharers', () => {
expect(sharerFilterHtml([], null)).toBe('');
expect(sharerFilterHtml([{ name: 'ann', count: 3 }], null)).toBe('');
});
it('leads with Everyone, counting every item', () => {
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
const chips = h.match(/<button[^>]*>/g);
expect(chips).toHaveLength(3);
expect(chips[0]).toContain('data-sharer=""');
expect(chips[0]).toContain('aria-pressed="true"');
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">3</span>');
expect(chips[1]).toContain('data-sharer="bob"');
});
it('presses the active sharer only', () => {
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], 'ann');
const pressed = h.match(/<button[^>]*aria-pressed="true"[^>]*>/g);
expect(pressed).toHaveLength(1);
expect(pressed[0]).toContain('data-sharer="ann"');
expect(pressed[0]).toContain('sharer-chip active');
});
it('encodes usernames in values and labels', () => {
const h = sharerFilterHtml([{ name: 'a"b', count: 1 }, { name: '<i>', count: 1 }], null);
expect(h).toContain('data-sharer="a&quot;b"');
expect(h).not.toContain('<i>');
});
});
+219 -2
View File
@@ -3008,7 +3008,7 @@ select.share-modal-link-access {
font-size: 13px;
font-weight: 600;
background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
color: hsl(var(--share-avatar-hue, 213), 45%, 38%);
color: hsl(var(--share-avatar-hue, 213), 45%, 28%);
}
.share-modal-row-who {
flex: 1 1 auto;
@@ -3251,7 +3251,7 @@ select.share-modal-row-mode:focus {
.share-modal-avatar,
.share-modal .share-suggest-avatar {
background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2);
color: hsl(var(--share-avatar-hue, 213), 55%, 72%);
color: hsl(var(--share-avatar-hue, 213), 55%, 76%);
}
/* Restated after the rule above, which would otherwise repaint the glyph
variant with a hue it does not carry. */
@@ -4900,6 +4900,189 @@ body.myapps-reordering .myapps-tile {
color: inherit;
}
/* -- Shared view: who shared each item --
The "Shared by" column, a sharer line on grid tiles, and the people filter.
TabFiles.js sets .shared-view on the Shared view only. */
.dashboard-section-files .files-tab:not(.shared-view) .header .columns .item-shared-by,
.dashboard-section-files .files-tab:not(.shared-view) .header .columns [data-resize="sharedBy"] {
display: none;
}
/* Its two-word label fits on one line, ending in an ellipsis once narrowed. */
.dashboard-section-files .header .columns .item-shared-by .sortable-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-section-files .row .item-shared-by {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0 10px;
color: var(--dashboard-text-tertiary);
}
.dashboard-section-files .row.selected .item-shared-by {
color: inherit;
}
.dashboard-section-files .item-shared-by-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The share modal's initial-and-color avatar, at row size. */
.dashboard-section-files .item-shared-by-avatar,
.dashboard-section-files .sharer-chip-avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 10.5px;
font-weight: 600;
line-height: 1;
background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
color: hsl(var(--share-avatar-hue, 213), 45%, 28%);
}
@media (prefers-color-scheme: dark) {
.dashboard-section-files .item-shared-by-avatar,
.dashboard-section-files .sharer-chip-avatar {
background: hsla(var(--share-avatar-hue, 213), 55%, 55%, 0.2);
color: hsl(var(--share-avatar-hue, 213), 55%, 76%);
}
}
/* Grid tiles: the sharer on a line under the name. */
.dashboard-section-files .files-tab .files.files-grid-view .row .item-metadata {
max-width: 100%;
min-width: 0;
}
.dashboard-section-files .files-tab .files.files-grid-view .row .item-shared-by {
justify-content: center;
gap: 5px;
margin-top: -4px;
padding: 0;
font-size: 12px;
line-height: 18px;
}
.dashboard-section-files .files-tab .files.files-grid-view .row .item-shared-by-avatar {
width: 16px;
height: 16px;
font-size: 9px;
}
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row .item-shared-by {
font-size: 11px;
}
/* People filter: one chip per person, scrolling sideways when they don't fit.
The edges fade inside the bar's own padding, so a chip at rest never does. */
.dashboard-section-files .shared-people-filter {
display: flex;
gap: 6px;
padding: 8px 12px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
}
.dashboard-section-files .shared-people-filter[hidden] {
display: none;
}
.dashboard-section-files .shared-people-filter::-webkit-scrollbar {
display: none;
}
.dashboard-section-files .sharer-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
height: 30px;
padding: 0 10px;
box-sizing: border-box;
border: 1px solid var(--dashboard-border);
border-radius: 999px;
background: var(--dashboard-card-background);
color: var(--dashboard-text);
font: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* The avatar sits in the chip's rounded end, as an input chip's does. */
.dashboard-section-files .sharer-chip-avatar {
margin-left: -6px;
}
.dashboard-section-files .sharer-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-section-files .sharer-chip-count {
flex: 0 0 auto;
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--dashboard-text-muted);
}
@media (hover: hover) {
.dashboard-section-files .sharer-chip:hover {
background: var(--select-tint-weak);
border-color: var(--select-tint-strong);
}
}
.dashboard-section-files .sharer-chip.active {
background: var(--select-tint);
border-color: var(--select-ring);
color: var(--select-text);
}
.dashboard-section-files .sharer-chip.active .sharer-chip-count {
color: inherit;
}
/* Inset, so the bar's scroll box doesn't clip the ring. */
.dashboard-section-files .sharer-chip:focus-visible {
outline: 2px solid var(--select-color);
outline-offset: -2px;
}
@media (pointer: coarse) {
.dashboard-section-files .sharer-chip {
height: 36px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-section-files .sharer-chip {
transition: none;
}
}
.dashboard-section-files .files-tab .files.files-grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
@@ -5712,6 +5895,40 @@ body.myapps-reordering .myapps-tile {
margin: 0 6px;
}
/* Shared view: the sharer leads the second line. */
.dashboard-section-files .files-tab .files.files-list-view .row .item-shared-by {
flex: 0 1 auto;
gap: 4px;
padding: 0;
font-size: 11px;
line-height: 24px;
color: var(--dashboard-text-muted);
}
.dashboard-section-files .files-tab .files.files-list-view .row .item-shared-by-avatar {
width: 16px;
height: 16px;
font-size: 9px;
}
.dashboard-section-files .files-tab .files.files-list-view .row .item-shared-by:not(:empty)::after {
content: '•';
flex: none;
margin: 0 6px 0 2px;
}
/* A long sharer name ends in an ellipsis instead of pushing the date off
the row and under the ⋯ button, which the name line keeps clear too. */
.dashboard-section-files .files-tab.shared-view .files.files-list-view .row .item-metadata {
min-width: 0;
padding-right: 40px;
}
.dashboard-section-files .files-tab.shared-view .files.files-list-view .row .item-size,
.dashboard-section-files .files-tab.shared-view .files.files-list-view .row .item-modified {
flex: none;
}
/* Hide outer spacers */
.dashboard-section-files .files-tab .files.files-list-view .row > .col-spacer {
display: none;
+1 -1
View File
@@ -6880,7 +6880,7 @@ html.dark-mode .usage-table-fade-overlay {
font-size: 12.5px;
font-weight: 600;
background: hsla(var(--share-avatar-hue, 213), 60%, 45%, 0.14);
color: hsl(var(--share-avatar-hue, 213), 45%, 38%);
color: hsl(var(--share-avatar-hue, 213), 45%, 28%);
}
/* A team is not a person, so it gets a glyph rather than an initial. */
@@ -48,6 +48,7 @@ import { is_default_app } from './defaultApp.js';
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
* @returns {Promise<Array>} Array of context menu items
*/
const generate_file_context_menu = async function (options) {
@@ -383,6 +384,7 @@ const generate_file_context_menu = async function (options) {
// walked away from.
invalidate_shared_roots();
$(el_item).remove();
options.onRemovedFromShared?.();
} catch (e) {
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
}
+4
View File
@@ -493,6 +493,10 @@ const en = {
shared: 'Shared',
shared_with_me: 'Shared with me',
shared_by: 'Shared by',
shared_by_user: 'Shared by {{username}}',
shared_by_user_owned_by: 'Shared by {{username}}, owned by {{owner}}',
shared_filter_everyone: 'Everyone',
shared_filter_people: 'Filter by who shared',
shared_link_other_account: 'This link was sent to a different Puter account. Sign in to that account to see what was shared with it.',
shared_link_sign_in: 'Sign in to another account',
shared_link_continue_as: 'Continue as {{username}}',