// @ts-check /** * System-users convenience layer. * * Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that * provides a userId → display-name index, a userId → photo-url index, * and a userId → primary-email index. * Used wherever a grant's `granted_by` UUID needs to be shown as a * human-readable name (owner tooltips, share dialogs, etc.), avatar * image (userVignette, user menu), or email (suggestion dropdowns). * * 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, User} from '../core/types.js' */ import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js'; /** @type {Map | null} userId → display name, built lazily */ let _index = null; /** @type {Map | null} userId → photo URL (or null), built lazily */ let _photoIndex = null; /** @type {Map | null} userId → primary email (or null), built lazily */ let _emailIndex = null; /** @type {Map | null} userId → is_external flag, built lazily. * The system-book bulk load populates `false` for every entry (PR 6 filters * externals out of the system book). Externals appear only when their UUID * shows up in a grant — `_resolveMissing` then back-fills via `/api/users/{id}`. */ let _externalIndex = null; /** @type {Map>} userId → in-flight fetch (de-dupe). */ const _inflight = new Map(); /** * 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 both indexes are 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} */ 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)])); _photoIndex = new Map(contacts.map((c) => [c.id, c.photo_url ?? null])); _emailIndex = new Map( contacts.map((c) => { const primary = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? null; return [c.id, primary]; }) ); // System book is internal-only post-PR-6 → every entry here is is_external=false. _externalIndex = new Map(contacts.map((c) => [c.id, false])); // 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, image?:string|null, is_external?:boolean}} */ ( JSON.parse(raw) ); if (u?.id) { if (!_index.has(u.id)) { const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`; _index.set(u.id, name); } if (!_photoIndex.has(u.id)) { _photoIndex.set(u.id, u.image ?? null); } if (!_emailIndex.has(u.id)) { _emailIndex.set(u.id, u.email ?? null); } if (!_externalIndex.has(u.id)) { _externalIndex.set(u.id, u.is_external ?? false); } } } } catch { // localStorage not available or JSON is invalid — silently skip } } /** * Fetch a single user profile from `/api/users/{id}` and back-fill every * cache map. Used when a userId surfaces (e.g. via a grant) that wasn't * part of the bulk system-book load — typically external users. * * In-flight requests are de-duplicated through `_inflight` so concurrent * vignette renders for the same external userId issue only one HTTP call. * Failures (404 / 403 / 429 / network) leave the caches in their * default-unknown state; callers fall back to UUID-prefix display. * * @param {string} userId * @returns {Promise} */ async function _resolveMissing(userId) { if (_index?.has(userId)) return; const pending = _inflight.get(userId); if (pending) return pending; const promise = (async () => { try { const resp = await fetch(`/api/users/${encodeURIComponent(userId)}`, { credentials: 'same-origin' }); if (!resp.ok) return; /** @type {User} */ const u = await resp.json(); _index?.set(u.id, u.username || u.email || `${u.id.slice(0, 8)}…`); _photoIndex?.set(u.id, u.image ?? null); _emailIndex?.set(u.id, u.email ?? null); _externalIndex?.set(u.id, !!u.is_external); } catch { // network error — caches stay unset; getters fall back to defaults } finally { _inflight.delete(userId); } })(); _inflight.set(userId, promise); return promise; } // ── 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 } /** * Synchronous best-effort display-name lookup from the pre-fetched cache. * Returns a shortened UUID prefix when the cache is not yet loaded. * Call `prefetch()` at view init time so the cache is warm by the time * items are rendered. * @param {string} userId * @returns {string} */ function getDisplayNameSync(userId) { if (_index === null) return `${userId.slice(0, 8)}…`; return _index.get(userId) ?? `${userId.slice(0, 8)}…`; } /** * Resolve a user UUID to a display name. * Awaits the first load if not yet cached; subsequent calls resolve * instantly. On a system-book miss (e.g. external users, which are * filtered out of the system address book), back-fills via * `/api/users/{id}` once per session. * * @param {string} userId * @returns {Promise} */ async function getDisplayName(userId) { await _ensureIndex(); if (!_index?.has(userId)) await _resolveMissing(userId); return _index?.get(userId) ?? `${userId.slice(0, 8)}…`; } /** * Resolve a user UUID to a photo URL (or null if none set). * Awaits the first load if not yet cached; subsequent calls resolve instantly. * * @param {string} userId * @returns {Promise} */ async function getPhoto(userId) { await _ensureIndex(); if (!_index?.has(userId)) await _resolveMissing(userId); return _photoIndex?.get(userId) ?? null; } /** * Resolve a user UUID to their primary email address (or null if unknown). * Awaits the first load if not yet cached; subsequent calls resolve instantly. * * @param {string} userId * @returns {Promise} */ async function getEmail(userId) { await _ensureIndex(); if (!_index?.has(userId)) await _resolveMissing(userId); return _emailIndex?.get(userId) ?? null; } /** * Resolve a user UUID to whether they are an external (grant-only) * recipient. Defaults to `false` (internal-by-assumption) for unknown * UUIDs so callers can render without an extra null-check. * Awaits the first system-book load; falls back to `/api/users/{id}` * on miss — externals are excluded from the system book per PR 6. * * @param {string} userId * @returns {Promise} */ async function getIsExternal(userId) { await _ensureIndex(); if (!_externalIndex?.has(userId)) await _resolveMissing(userId); return _externalIndex?.get(userId) ?? false; } /** * Force-refresh the current user's photo entry in the index from localStorage. * Call this after saving a new avatar on the profile page so that existing * vignettes can re-render without a full page reload. */ function refreshCurrentUserPhoto() { try { const raw = localStorage.getItem('oxicloud_user'); if (!raw || !_photoIndex) return; const u = /** @type {{id?:string, image?:string|null}} */ (JSON.parse(raw)); if (u?.id) { _photoIndex.set(u.id, u.image ?? null); } } catch { // ignore } } /** * 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, getDisplayNameSync, getPhoto, getEmail, getIsExternal, refreshCurrentUserPhoto, isAvailable };