feat(list view): show the owner of the File or Folder in list view

This commit is contained in:
Edouard Vanbelle
2026-05-25 22:34:45 +02:00
parent 79c1a37931
commit 8365608bd5
32 changed files with 365 additions and 67 deletions
+1
View File
@@ -233,6 +233,7 @@ async function loadFiles(options = { insertHistory: true }) {
} else {
ui.renderFolders(folderList);
ui.renderFiles(fileList);
ui.resolveOwnerCells();
// check if a file was provided
if (app.viewFile) {
+12
View File
@@ -164,6 +164,9 @@ function setCurrentSection(section) {
sharedWithMeView.hide();
}
// Reset owner column — sections that need it re-enable it explicitly below.
ui.setOwnerColumnVisible(false);
// Hide photosView when switching to any other section
if (section !== 'photos' && photosView) {
photosView.hide();
@@ -211,6 +214,9 @@ function switchToSharedWithMeSection() {
// Show actions-bar with view toggle (no upload / new-folder in this view)
setActionsBarMode('sharedwithme');
// Show the Owner column — names are resolved async after render.
ui.setOwnerColumnVisible(true);
// Show the standard files container and respect grid/list preference
toggleFileContainer(true);
syncViewContainers();
@@ -227,6 +233,9 @@ function switchToFilesSection() {
// Set actions bar mode
setActionsBarMode('files', true);
// Show owner column in the Files section
ui.setOwnerColumnVisible(true);
// Show breadcrumb (only in Files view)
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.remove('hidden');
@@ -258,6 +267,9 @@ function switchToFavoritesSection() {
// Set actions bar mode
setActionsBarMode('favorites');
// Show the Owner column — names are resolved async after render.
ui.setOwnerColumnVisible(true);
// Hide breadcrumb (only shown in Files view)
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.add('hidden');
+52
View File
@@ -6,6 +6,7 @@
// @ts-check
import { shareModal } from '../components/shareModal.js';
import { createUserVignette } from '../components/userVignette.js';
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { OxiIcons } from '../core/icons.js';
@@ -18,6 +19,7 @@ import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { thumbnail } from '../features/thumbnail.js';
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
import { loadFiles } from './filesView.js';
import { updateHistory } from './main.js';
import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js';
@@ -37,6 +39,12 @@ const ui = {
/** @type {HTMLDivElement | null} */
draggedItems: null,
/**
* Whether the Owner column is currently visible.
* Tracked so that newly rendered items can stamp the correct initial class.
*/
_ownerVisible: false,
/**
* Initialize context menus and dialogs
*/
@@ -436,6 +444,47 @@ const ui = {
syncViewContainers();
},
/**
* Show or hide the Owner column. When hidden, no name-resolution calls are made.
* Sections that show owner (SharedWithMe, Favorites) pass `true`; all others `false`.
* @param {boolean} visible
*/
setOwnerColumnVisible(visible) {
this._ownerVisible = visible;
document
.getElementById('files-list')
?.querySelectorAll('.owner-cell')
.forEach((cell) => {
cell.classList.toggle('hidden', !visible);
});
},
/**
* Asynchronously fill every un-resolved `.owner-cell` in the current list with
* the display name for its `data-owner-id` attribute.
*
* Call this after `renderFiles()` / `renderFolders()` in sections where the owner
* column is visible. Idempotent: cells already stamped with `data-owner-resolved`
* are skipped (safe to call on each "Load more" page append).
*
* When the column is hidden nothing calls this function, so `systemUsers` is never
* touched and no address-book requests are issued.
*
* @returns {Promise<void>}
*/
async resolveOwnerCells() {
const filesList = document.getElementById('files-list');
const cells = /** @type {NodeListOf<HTMLElement>} */ (filesList?.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])'));
if (!cells?.length) return;
systemUsers.prefetch(); // warm cache once (idempotent, fire-and-forget)
for (const cell of cells) {
const id = cell.dataset.ownerId;
cell.dataset.ownerResolved = '1';
if (!id) continue;
cell.replaceChildren(createUserVignette(id, 'sm'));
}
},
/**
* Update breadcrumb navigation from the breadcrumbPath array.
* Renders: Home > folder1 > folder2 > ...
@@ -1236,6 +1285,7 @@ const ui = {
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
<div class="type-cell">${i18n.t('files.file_types.folder')}</div>
<div class="size-cell">--</div>
<div class="date-cell">${formattedDate}</div>
@@ -1290,6 +1340,7 @@ const ui = {
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
<div class="type-cell">${typeLabel}</div>
<div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</div>
@@ -1329,6 +1380,7 @@ const ui = {
<div class="list-header">
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
<div data-i18n="files.name">Name</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-i18n="files.owner">Owner</div>
<div data-i18n="files.type">Type</div>
<div data-i18n="files.size">Size</div>
<div data-i18n="files.modified">Modified</div>
+18 -19
View File
@@ -21,6 +21,7 @@ import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
import { Modal } from './modal.js';
import { _colorIndex, _initials } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
@@ -73,17 +74,6 @@ function _buildMembers(grantList) {
return members;
}
/**
* Get initials for an avatar (up to 2 chars).
* @param {string} name
* @returns {string}
*/
function _initials(name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
// ── Component ──────────────────────────────────────────────────────────────────
const shareModal = {
@@ -291,7 +281,16 @@ const shareModal = {
}
debounce = setTimeout(async () => {
const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]);
this._renderSuggestions(dropdown, results.slice(0, 8), (contact) => {
// Filter out the currently logged-in user — they cannot share with themselves
const currentUserId = (() => {
try {
return /** @type {{id?:string}} */ (JSON.parse(localStorage.getItem('oxicloud_user') ?? '{}'))?.id ?? null;
} catch {
return null;
}
})();
const filtered = currentUserId ? results.filter((c) => c.id !== currentUserId) : results;
this._renderSuggestions(dropdown, filtered.slice(0, 8), (contact) => {
this._stageUser(contact, input, dropdown, addBtn);
});
}, 200);
@@ -332,13 +331,13 @@ const shareModal = {
container.classList.add('hidden');
return;
}
results.forEach((c, i) => {
results.forEach((c) => {
const item = document.createElement('div');
item.className = 'smd-suggestion-item';
item.tabIndex = 0;
const avatar = document.createElement('div');
avatar.className = `smd-suggestion-avatar smd-avatar--${i % 5}`;
avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`;
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
avatar.textContent = _initials(displayName);
@@ -411,12 +410,12 @@ const shareModal = {
*/
_renderChipsInto(container) {
container.replaceChildren();
this._stagedUsers.forEach((c, i) => {
this._stagedUsers.forEach((c) => {
const chip = document.createElement('div');
chip.className = 'smd-chip';
const avatar = document.createElement('div');
avatar.className = `smd-chip-avatar smd-avatar--${i % 5}`;
avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`;
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
avatar.textContent = _initials(displayName);
@@ -516,15 +515,15 @@ const shareModal = {
/**
* @param {MemberEntry} entry
* @param {number} idx
* @param {number} _idx (unused — color is now derived deterministically from userId)
* @returns {HTMLElement}
*/
_buildMemberRow(entry, idx) {
_buildMemberRow(entry, _idx) {
const row = document.createElement('div');
row.className = 'smd-member-row';
const avatar = document.createElement('div');
avatar.className = `smd-member-avatar smd-avatar--${idx % 5}`;
avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`;
// Resolve display name async
systemUsers.getDisplayName(entry.grant.subject.id).then((name) => {
+85
View File
@@ -0,0 +1,85 @@
// @ts-check
/**
* UserVignette — reusable user avatar + name inline component.
*
* Renders a coloured circle with initials (or photo when available) alongside
* an asynchronously-resolved display name. Used in:
* • Owner column (list view) via `ui.resolveOwnerCells()`
* • ShareModal member rows / chips / suggestion items
*
* Usage:
* import { createUserVignette } from './userVignette.js';
* cell.replaceChildren(createUserVignette(userId, 'sm'));
*/
import { systemUsers } from '../model/systemUsers.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
/**
* Get initials for an avatar (1-2 characters).
* @param {string} name
* @returns {string}
*/
export function _initials(name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
/**
* Deterministic color index 0-4 derived from a userId string.
* Same userId always maps to the same color across all components.
* @param {string} userId
* @returns {number}
*/
export function _colorIndex(userId) {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 5;
}
// ── Component ──────────────────────────────────────────────────────────────────
/**
* @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize
*/
/**
* Create a user vignette element: a coloured initials circle + async-resolved
* display name span. The element is returned immediately with a short-UUID
* placeholder; the name resolves in the background via `systemUsers`.
*
* @param {string} userId UUID of the user
* @param {VignetteSize} [size='sm']
* @returns {HTMLElement}
*/
export function createUserVignette(userId, size = 'sm') {
const colorIdx = _colorIndex(userId);
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
wrapper.className = `user-vignette user-vignette--${size}`;
const avatar = document.createElement('span');
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
// Temporary placeholder: first two chars of UUID
avatar.textContent = userId.slice(0, 2).toUpperCase();
const nameEl = document.createElement('span');
nameEl.className = 'user-vignette__name';
nameEl.textContent = `${userId.slice(0, 8)}…`;
wrapper.appendChild(avatar);
wrapper.appendChild(nameEl);
// Resolve full name asynchronously and update both avatar initials and name
systemUsers.getDisplayName(userId).then((name) => {
avatar.textContent = _initials(name);
nameEl.textContent = name;
});
return wrapper;
}
+1
View File
@@ -103,6 +103,7 @@
* @property {String} icon_special_class
* @property {String} category
* @property {String} size_formatted
* @property {string|null} owner_id UUID of the file/folder's actual owner
*/
/**
+5 -3
View File
@@ -201,7 +201,7 @@ const favorites = {
const files = [];
for (const item of this._cache.values()) {
// TODO: cast objects, but for that need to review user_id vs owner_id...
// owner_id comes from the backend JOIN (actual file/folder owner, not the favoriter)
if (item.item_type === 'folder') {
folders.push(
// FIXME: better to grab the real values
@@ -215,7 +215,7 @@ const favorites = {
created_at: item.created_at,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: item.user_id,
owner_id: item.owner_id ?? '',
is_root: false
}
);
@@ -234,7 +234,7 @@ const favorites = {
size_formatted: item.size_formatted,
modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
owner_id: item.user_id,
owner_id: item.owner_id ?? '',
created_at: item.created_at,
sort_date: item.created_at
}
@@ -246,6 +246,8 @@ const favorites = {
const filesList = document.getElementById('files-list');
if (filesList) pathTooltip.init(filesList);
await ui.resolveOwnerCells();
} catch (error) {
console.error('Error displaying favorites:', error);
if (ui?.showNotification) {
+17
View File
@@ -37,12 +37,29 @@ function _nameFor(c) {
/**
* Ensure the index is built (idempotent).
* After loading contacts from the system address book, the current user
* (from localStorage) is injected so owner cells resolve correctly even
* when the server-side address book does not include the logged-in user.
* @returns {Promise<void>}
*/
async function _ensureIndex() {
if (_index !== null) return;
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
_index = new Map(contacts.map((c) => [c.id, _nameFor(c)]));
// Inject the current user if they are not already in the index
try {
const raw = localStorage.getItem('oxicloud_user');
if (raw) {
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw));
if (u?.id && !_index.has(u.id)) {
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
_index.set(u.id, name);
}
}
} catch {
// localStorage not available or JSON is invalid — silently skip
}
}
// ── Public API ────────────────────────────────────────────────────────────────
@@ -112,6 +112,9 @@ const sharedWithMeView = {
ownerTooltip.init(filesList);
}
// Fill the Owner column cells (idempotent: skips already-resolved rows).
await ui.resolveOwnerCells();
this._setLoadMoreVisible(!!this._nextCursor);
} catch (err) {
ui.showError(`