diff --git a/static/js/core/types.js b/static/js/core/types.js index 030f29a3..58c87cf8 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -313,3 +313,45 @@ * @property {string|undefined} [next_cursor] - Absent when the last page is reached. */ +/** + * @typedef {Object} ContactEmail + * @property {string} email + * @property {string} type - e.g. "work", "home" + * @property {boolean} is_primary + */ + +/** + * Mirrors the backend `ContactDto`. + * `id` equals the OxiCloud user UUID for contacts from the system address book. + * @typedef {Object} ContactItem + * @property {string} id + * @property {string} address_book_id + * @property {string} uid - vCard UID + * @property {string|null} [full_name] + * @property {string|null} [first_name] + * @property {string|null} [last_name] + * @property {string|null} [nickname] + * @property {ContactEmail[]} email + * @property {string|null} [organization] + * @property {string|null} [title] + * @property {string|null} [photo_url] + * @property {string} created_at - ISO-8601 + * @property {string} updated_at - ISO-8601 + * @property {string} etag + */ + +/** + * Mirrors the backend `AddressBookResponse`. + * @typedef {Object} AddressBookItem + * @property {string} id + * @property {string} name + * @property {string} owner_id + * @property {string|null} [description] + * @property {string|null} [color] + * @property {boolean} is_public + * @property {boolean} is_readonly + * @property {boolean} is_system + * @property {string} created_at - ISO-8601 + * @property {string} updated_at - ISO-8601 + */ + diff --git a/static/js/features/ownerTooltip.js b/static/js/features/ownerTooltip.js new file mode 100644 index 00000000..3349374d --- /dev/null +++ b/static/js/features/ownerTooltip.js @@ -0,0 +1,121 @@ +// @ts-check + +/** + * Owner tooltip — shows "Shared by: " when hovering a + * `.file-item[data-owner-id]` element. + * + * Reuses the existing `#path-tooltip` DOM element (same position and style) + * so no extra CSS is needed. The tooltip is hidden immediately on mouseleave + * and the display-name resolution is async-but-usually-instant because + * `systemUsers` is pre-fetched when the Shared-with-me section is entered. + * + * Usage: + * ownerTooltip.init(containerEl) — call after rendering items + * ownerTooltip.destroy(containerEl) — call when leaving the section + */ + +import { i18n } from '../core/i18n.js'; +import { systemUsers } from '../model/systemUsers.js'; + +// ── Tooltip DOM ─────────────────────────────────────────────────────────────── + +/** @returns {HTMLElement} */ +function _getOrCreateTooltip() { + let el = document.getElementById('path-tooltip'); + if (!el) { + el = document.createElement('div'); + el.id = 'path-tooltip'; + el.className = 'path-tooltip hidden'; + document.querySelector('.main-content')?.appendChild(el); + } + return el; +} + +function _hide() { + document.getElementById('path-tooltip')?.classList.add('hidden'); +} + +// ── Event handlers ──────────────────────────────────────────────────────────── + +/** + * @param {MouseEvent} e + */ +async function _onEnter(e) { + const item = /** @type {HTMLElement} */ (e.currentTarget); + const ownerId = item.dataset.ownerId; + if (!ownerId) return; + + if (!systemUsers.isAvailable()) return; + + const tooltip = _getOrCreateTooltip(); + + // Show immediately with a placeholder so the tooltip appears without lag. + const label = i18n.t('sharedwithme_sharedBy', 'Shared by'); + tooltip.textContent = `${label}: …`; + tooltip.classList.remove('hidden'); + + // Resolve the name (usually instant from the pre-fetched cache). + const name = await systemUsers.getDisplayName(ownerId); + + // Guard: don't update if the user already moved away. + if (!tooltip.classList.contains('hidden')) { + tooltip.textContent = `${label}: ${name}`; + } +} + +function _onLeave() { + _hide(); +} + +// ── Listener registry (WeakMap for leak-free cleanup) ──────────────────────── + +/** + * @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers + */ + +/** @type {WeakMap} */ +const _registry = new WeakMap(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Attach owner-tooltip listeners to every `.file-item[data-owner-id]` + * inside `container`. + * @param {HTMLElement} container + */ +function init(container) { + for (const item of container.querySelectorAll('.file-item[data-owner-id]')) { + const el = /** @type {HTMLElement} */ (item); + if (_registry.has(el)) continue; // already wired + + /** @type {(e: MouseEvent) => void} */ + const enter = (e) => { + _onEnter(e); + }; // intentionally discard the Promise + const leave = () => _onLeave(); + + el.addEventListener('mouseenter', enter); + el.addEventListener('mouseleave', leave); + _registry.set(el, { enter, leave }); + } +} + +/** + * Remove owner-tooltip listeners from all `.file-item` elements inside + * `container` and hide any visible tooltip. + * @param {HTMLElement} container + */ +function destroy(container) { + for (const item of container.querySelectorAll('.file-item')) { + const el = /** @type {HTMLElement} */ (item); + const h = _registry.get(el); + if (h) { + el.removeEventListener('mouseenter', h.enter); + el.removeEventListener('mouseleave', h.leave); + _registry.delete(el); + } + } + _hide(); +} + +export const ownerTooltip = { init, destroy }; diff --git a/static/js/model/addressBook.js b/static/js/model/addressBook.js new file mode 100644 index 00000000..460201f1 --- /dev/null +++ b/static/js/model/addressBook.js @@ -0,0 +1,179 @@ +// @ts-check + +/** + * Address Book model. + * + * Provides access to all address books (user-owned + shared + the virtual + * system book) and their contacts. Serves as the single source of truth for + * contact data across the application — sharing dialogs, owner tooltips, etc. + * + * Caching strategy + * ───────────────── + * • System book — cached for the whole session (contacts = OxiCloud users, + * changes rarely and requires a page reload to pick up anyway). + * • User books — cached on first load; call `invalidate(bookId)` after a + * write (create/update/delete contact) to force a re-fetch. + * + * The system book returns 404 when `OXICLOUD_EXPOSE_SYSTEM_USERS` is disabled. + * In that case `isSystemAvailable()` returns false and all callers degrade + * gracefully. + */ + +/** @import {AddressBookItem, ContactItem} from '../core/types.js' */ + +/** Sentinel id for the virtual system address book. */ +export const SYSTEM_BOOK_ID = 'system'; + +/** @type {AddressBookItem[] | null} */ +let _books = null; + +/** @type {Map} bookId → contacts (loaded books) */ +const _contactCache = new Map(); + +/** @type {Map>} bookId → in-flight request */ +const _inflight = new Map(); + +/** + * `null` = not yet attempted + * `true` = loaded successfully at least once + * `false` = 404 / feature disabled + * @type {boolean | null} + */ +let _systemAvailable = null; + +// ── Address books ───────────────────────────────────────────────────────────── + +/** + * List all address books accessible to the current user. + * Result is cached for the session. + * @returns {Promise} + */ +async function listBooks() { + if (_books !== null) return _books; + const res = await fetch('/api/address-books', { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`addressBook.listBooks: HTTP ${res.status}`); + _books = /** @type {AddressBookItem[]} */ (await res.json()); + return _books; +} + +// ── Contacts ────────────────────────────────────────────────────────────────── + +/** + * List contacts in an address book. + * + * Results are cached per book id. For the system book, a 404 is treated as + * "feature disabled" — an empty array is returned and `isSystemAvailable()` + * will report false. + * + * @param {string} bookId + * @param {{ limit?: number, offset?: number }} [opts] + * @returns {Promise} + */ +async function listContacts(bookId, opts = {}) { + if (_contactCache.has(bookId)) { + return /** @type {ContactItem[]} */ (_contactCache.get(bookId)); + } + + if (_inflight.has(bookId)) { + return /** @type {Promise} */ (_inflight.get(bookId)); + } + + const p = (async () => { + try { + const params = new URLSearchParams(); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.size ? `?${params}` : ''; + + const res = await fetch(`/api/address-books/${encodeURIComponent(bookId)}/contacts${qs}`, { + credentials: 'same-origin', + cache: 'default' + }); + + if (res.status === 404 && bookId === SYSTEM_BOOK_ID) { + _systemAvailable = false; + _contactCache.set(bookId, []); + return /** @type {ContactItem[]} */ ([]); + } + + if (!res.ok) { + throw new Error(`addressBook.listContacts(${bookId}): HTTP ${res.status}`); + } + + const contacts = /** @type {ContactItem[]} */ (await res.json()); + _contactCache.set(bookId, contacts); + if (bookId === SYSTEM_BOOK_ID) _systemAvailable = true; + return contacts; + } finally { + _inflight.delete(bookId); + } + })(); + + _inflight.set(bookId, p); + return p; +} + +/** + * Invalidate the contact cache for a given book so the next `listContacts` + * call re-fetches from the server. Call after any write operation. + * @param {string} bookId + */ +function invalidate(bookId) { + _contactCache.delete(bookId); +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +/** + * Search contacts across one or more address books. + * + * Matching is case-insensitive against the full name, first+last name, and + * primary email. Books are fetched and cached on first use. + * + * @param {string} query + * @param {string[]} [bookIds] - Books to search. Defaults to all cached books. + * Pass `[SYSTEM_BOOK_ID]` to restrict to OxiCloud users. + * @returns {Promise} + */ +async function searchContacts(query, bookIds) { + const ids = bookIds ?? [..._contactCache.keys()]; + const q = query.toLowerCase().trim(); + if (!q) return []; + + /** @type {ContactItem[]} */ + const results = []; + + for (const id of ids) { + const contacts = await listContacts(id); + for (const c of contacts) { + const fullName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || ''; + const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? ''; + + if (fullName.toLowerCase().includes(q) || primaryEmail.toLowerCase().includes(q)) { + results.push(c); + } + } + } + + return results; +} + +// ── Status ──────────────────────────────────────────────────────────────────── + +/** + * Whether the system address book is (or may be) available. + * Returns `true` when status is unknown (not yet fetched). + * Returns `false` only after a confirmed 404. + * @returns {boolean} + */ +function isSystemAvailable() { + return _systemAvailable !== false; +} + +export const addressBook = { + listBooks, + listContacts, + invalidate, + searchContacts, + isSystemAvailable +}; diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js new file mode 100644 index 00000000..fb035ccf --- /dev/null +++ b/static/js/model/systemUsers.js @@ -0,0 +1,80 @@ +// @ts-check + +/** + * System-users convenience layer. + * + * Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that + * provides a userId → display-name index. Used wherever a grant's + * `granted_by` UUID needs to be shown as a human-readable name + * (owner tooltips, share dialogs, etc.). + * + * Falls back gracefully when the system address book is disabled + * server-side (`OXICLOUD_EXPOSE_SYSTEM_USERS` not set): `isAvailable()` + * returns false and `getDisplayName()` returns a shortened UUID. + */ + +/** @import {ContactItem} from '../core/types.js' */ + +import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js'; + +/** @type {Map | null} userId → display name, built lazily */ +let _index = null; + +/** + * Derive the best human-readable name from a contact. + * Priority: "First Last" → full_name → primary email → shortened id. + * @param {ContactItem} c + * @returns {string} + */ +function _nameFor(c) { + const parts = /** @type {string[]} */ ([c.first_name, c.last_name].filter(Boolean)); + if (parts.length) return parts.join(' '); + if (c.full_name) return c.full_name; + const mail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email; + if (mail) return mail; + return `${c.id.slice(0, 8)}…`; +} + +/** + * Ensure the index is built (idempotent). + * @returns {Promise} + */ +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)])); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Start loading the system address book in the background. + * Safe to call multiple times — subsequent calls are no-ops once loaded. + */ +function prefetch() { + if (!addressBook.isSystemAvailable()) return; + _ensureIndex(); // intentionally fire-and-forget +} + +/** + * Resolve a user UUID to a display name. + * Awaits the first load if not yet cached; subsequent calls resolve instantly. + * + * @param {string} userId + * @returns {Promise} + */ +async function getDisplayName(userId) { + await _ensureIndex(); + return _index?.get(userId) ?? `${userId.slice(0, 8)}…`; +} + +/** + * Returns `false` only after a confirmed 404 from the server (feature + * disabled). Returns `true` when status is unknown or the book loaded OK. + * @returns {boolean} + */ +function isAvailable() { + return addressBook.isSystemAvailable(); +} + +export const systemUsers = { prefetch, getDisplayName, isAvailable }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 24ff8171..bcde3ac8 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -16,8 +16,8 @@ import { ui } from '../../app/ui.js'; import { i18n } from '../../core/i18n.js'; -import { ownerTooltip } from '../../features/ownerTooltip.js'; import { multiSelect } from '../../features/files/multiSelect.js'; +import { ownerTooltip } from '../../features/ownerTooltip.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; @@ -201,9 +201,7 @@ const sharedWithMeView = { */ _stampOwnerIds(container, ownerMap) { for (const [itemId, ownerId] of ownerMap) { - const el = container.querySelector( - `[data-folder-id="${itemId}"], [data-file-id="${itemId}"]` - ); + const el = container.querySelector(`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`); if (el instanceof HTMLElement) { el.dataset.ownerId = ownerId; }