/** * OxiCloud – "Shared with me" view. * * Renders files and folders that other users have explicitly granted the * current user access to, using the cursor-paginated * `GET /api/grants/incoming/resources` endpoint. * * Uses `ResourceListComponent` so the grid ↔ list toggle and all card * components work out of the box. A "Load more" button is injected below * the files container for cursor-based pagination. */ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; /** @import {SharedWithMeItem, FileItem, FolderItem, ResourceTypeEnum} from '../../core/types.js' */ /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, * labelFn?: (key: string) => string, * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** * Group-by dimension definitions for this section. * Exported via `sharedWithMeView.groupByDefs` so `main.js` can populate * the dropdown dynamically without knowing the internals of this view. * * `keyFn` returns the grouping key (stable UUID for owner, or a * human-readable bucket label for shareDate — the bucket IS the key because * it is already derived from the date, so no separate `labelFn` is needed * for shareDate). * * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ { key: 'owner', // label is accessed via syncGroupByMenu → read at section-switch time, // when translations are guaranteed to be loaded. get label() { return i18n.t('groupby.owner', 'Owner'); }, orderBy: 'granted_by', // keyFn groups by UUID — stable and unique, avoids collisions between // users with the same display name. keyFn: (item) => { const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.owner_id || null; }, // labelFn resolves UUID → display name from the pre-fetched cache. labelFn: (id) => systemUsers.getDisplayNameSync(id), headerNodeFn: (id) => createUserVignette(id, 'sm') }, { key: 'type', get label() { return i18n.t('groupby.type', 'Type'); }, orderBy: 'type', // keyFn: folders get their own swimlane; files use the pre-computed // `category` field from the DTO (e.g. 'Image', 'Video', 'Audio' …). // The server orders by category_order (a pre-computed SMALLINT column) // so items within the same category arrive grouped — no client sort needed. keyFn: (item) => ('mime_type' in item ? /** @type {Record} */ (/** @type {unknown} */ (item)).category || 'other' : 'Folder'), labelFn: (key) => { // biome-ignore format: keep indentation /** @type {Record} */ const labels = { Folder: i18n.t('groupby.type.folders', 'Folders'), Image: i18n.t('category.images', 'Images'), Video: i18n.t('category.videos', 'Videos'), Audio: i18n.t('category.audio', 'Audio'), PDF: 'PDF', Document: i18n.t('category.documents', 'Documents'), Spreadsheet: i18n.t('category.spreadsheets', 'Spreadsheets'), Presentation: i18n.t('category.presentations', 'Presentations'), Archive: i18n.t('category.archives', 'Archives'), Code: i18n.t('category.code', 'Code'), Markdown: i18n.t('category.markdown', 'Markdown'), Text: i18n.t('category.text', 'Text'), Installer: i18n.t('category.installers', 'Installers') }; return labels[key] ?? key; } }, { key: 'size', get label() { return i18n.t('groupby.size', 'Size'); }, orderBy: 'size', // keyFn: the key IS the bucket label returned by sizeBucket(), so no // separate labelFn is needed (same pattern as shareDate). // Folders have no size — sizeBucket(-1) returns the "Folders" label. keyFn: (item) => { if (!('mime_type' in item)) return sizeBucket(-1); const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return sizeBucket(r.size ?? 0); } // No labelFn: keyFn already returns the human-readable label. }, { key: 'shareDate', get label() { return i18n.t('groupby.shareDate', 'Share date'); }, orderBy: 'granted_at', // keyFn returns the human-readable bucket label; the label IS the key // because consecutive items with the same bucket should be in one group. // sort_date is stored as unix seconds (number) in _mapItems(). keyFn: (item) => { const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.sort_date ? normalizeDateBucket(r.sort_date) : null; } // No labelFn: keyFn already returns the human-readable label. } ]; /** ID of the "Load more" wrapper injected below `.files-container`. */ const LOAD_MORE_ID = 'swm-load-more-wrapper'; const sharedWithMeView = { // ── State ───────────────────────────────────────────────────────────────── /** @type {string|null} */ _nextCursor: null, _loading: false, /** @type {ResourceListComponent|null} */ _component: null, /** * Active group-by key. '' = no grouping, 'owner' | 'shareDate' = active. * @type {string} */ _groupBy: '', /** Whether the current sort order is reversed. */ _reversed: false, // ── Public API ──────────────────────────────────────────────────────────── /** * The group-by dimension definitions for this section. * `main.js` reads this to populate the Group-by dropdown dynamically. * @returns {GroupByDef[]} */ get groupByDefs() { return GROUP_BY_DEFS; }, /** * Change the active group-by dimension and reload from page 1. * Calling with the current key is a no-op. * @param {string} key '' | 'owner' | 'shareDate' */ setGroupBy(key) { if (this._groupBy === key) return; this._groupBy = key; viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view); this._nextCursor = null; // restart from first page this._component?.clear(); this._loadPage(); }, /** * Flip the sort direction and reload from page 1. * Calling with the current value is a no-op. * @param {boolean} reversed */ setDirection(reversed) { if (this._reversed === reversed) return; this._reversed = reversed; viewPrefs.save('sharedwithme', this._groupBy, this._reversed, viewPrefs.load('sharedwithme').view); this._nextCursor = null; this._component?.clear(); this._loadPage(); }, /** * (Re-)load from page 1 and render into the existing files container. * Called every time the user switches to this section. */ async init() { this._nextCursor = null; this._loading = false; const _savedPrefs = viewPrefs.load('sharedwithme'); this._groupBy = _savedPrefs.groupBy; this._reversed = _savedPrefs.reversed; this._ensureLoadMoreButton(); // Start fetching system users in background so tooltips resolve instantly // by the time the user hovers over an item. systemUsers.prefetch(); // Standard files-view setup: clear list, show container ui.resetFilesList(); batchToolbar.init(); ui.updateBreadcrumb(); // Create (or re-use) the component bound to #files-list. const filesList = document.getElementById('files-list'); if (filesList) { if (!this._component) { this._component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { selectable: true, showFavorite: true, showOwner: true, showShareBadge: false, draggable: false, showContextMenu: true, isFavorite: (id, type) => favorites.isFavorite(id, type), isShared: () => false, onOpen: (item) => ui.openItem(item), onFavoriteToggle: async (item) => { const isFile = 'mime_type' in item; const type = isFile ? 'file' : 'folder'; if (favorites.isFavorite(item.id, type)) { await favorites.removeFromFavorites(item.id, type, item.name); this._component?.setFavoriteVisualState(item.id, type, false); } else { await favorites.addToFavorites(item.id, item.name, type, null); this._component?.setFavoriteVisualState(item.id, type, true); } }, onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), onSelectionChange: (selectedItems) => { batchToolbar._selected.clear(); for (const sel of selectedItems) { const isFile = 'mime_type' in sel; batchToolbar._selected.set(sel.id, { id: sel.id, name: sel.name, type: isFile ? 'file' : 'folder', parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' }); } batchToolbar._syncUI(); } }); } batchToolbar.setActiveComponent(this._component); } await this._loadPage(); }, /** * Hide the "Load more" button when leaving this section. * The files container itself is managed by navigation.js. */ hide() { const w = document.getElementById(LOAD_MORE_ID); if (w) w.classList.add('hidden'); batchToolbar.setActiveComponent(null); const filesList = document.getElementById('files-list'); if (filesList) itemTooltip.destroy(filesList); }, // ── Internal helpers ────────────────────────────────────────────────────── /** * Fetch one page, map items → FileItem / FolderItem, render them, then * wire the owner tooltip. * @returns {Promise} */ async _loadPage() { if (this._loading) return; this._loading = true; // Remember whether this is a fresh first-page load (cursor was null on // entry) so we know whether to replace or append items. const isFirstPage = this._nextCursor === null; try { const def = GROUP_BY_DEFS.find((d) => d.key === this._groupBy); // When no swimlane grouping is active, sort by resource name so the // list is alphabetical (same expectation as the Files section). // Group-by modes supply their own orderBy via the def. const orderBy = def?.orderBy ?? 'name'; const data = await grants.fetchSharedWithMe({ resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']), limit: 50, cursor: this._nextCursor ?? undefined, orderBy, reverse: this._reversed }); this._nextCursor = data.next_cursor ?? null; if (data.items.length === 0 && isFirstPage) { // First page came back empty ui.showError(`

${i18n.t('sharedwithme_emptyStateTitle', 'Nothing shared with you yet')}

${i18n.t('sharedwithme_emptyStateDesc', 'Items shared with you by other users will appear here')}

`); this._setLoadMoreVisible(false); return; } const items = this._mapItems(data.items); if (isFirstPage) { this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire owner tooltips after items are in the DOM const filesList = document.getElementById('files-list'); if (filesList) itemTooltip.init(filesList); // Fill the Owner column cells (idempotent: skips already-resolved rows). await this._component?.resolveOwnerCells(); this._setLoadMoreVisible(!!this._nextCursor); } catch (err) { ui.showError(`

${i18n.t('errors_loadFailed', 'Failed to load items')}

`); console.error('sharedWithMeView: load error', err); } finally { this._loading = false; } }, /** * Map `SharedWithMeItem[]` → a flat `(FileItem|FolderItem)[]` in * **server-returned order**. The order must be preserved so that * swimlane grouping (group by owner / share date) works correctly when * the server interleaves files and folders by the sort key. * * Sets `owner_id` to `item.granted_by` so the component stamps * `data-owner-id` with the granter's user ID automatically. * Sets `sort_date` (unix seconds) to the grant date so the shareDate * `keyFn` buckets by when the share was created, not the resource's * own modification time. * * @param {SharedWithMeItem[]} items * @returns {Array} */ _mapItems(items) { /** @type {Array} */ const result = []; /** @param {string} iso @returns {number} */ const grantedAtSecs = (iso) => Math.floor(new Date(iso).getTime() / 1000); for (const item of items) { if (item.resource_type === 'folder') { const f = /** @type {FolderItem} */ (item.resource); result.push( /** @type {FolderItem} */ ({ id: f.id, name: f.name, path: f.path ?? '', parent_id: f.parent_id ?? '', owner_id: item.granted_by, is_root: f.is_root ?? false, created_at: f.created_at, modified_at: f.modified_at, sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', category: 'folder' }) ); } else if (item.resource_type === 'file') { const f = /** @type {FileItem} */ (item.resource); result.push( /** @type {FileItem} */ ({ id: f.id, name: f.name, path: f.path ?? '', folder_id: f.folder_id ?? '', owner_id: item.granted_by, mime_type: f.mime_type, size: f.size, size_formatted: f.size_formatted, created_at: f.created_at, modified_at: f.modified_at, sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', category: f.category }) ); } } return result; }, // ── "Load more" button ──────────────────────────────────────────────────── /** * Create the "Load more" wrapper once and attach it below `.files-container`. * Subsequent calls are no-ops. */ _ensureLoadMoreButton() { if (document.getElementById(LOAD_MORE_ID)) return; const filesContainer = document.querySelector('.files-container'); if (!filesContainer) return; const wrapper = document.createElement('div'); wrapper.id = LOAD_MORE_ID; wrapper.className = 'swm-load-more-wrapper hidden'; const btn = document.createElement('button'); btn.id = 'swm-load-more'; btn.className = 'button secondary'; btn.textContent = i18n.t('sharedwithme_loadMore', 'Load more'); btn.addEventListener('click', () => this._loadPage()); wrapper.appendChild(btn); filesContainer.after(wrapper); }, /** * @param {boolean} visible */ _setLoadMoreVisible(visible) { const w = document.getElementById(LOAD_MORE_ID); if (w) w.classList.toggle('hidden', !visible); } }; export { sharedWithMeView };