fix(web): sync group member nicknames
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user