diff --git a/apps/desktop/src/components/CallParticipantTile.tsx b/apps/desktop/src/components/CallParticipantTile.tsx index 6ea808c..3d1f3d7 100644 --- a/apps/desktop/src/components/CallParticipantTile.tsx +++ b/apps/desktop/src/components/CallParticipantTile.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; +import { useNickname } from '../lib/friendNicknames'; import { CrownIcon, HeadphonesOffIcon, @@ -89,6 +90,7 @@ export interface ParticipantTileProps { export function CallParticipantTile(props: ParticipantTileProps) { const { + userId, displayName, me, muted, @@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) { onContextMenu, } = 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'; // Discord-style: full tile border switches to emerald the whole time the // user is speaking. Same treatment for audio + video tiles so the visual @@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) { } > {video ? ( - + ) : ( - + )} {speaking && ( @@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) { /> )} - {displayName} + {resolvedName} {me ? ' (du)' : ''} {e2ee && ( diff --git a/apps/desktop/src/components/ConversationHeader.tsx b/apps/desktop/src/components/ConversationHeader.tsx index 829504f..0215a83 100644 --- a/apps/desktop/src/components/ConversationHeader.tsx +++ b/apps/desktop/src/components/ConversationHeader.tsx @@ -3,6 +3,7 @@ import type { PresenceState } from '@chat-app/shared/supabase'; import { useTranslation } from 'react-i18next'; import { useCall } from '../context/CallContext'; +import { useNickname } from '../lib/friendNicknames'; import type { PeerPresence } from '../lib/usePeerPresence'; import { Avatar } from './Avatar'; import { @@ -79,7 +80,13 @@ function HeaderBar({ const { t } = useTranslation(['app']); 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 peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) diff --git a/apps/desktop/src/components/MentionAutocomplete.tsx b/apps/desktop/src/components/MentionAutocomplete.tsx index c896438..5bdf96e 100644 --- a/apps/desktop/src/components/MentionAutocomplete.tsx +++ b/apps/desktop/src/components/MentionAutocomplete.tsx @@ -1,8 +1,11 @@ import type { ConversationSummary } from '@chat-app/shared/chat'; import { useEffect, useState } from 'react'; +import { useNickname } from '../lib/friendNicknames'; import { Avatar } from './Avatar'; +type Member = ConversationSummary['members'][number]; + interface Props { members: ConversationSummary['members']; query: string; @@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o 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]" > - {matches.map((m, idx) => { - const name = m.profile?.displayName ?? m.profile?.username ?? '?'; - const handle = m.profile?.username ?? ''; - const isActive = idx === active; - return ( - - ); - })} + {matches.map((m, idx) => ( + setActive(idx)} + onSelect={onSelect} + /> + ))} ); } + +// 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 ( + + ); +} diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index 9945171..6556312 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -10,6 +10,7 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; +import { useNickname } from '../lib/friendNicknames'; import { ensureInstallId } from '../lib/installId'; import { supabase } from '../lib/supabase'; import { cachedUserKey } from '../lib/userIdentity'; @@ -100,6 +101,12 @@ export function MessageBubble({ const { t } = useTranslation(['app']); 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 initialText = parsed.kind === 'text' ? parsed.text : ''; const initialAttachments = parsed.kind === 'text' ? parsed.attachments : []; @@ -281,7 +288,7 @@ export function MessageBubble({ onAvatarClick(message.senderId, ev) } : {})} @@ -299,7 +306,7 @@ export function MessageBubble({ parsed={parsed} mine={mine} time={time} - senderDisplayName={senderDisplayName ?? null} + senderDisplayName={resolvedSenderDisplayName} /> ); } @@ -315,7 +322,7 @@ export function MessageBubble({ onAvatarClick(message.senderId, ev) } : {})}