mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 13:03:37 +00:00
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.
This commit is contained in:
1 parent
7759f7afe9
commit
60bfe4d54e
5 files changed
+640
-15
No files matched your search
@@ -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, 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">${i18n('shared_by')}</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,12 @@ const TabFiles = {
|
||||
maxWidth = Math.max(maxWidth, textWidth);
|
||||
}
|
||||
});
|
||||
} else if ( column === 'sharedBy' ) {
|
||||
// The avatar and its gap sit beside the name.
|
||||
$filesTab.find('.files.files-list-view .row:not(.header) .item-shared-by-name').each(function () {
|
||||
const textWidth = measureTextWidth($(this).text(), '400 13px system-ui, -apple-system, sans-serif') + padding + 26;
|
||||
maxWidth = Math.max(maxWidth, textWidth);
|
||||
});
|
||||
} else if ( column === 'size' ) {
|
||||
$filesTab.find('.files.files-list-view .row:not(.header) .item-size').each(function () {
|
||||
const text = $(this).text();
|
||||
@@ -2026,24 +2044,30 @@ 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 };
|
||||
|
||||
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 +2110,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 +2123,13 @@ const TabFiles = {
|
||||
size: this.columnWidths.size || 100,
|
||||
modified: this.columnWidths.modified || 120,
|
||||
};
|
||||
let fixed = COLUMN_FIXED_WIDTH;
|
||||
if ( this.currentPath === window.shared_path ) {
|
||||
widths.sharedBy = this.columnWidths.sharedBy || 160;
|
||||
fixed += 4;
|
||||
}
|
||||
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));
|
||||
return Math.max(min, Math.min(width, containerWidth - fixed - others));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2281,9 +2310,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 +2334,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 +2367,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 +2421,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 +2677,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 +2713,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);
|
||||
if ( this.currentPath === window.shared_path ) {
|
||||
this.$el_window.find('.files-tab .files').scrollTop(0);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the contents of a directory.
|
||||
*
|
||||
@@ -2772,6 +2888,9 @@ const TabFiles = {
|
||||
this.currentPath = path || target;
|
||||
}
|
||||
this.loadingFolder = false;
|
||||
// Arriving at Shared shows everyone's items; a refresh keeps the filter.
|
||||
if ( ! 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 +2909,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 +3325,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>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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));
|
||||
|
||||
const compareNames = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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, '&').replace(/</g, '<') : raw;
|
||||
};
|
||||
globalThis.window = {
|
||||
html_encode: (v) => String(v).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"'),
|
||||
};
|
||||
|
||||
const {
|
||||
compareSharers,
|
||||
filterBySharer,
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
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('<b>"x"');
|
||||
});
|
||||
|
||||
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"b"');
|
||||
expect(h).not.toContain('<i>');
|
||||
});
|
||||
});
|
||||
@@ -4900,6 +4900,181 @@ 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;
|
||||
}
|
||||
|
||||
.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%, 38%);
|
||||
}
|
||||
|
||||
@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%, 72%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 +5887,28 @@ 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;
|
||||
}
|
||||
|
||||
/* Hide outer spacers */
|
||||
.dashboard-section-files .files-tab .files.files-list-view .row > .col-spacer {
|
||||
display: none;
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
Reference in new issue
Block a user