diff --git a/static/js/components/userVignette.js b/static/js/components/userVignette.js index 1bee1e16..a760cc9a 100644 --- a/static/js/components/userVignette.js +++ b/static/js/components/userVignette.js @@ -155,14 +155,25 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE // longer existed in the DOM, leaving the badge invisible until // the next render. Creating-then-appending keeps the icon system // and our reveal step in agreement. + // + // We always fetch the email — when `showEmail` is false (the common + // case) it's still used as the hover-tooltip on the vignette so the + // recipient identifier stays discoverable without visual clutter. Promise.all([ systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), - emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null), + systemUsers.getEmail(userId), showOrigin ? systemUsers.getIsExternal(userId) : Promise.resolve(false) ]).then(([name, photo, email, isExternal]) => { if (nameEl) nameEl.textContent = name; if (emailEl) emailEl.textContent = email ?? ''; + // Tooltip: surface the email on hover when it's not already + // rendered as the visible label (showEmail mode) and isn't + // already the displayed name (the fallback case where the user + // has no given/family/username and the label IS the email). + if (email && !showEmail && email !== name) { + wrapper.title = email; + } if (photo) { _applyPhoto(avatar, photo, name); } else { diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js index 8b7c9767..e2f1db39 100644 --- a/static/js/model/systemUsers.js +++ b/static/js/model/systemUsers.js @@ -53,6 +53,31 @@ function _nameFor(c) { return `${c.id.slice(0, 8)}…`; } +/** + * Derive the best display name from a `User` shape (i.e. the + * `/api/users/{id}` payload OR the `oxicloud_user` localStorage blob). + * Priority — matches the server-side `User::display_full()` rule sans + * the email decoration; the `` part is added in the vignette + * layer as a tooltip when the email isn't already in the displayed + * label: + * + * 1. `"Given Family"` — both names set + * 2. `username` — handle (the typical case for password / OIDC + * users with no profile claims) + * 3. `email` — last resort but unambiguous + * 4. shortened UUID — failure mode (e.g. /api/users/{id} returned + * nothing usable) + * + * @param {{id?: string, given_name?: string|null, family_name?: string|null, username?: string|null, email?: string|null}} u + * @returns {string} + */ +function _displayNameFromUser(u) { + if (u.given_name && u.family_name) return `${u.given_name} ${u.family_name}`; + if (u.username) return u.username; + if (u.email) return u.email; + return u.id ? `${u.id.slice(0, 8)}…` : '?'; +} + /** * Ensure both indexes are built (idempotent). * After loading contacts from the system address book, the current user @@ -93,13 +118,13 @@ async function _ensureIndex() { 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) - ); + const u = + /** @type {{id?:string, given_name?:string|null, family_name?:string|null, username?:string|null, email?:string|null, 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); + _index.set(u.id, _displayNameFromUser(u)); } if (!_photoIndex.has(u.id)) { _photoIndex.set(u.id, u.image ?? null); @@ -143,7 +168,7 @@ async function _resolveMissing(userId) { if (!resp.ok) return; /** @type {User} */ const u = await resp.json(); - _index?.set(u.id, u.username || u.email || `${u.id.slice(0, 8)}…`); + _index?.set(u.id, _displayNameFromUser(u)); _photoIndex?.set(u.id, u.image ?? null); _emailIndex?.set(u.id, u.email ?? null); _externalIndex?.set(u.id, !!u.is_external); diff --git a/static/locales/en.json b/static/locales/en.json index b2636368..4ed01aef 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -20,7 +20,7 @@ "email": { "invitation": { "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", - "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + "body": "{{inviter_full}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" }, "login": { "subject": "Sign in to OxiCloud", @@ -34,7 +34,7 @@ "notification": { "share": { "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", - "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)." + "body": "{{inviter_full}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)." } } },