fix(profile): stop falling back to email when display_name is empty

The owner attribution helper used to fall back from display_name to
the part of the email before "@" when no display name was set. That
defeats the point of letting users pick a name: anyone who hadn't
explicitly chosen one had their email prefix surfaced publicly.

New fallback chain, applied uniformly:
  - display_name (the user's chosen name) — if set, use it
  - username (also chosen by the user as a URL slug) — if set, use it
  - null — render nothing; the client hides the attribution line

Wire type ActivityPublic.owner_display is now `string | null`.
ActivityRow renders the "Lagt til av X" line only when display is
non-null.

Same idea applied to the user's own surfaces (nav + greeting):
  - Nav button shows "Profil" (a label, not a name) when display_name
    is empty, instead of falling back to the email.
  - Home greeting drops "Velkommen, <name>." entirely when the user
    has no display name, leaving just "Her er aktivitetene dine ...".

The feedback list (moderator view) and admin user table keep showing
the email — moderators and admins legitimately need it to identify
users for triage and role management.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:00:39 +02:00
commit 43c24ec16b
6 changed files with 35 additions and 11 deletions

View file

@ -168,7 +168,7 @@
</button>
<button type="button" onclick={() => (view = 'profile')}
aria-label="Profil">
{session.user.display_name?.trim() || session.user.email}
{session.user.display_name?.trim() || 'Profil'}
</button>
<button onclick={onLogout}>Logg ut</button>
{:else if view !== 'login' && view !== 'signup' && view !== 'recovery'}

View file

@ -211,7 +211,7 @@
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
{/if}
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
{#if activity.visibility === 'public'}
{#if activity.visibility === 'public' && activity.owner_display}
<p class="muted" style="font-size: 0.8rem;">
Lagt til av
{#if activity.owner_username}

View file

@ -117,8 +117,11 @@
</p>
{:else if session.user}
<p class="muted" style="margin: 0;">
Velkommen, {session.user.display_name?.trim() || session.user.email}.
Her er aktivitetene dine for vinteren.
{#if session.user.display_name?.trim()}
Velkommen, {session.user.display_name}. Her er aktivitetene dine for vinteren.
{:else}
Her er aktivitetene dine for vinteren.
{/if}
</p>
{#if !showForm && !editing}
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>

View file

@ -66,16 +66,29 @@ function heartsFor(activityId: string, viewerId: string | null): { count: number
return { count, hearted };
}
function ownerAttribution(ownerId: string): { display: string; username: string | null } {
/**
* Build the public-facing attribution for an owner. Prefer the user's chosen
* `display_name`; fall back to their `username` slug if set (also user-chosen);
* otherwise return null so the client hides attribution entirely. We
* deliberately do NOT fall back to the email or any prefix of it that's a
* contact identifier the user didn't elect to surface.
*
* `username` (the link target) is returned independently and is only non-null
* when the owner has opted into a public list drives whether the
* attribution renders as a link to /<username>/list.
*/
function ownerAttribution(ownerId: string): { display: string | null; username: string | null } {
const row = getDb()
.prepare('SELECT display_name, email, username, public_list_enabled FROM users WHERE id = ?')
.prepare('SELECT display_name, username, public_list_enabled FROM users WHERE id = ?')
.get(ownerId) as
| { display_name: string | null; email: string; username: string | null; public_list_enabled: number | null }
| { display_name: string | null; username: string | null; public_list_enabled: number | null }
| null;
if (!row) return { display: 'ukjent', username: null };
if (!row) return { display: null, username: null };
const display = (row.display_name && row.display_name.trim())
? row.display_name
: (row.email.indexOf('@') > 0 ? row.email.slice(0, row.email.indexOf('@')) : row.email);
: (row.username && row.username.trim())
? row.username
: null;
const username = row.public_list_enabled === 1 ? row.username : null;
return { display, username };
}

View file

@ -57,7 +57,9 @@ usersRoutes.get('/:username/list', (c) => {
id: r.id,
visibility: 'public',
owner_id: r.owner_id,
owner_display: user.display_name?.trim() || username,
// Prefer the display name; fall back to the username slug (which is
// what the URL already shows). Never falls through to email/id.
owner_display: (user.display_name && user.display_name.trim()) || username,
// The list itself is at /<username>/list, so we already know the slug.
// Surfacing it on each row keeps ActivityRow's rendering uniform.
owner_username: username,

View file

@ -173,7 +173,13 @@ export interface ActivityPublic {
id: string;
visibility: 'public';
owner_id: string; // serialized for public
owner_display: string; // display_name OR derived handle (email prefix)
/**
* Public-facing handle: display_name OR (when only the slug is set) the
* username. NULL when the owner has set neither in that case the client
* hides attribution entirely rather than falling back to the email or to
* an identifier the user didn't choose.
*/
owner_display: string | null;
// Owner's URL slug, if they've opted into a public list. When non-null, the
// client renders the owner attribution as a link to /<owner_username>/list.
owner_username: string | null;