feat(ui): add addressBook and systemUsers model + show person who shared an item with me in tooltip
This commit is contained in:
@@ -313,3 +313,45 @@
|
|||||||
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
* @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
|
||||||
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner tooltip — shows "Shared by: <display name>" 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<HTMLElement, Handlers>} */
|
||||||
|
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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
|
||||||
import { multiSelect } from '../../features/files/multiSelect.js';
|
import { multiSelect } from '../../features/files/multiSelect.js';
|
||||||
|
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
||||||
import { grants } from '../../model/grants.js';
|
import { grants } from '../../model/grants.js';
|
||||||
import { systemUsers } from '../../model/systemUsers.js';
|
import { systemUsers } from '../../model/systemUsers.js';
|
||||||
|
|
||||||
@@ -201,9 +201,7 @@ const sharedWithMeView = {
|
|||||||
*/
|
*/
|
||||||
_stampOwnerIds(container, ownerMap) {
|
_stampOwnerIds(container, ownerMap) {
|
||||||
for (const [itemId, ownerId] of ownerMap) {
|
for (const [itemId, ownerId] of ownerMap) {
|
||||||
const el = container.querySelector(
|
const el = container.querySelector(`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`);
|
||||||
`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`
|
|
||||||
);
|
|
||||||
if (el instanceof HTMLElement) {
|
if (el instanceof HTMLElement) {
|
||||||
el.dataset.ownerId = ownerId;
|
el.dataset.ownerId = ownerId;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user