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) }
: {})}