fix(web): sync group member nicknames

This commit is contained in:
richjohnrambo
2026-10-17 10:12:20 +08:00
parent 8bdb8ed5a5
commit dd29eaca42
11 changed files with 60 additions and 21 deletions
+6 -1
View File
@@ -48,10 +48,15 @@ export function AppShell() {
// and NewFriendsView's list (['friend-requests','all']) both refetch on invalidation. Listening
// here rather than in those views keeps the badge live no matter which page the user is on.
useEffect(() => {
return subscribeTopicInfo((_topicName, info) => {
return subscribeTopicInfo((topicName, info) => {
if (info?.event?.startsWith('friend.request.')) {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
}
if (info?.event === 'group.member.profile.updated' && topicName.startsWith('grp')) {
queryClient.invalidateQueries({ queryKey: ['group-members', topicName] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicName] });
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicName] });
}
});
}, [queryClient]);
// "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
+8 -6
View File
@@ -24,6 +24,7 @@ import {
} from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { requestConfirm } from '@/stores/confirmDialog';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
@@ -288,9 +289,9 @@ function MemberPickerList({
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:opacity-50"
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
{avatarLetter(groupMemberDisplayName(m))}
</span>
<span className="truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<span className="truncate text-sm text-text-body">{groupMemberDisplayName(m)}</span>
</button>
))}
</>
@@ -370,9 +371,9 @@ function AdminsSubView({
{admins.map((a) => (
<div key={a.user_id} className="flex items-center gap-3 px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(a.user_id)}`}>
{avatarLetter(a.display_name)}
{avatarLetter(groupMemberDisplayName(a))}
</span>
<span className="flex-1 truncate text-sm text-text-body">{a.display_name || a.user_id}</span>
<span className="flex-1 truncate text-sm text-text-body">{groupMemberDisplayName(a)}</span>
<button
type="button"
onClick={() => removeMutation.mutate(a.user_id)}
@@ -445,7 +446,8 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
) : (
<>
{muted.map((m) => {
const name = memberByUserId.get(m.user_id)?.display_name || m.user_id;
const member = memberByUserId.get(m.user_id);
const name = member ? groupMemberDisplayName(member) : m.user_id;
return (
<div key={m.user_id} className="flex items-center gap-3 px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
@@ -500,7 +502,7 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }}
onPick={async (m) => {
if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return;
if (!(await requestConfirm({ message: `确定将群主转让给 ${groupMemberDisplayName(m)} 吗?此操作不可撤销。`, danger: true }))) return;
const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
if (!actionToken) return;
transferMutation.mutate({ userId: m.user_id, actionToken });
@@ -31,6 +31,7 @@ import {
import type { MediaType } from '@/lib/api/messages';
import { uploadFile } from '@/lib/api/upload';
import { MAX_TITLE_LENGTH } from '@/lib/config';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { useTinode } from '@/lib/tinode/hooks';
import { forgetTopic } from '@/lib/tinode/topics';
import { requestConfirm } from '@/stores/confirmDialog';
@@ -119,8 +120,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
// GroupAdminModal's join-request badge, so this is a cache hit more often than not).
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
const membersQuery = useQuery({ queryKey: ['group-members-preview', topicId], queryFn: () => getGroupMembers(topicId, {}) });
// `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
// only this per-user profile endpoint does, so the nickname row needs its own fetch. Uses the
// The nickname row needs the per-user profile endpoint. Uses the
// dedicated `members/me/profile` route, not getGroupMemberProfile(topic, myUserId) -- see that
// function's own warning for why the generic by-id path 400s on self-lookups.
const myProfileQuery = useQuery({
@@ -168,6 +168,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
mutationFn: (nickname: string) => updateMyGroupNickname(topicId, nickname, myProfileQuery.data?.version ?? 1),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicId, myUserId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
setNicknameEditing(false);
},
onError: (err) => showError(err, '昵称保存失败'),
@@ -430,12 +432,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
desktop's 40px (unconditional here before this fix). */}
<Avatar
id={m.user_id}
name={m.display_name}
name={groupMemberDisplayName(m)}
avatarFileId={m.avatar_file_id}
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
/>
<RoleCorner role={m.role} />
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
<span className="w-full truncate text-center text-[11px] text-text-secondary">{groupMemberDisplayName(m)}</span>
</button>
))}
{canInvite ? (
@@ -9,6 +9,7 @@ import { getFriends } from '@/lib/api/friends';
import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { requestConfirm } from '@/stores/confirmDialog';
import { openUserCard } from '@/stores/userCard';
@@ -81,7 +82,7 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const members = membersQuery.data || [];
const filtered = members.filter((m) => !query.trim() || (m.display_name || m.user_id).toLowerCase().includes(query.trim().toLowerCase()));
const filtered = members.filter((m) => !query.trim() || groupMemberDisplayName(m).toLowerCase().includes(query.trim().toLowerCase()));
const friendsQuery = useQuery({
queryKey: ['friends'],
@@ -198,9 +199,9 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
{avatarLetter(groupMemberDisplayName(m))}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{groupMemberDisplayName(m)}</span>
</button>
<RoleTag role={m.role} />
{canRemoveMember(myUserId, myRole, m) ? (
@@ -208,7 +209,7 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia
type="button"
disabled={removingIds.has(m.user_id)}
onClick={async () => {
const label = m.display_name || m.user_id;
const label = groupMemberDisplayName(m);
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
setRemovingIds((prev) => new Set(prev).add(m.user_id));
removeMutation.mutate(m.user_id, {
@@ -28,6 +28,7 @@ import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { isTopicGone } from '@/lib/tinode/errors';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
@@ -602,7 +603,8 @@ export function ConversationView() {
const quotedSenderLabel = (fromUserId: string | undefined) => {
if (myUserId && fromUserId === myUserId) return '我';
if (isGroup && fromUserId) {
return groupSenderLabel(memberByUserId.get(fromUserId)?.display_name, staleSenderById.get(fromUserId)?.display_name);
const member = memberByUserId.get(fromUserId);
return groupSenderLabel(member && groupMemberDisplayName(member), staleSenderById.get(fromUserId)?.display_name);
}
return peerName;
};
@@ -1075,7 +1077,7 @@ export function ConversationView() {
mine={mine}
isGroup={isGroup}
peerName={peerName}
senderName={sender?.display_name || staleSender?.display_name}
senderName={sender ? groupMemberDisplayName(sender) : staleSender?.display_name}
senderRole={sender?.role}
avatarFileId={rowAvatarFileId}
selectMode={selectMode}
@@ -11,6 +11,7 @@ import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
@@ -199,7 +200,10 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGr
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我';
if (isGroup) return groupSenderLabel(membersQuery.data?.find((m) => m.user_id === full)?.display_name);
if (isGroup) {
const member = membersQuery.data?.find((m) => m.user_id === full);
return groupSenderLabel(member && groupMemberDisplayName(member));
}
return peerName;
};
+1 -1
View File
@@ -58,7 +58,7 @@ export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
// who have no idea what the sender's personal remark for this person means. `nickname`
// is that member's own real public name, unaffected by anyone's remark, so it stays
// correct for whoever else ends up reading the mention.
const label = m.nickname || m.display_name || m.user_id;
const label = m.group_nickname || m.nickname || m.display_name || m.user_id;
return (
<button
key={m.user_id}
+2 -1
View File
@@ -159,6 +159,8 @@ export interface GroupMember {
* OTHER member then saw with no idea who it meant. Use `nickname` (below) instead for that case.
*/
display_name?: string;
/** Name chosen by this member for this group. */
group_nickname?: string;
/** The member's own real public profile nickname -- same backend response, but from
* copyUserNickname(), which reads straight off their public profile and is NEVER shadowed by the
* viewer's own remark the way `display_name` above is. This is what stays correct for everyone
@@ -176,7 +178,6 @@ export interface GroupMember {
acs?: { want?: string; given?: string; mode?: string };
}
/** `GET .../members` never returns `group_nickname` on any item. */
export function getGroupMembers(
topic: string,
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
@@ -0,0 +1,9 @@
type GroupMemberName = {
group_nickname?: string;
display_name?: string;
user_id: string;
};
export function groupMemberDisplayName(member: GroupMemberName): string {
return member.group_nickname?.trim() || member.display_name?.trim() || member.user_id;
}
+1 -1
View File
@@ -185,7 +185,7 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
}
export interface RawTopicInfo {
what: 'read' | 'recv' | 'kp' | 'call' | 'im';
what: 'read' | 'recv' | 'kp' | 'call' | 'im' | 'group';
seq?: number;
from?: string;
event?: string;
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { groupMemberDisplayName } from '../src/lib/messages/group-member-name.ts';
test('group nickname wins over a viewer remark or public nickname', () => {
assert.equal(groupMemberDisplayName({ user_id: 'usrA', group_nickname: '群里小王', display_name: '好友备注' }), '群里小王');
});
test('empty group nickname falls back to the usual member name', () => {
assert.equal(groupMemberDisplayName({ user_id: 'usrA', group_nickname: ' ', display_name: '公开昵称' }), '公开昵称');
assert.equal(groupMemberDisplayName({ user_id: 'usrA' }), 'usrA');
});