feat(ui): add addressBook and systemUsers model + show person who shared an item with me in tooltip

This commit is contained in:
Edouard Vanbelle
2026-05-24 02:31:50 +02:00
parent 7fd444259f
commit d64beb43a7
5 changed files with 424 additions and 4 deletions
+179
View File
@@ -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<string, ContactItem[]>} bookId → contacts (loaded books) */
const _contactCache = new Map();
/** @type {Map<string, Promise<ContactItem[]>>} 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<AddressBookItem[]>}
*/
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<ContactItem[]>}
*/
async function listContacts(bookId, opts = {}) {
if (_contactCache.has(bookId)) {
return /** @type {ContactItem[]} */ (_contactCache.get(bookId));
}
if (_inflight.has(bookId)) {
return /** @type {Promise<ContactItem[]>} */ (_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<ContactItem[]>}
*/
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
};
+80
View File
@@ -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<string, string> | 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<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)]));
}
// ── 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<string>}
*/
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 };