feat(shares): show external users with avatar, email and a badge (#500)

Two related parity gaps from the VanillaJS → Svelte migration (issue #500):
internal-vs-external users weren't badged, and external users in a share's
member list rendered as a bare UUID with a static icon — no avatar, no email.
Both share one root cause: there was no shared user vignette and no resolver
for non-directory (external) users (the system address book lists internal
users only, and ShareDialog hardcoded isExternal=false).

- lib/api/endpoints/users.ts: resolveUser(id) — cached GET /api/users/{id}
  (the authenticated per-user profile lookup) → {name, email, image,
  isExternal}; returns null when the profile isn't visible so callers keep
  their fallback label.
- lib/components/UserVignette.svelte: reusable identity chip — avatar (photo
  or coloured initials), name, email, and a building-circle-xmark badge for
  external users; resolves lazily and falls back to a caller-supplied label.
- lib/utils/avatar.ts: userInitials() + avatarColorIndex() extracted from
  AppShell (now shared by both — no duplicated logic) so vignette and account
  button render identically.
- ShareDialog: user member rows now render <UserVignette>; groups keep their
  icon+label. Drops the dead hardcoded isExternal.

Backend already exposes everything (UserDto.email/image/is_external via
GET /api/users/{id}); no backend change. Frontend gate green (svelte-check
0/0, eslint, stylelint, prettier) + 47 Vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-20 00:23:54 +02:00
parent 3f887089ae
commit b3bde0d896
5 changed files with 254 additions and 27 deletions
+4 -18
View File
@@ -9,6 +9,7 @@
import CommandPalette from '$lib/components/CommandPalette.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { apiFetch } from '$lib/api/client';
import { session } from '$lib/stores/session.svelte';
@@ -171,28 +172,13 @@
: 0
);
const initials = $derived.by(() => {
const u = session.user;
if (!u) return '?';
const base = u.username || u.email || '?';
const parts = base.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return base.slice(0, 2).toUpperCase();
});
const initials = $derived(userInitials(session.user?.username || session.user?.email));
/** Uploaded avatar photo URL, if any. */
const avatarPhoto = $derived(session.user?.image ?? null);
/**
* Deterministic colour bucket 0–4 from the user id (matches the original
* userVignette `_colorIndex`) so the same user always gets the same colour.
*/
const avatarColor = $derived.by(() => {
const id = session.user?.id ?? '';
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
return Math.abs(hash) % 5;
});
/** Deterministic colour bucket 0–4 from the user id (shared with UserVignette). */
const avatarColor = $derived(avatarColorIndex(session.user?.id));
function closeMenus() {
notifOpen = false;