feat(desktop): apply friend nicknames in header / bubble / mentions / call tile
This commit is contained in:
@@ -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,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) }
|
||||||
: {})}
|
: {})}
|
||||||
|
|||||||
Reference in New Issue
Block a user