From 0bdc2197b04ee057bfc49f052339f0cbf91ae497 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sat, 24 Oct 2026 18:10:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20show=20who=20shared=20each?= =?UTF-8?q?=20item=20in=20Files=20=E2=86=92=20Shared=20(#4327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/gui/src/UI/Dashboard/TabFiles.js | 239 ++++++++++++++++-- src/gui/src/UI/Dashboard/TabFiles.test.js | 196 +++++++++++++- src/gui/src/UI/Dashboard/sharedBy.js | 160 ++++++++++++ src/gui/src/UI/Dashboard/sharedBy.test.js | 176 +++++++++++++ src/gui/src/css/dashboard.css | 221 +++++++++++++++- src/gui/src/css/style.css | 2 +- .../src/helpers/generateFileContextMenu.js | 2 + src/gui/src/i18n/translations/en.js | 4 + 8 files changed, 971 insertions(+), 29 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/sharedBy.js create mode 100644 src/gui/src/UI/Dashboard/sharedBy.test.js diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 61c4ea199..089ef4cf7 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -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 = { +
${i18n('name')}
+
${i18n('shared_by')}
+
${i18n('size')}
${i18n('modified')}
@@ -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} files - Array of file/folder objects to sort * @returns {Array} 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} rows + * @returns {Array} + */ + 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} + */ + 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} */ 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 = {