diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index 61c4ea199..7be30f33b 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, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
const { html_encode, SelectionArea } = window;
@@ -168,10 +169,13 @@ const TabFiles = {
${icons.upload}
+
${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,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
} files - Array of file/folder objects to sort
* @returns {Array} 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} 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);
+ 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 = {
+ ${this.currentPath === window.shared_path ? `${sharedByCellHtml(file)}
` : ''}
${file.is_dir ? '
' : `
${this.formatFileSize(file.size)}
`}
${html_encode(formatRelativeTime(file.modified * 1000, Date.now(), window.locale))}
diff --git a/src/gui/src/UI/Dashboard/sharedBy.js b/src/gui/src/UI/Dashboard/sharedBy.js
new file mode 100644
index 000000000..8bb8b372f
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/sharedBy.js
@@ -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
.
+ */
+
+// 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) => `
${window.html_encode(avatarInitial(name))} `;
+
+/**
+ * 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 '
';
+ return `
`
+ + avatarHtml(name, 'item-shared-by-avatar')
+ + `${window.html_encode(name)} `
+ + '
';
+};
+
+/**
+ * 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 `
`
+ + inner
+ + `${count} `
+ + ' ';
+ };
+ let h = chip('', `
${i18n('shared_filter_everyone')} `, total);
+ for ( const { name, count } of sharers ) {
+ h += chip(
+ name,
+ avatarHtml(name, 'sharer-chip-avatar') + `
${window.html_encode(name)} `,
+ count,
+ i18n('shared_by_user', { username: name }, false),
+ );
+ }
+ return h;
+};
diff --git a/src/gui/src/UI/Dashboard/sharedBy.test.js b/src/gui/src/UI/Dashboard/sharedBy.test.js
new file mode 100644
index 000000000..bafcf7db0
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/sharedBy.test.js
@@ -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
.
+ */
+
+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(/ String(v).replace(/&/g, '&').replace(/ ({ 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');
+ expect(h).toContain('
ann ');
+ });
+
+ it('encodes the username', () => {
+ const h = sharedByCellHtml(row('
"x"'));
+ expect(h).not.toContain('');
+ expect(h).toContain('<b>"x"');
+ });
+
+ it('still renders an empty cell, so the columns line up', () => {
+ expect(sharedByCellHtml({})).toBe('
');
+ });
+});
+
+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(/]*>/g);
+ expect(chips).toHaveLength(3);
+ expect(chips[0]).toContain('data-sharer=""');
+ expect(chips[0]).toContain('aria-pressed="true"');
+ expect(h).toContain('shared_filter_everyone:3 ');
+ 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(/]*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: '', count: 1 }], null);
+ expect(h).toContain('data-sharer="a"b"');
+ expect(h).not.toContain('');
+ });
+});
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index b4b9fe5bd..2308d8c36 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -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;
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index a6a0e02b2..dabb868db 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -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}}',