feat(desktop): apply friend nicknames in header / bubble / mentions / call tile

This commit is contained in:
byGalax
2026-05-16 17:06:18 +02:00
parent 18a6365586
commit 8ad212291a
4 changed files with 85 additions and 38 deletions
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useNickname } from '../lib/friendNicknames';
import { import {
CrownIcon, CrownIcon,
HeadphonesOffIcon, HeadphonesOffIcon,
@@ -89,6 +90,7 @@ export interface ParticipantTileProps {
export function CallParticipantTile(props: ParticipantTileProps) { export function CallParticipantTile(props: ParticipantTileProps) {
const { const {
userId,
displayName, displayName,
me, me,
muted, muted,
@@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
onContextMenu, onContextMenu,
} = props; } = props;
// Apply the per-viewer nickname override once at the top — each tile is
// already per-participant, so a single hook call is fine. The resolved
// name is forwarded into the avatar sub-components below so their initial
// letter respects the nickname too.
const resolvedName = useNickname(userId, displayName);
const tileProps = { ...props, displayName: resolvedName };
const small = size === 'small'; const small = size === 'small';
// Discord-style: full tile border switches to emerald the whole time the // Discord-style: full tile border switches to emerald the whole time the
// user is speaking. Same treatment for audio + video tiles so the visual // user is speaking. Same treatment for audio + video tiles so the visual
@@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
} }
> >
{video ? ( {video ? (
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} /> <VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
) : ( ) : (
<AudioContent {...props} small={small} /> <AudioContent {...tileProps} small={small} />
)} )}
{speaking && ( {speaking && (
@@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
/> />
)} )}
<span className="truncate"> <span className="truncate">
{displayName} {resolvedName}
{me ? ' (du)' : ''} {me ? ' (du)' : ''}
</span> </span>
{e2ee && ( {e2ee && (
@@ -3,6 +3,7 @@ import type { PresenceState } from '@chat-app/shared/supabase';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useNickname } from '../lib/friendNicknames';
import type { PeerPresence } from '../lib/usePeerPresence'; import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { import {
@@ -79,7 +80,13 @@ function HeaderBar({
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm'; const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?'); // For DMs, route the peer's display name through the per-viewer nickname
// override. Falls back to the real displayName when no nickname is set.
const peerName = useNickname(
conversation.peer?.userId,
conversation.peer?.displayName ?? '?',
);
const title = isDm ? peerName : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : ''; const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const peerAvatar = isDm const peerAvatar = isDm
? (conversation.peer?.avatarUrl ?? null) ? (conversation.peer?.avatarUrl ?? null)
@@ -1,8 +1,11 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNickname } from '../lib/friendNicknames';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
type Member = ConversationSummary['members'][number];
interface Props { interface Props {
members: ConversationSummary['members']; members: ConversationSummary['members'];
query: string; query: string;
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
aria-label="Mitglieder" aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]" className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
> >
{matches.map((m, idx) => { {matches.map((m, idx) => (
const name = m.profile?.displayName ?? m.profile?.username ?? '?'; <MemberRow
const handle = m.profile?.username ?? ''; key={m.userId}
const isActive = idx === active; member={m}
return ( isActive={idx === active}
<button onActivate={() => setActive(idx)}
key={m.userId} onSelect={onSelect}
type="button" />
role="option" ))}
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
</div> </div>
); );
} }
// Extracted per-row component so we can call `useNickname` once per member
// at the top of THIS component (hooks can't run inside .map callbacks).
function MemberRow({
member,
isActive,
onActivate,
onSelect,
}: {
member: Member;
isActive: boolean;
onActivate: () => void;
onSelect: (username: string) => void;
}) {
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
const name = useNickname(member.userId, fallback);
const handle = member.profile?.username ?? '';
return (
<button
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={onActivate}
onClick={() => {
if (member.profile?.username) onSelect(member.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={member.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
}
+10 -3
View File
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useNickname } from '../lib/friendNicknames';
import { ensureInstallId } from '../lib/installId'; import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity'; import { cachedUserKey } from '../lib/userIdentity';
@@ -100,6 +101,12 @@ export function MessageBubble({
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { session } = useAuth(); const { session } = useAuth();
// Per-viewer nickname override for the message sender. Fallback chain
// keeps the existing behavior when no nickname is set. Skipped for the
// quoted-sender snippet — that's a different user.
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
const parsed = parseMessagePayload(message.plaintext); const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : ''; const initialText = parsed.kind === 'text' ? parsed.text : '';
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : []; const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
@@ -281,7 +288,7 @@ export function MessageBubble({
<AvatarSlot <AvatarSlot
show={isLastOfRun} show={isLastOfRun}
url={senderAvatarUrl ?? null} url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null} displayName={resolvedSenderDisplayName}
{...(onAvatarClick {...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) } ? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})} : {})}
@@ -299,7 +306,7 @@ export function MessageBubble({
parsed={parsed} parsed={parsed}
mine={mine} mine={mine}
time={time} time={time}
senderDisplayName={senderDisplayName ?? null} senderDisplayName={resolvedSenderDisplayName}
/> />
); );
} }
@@ -315,7 +322,7 @@ export function MessageBubble({
<AvatarSlot <AvatarSlot
show={isLastOfRun} show={isLastOfRun}
url={senderAvatarUrl ?? null} url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null} displayName={resolvedSenderDisplayName}
{...(onAvatarClick {...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) } ? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})} : {})}