feat(web): support group avatar updates

This commit is contained in:
Your Name
2026-09-30 15:28:50 +08:00
parent cdd46c0b62
commit bd61be8a86
4 changed files with 94 additions and 13 deletions
+3 -5
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { privateChatOption, privateChatOptions } from '@/features/groups/group-settings';
import {
addGroupAdmins,
getAllGroupMembers,
@@ -184,11 +185,8 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">成员私聊权限</span>
<SegmentedControl
value={govQuery.data?.member_private_chat ? 'all' : 'friends'}
options={[
{ value: 'all', label: '允许' },
{ value: 'friends', label: '仅好友' },
]}
value={privateChatOption(!!govQuery.data?.member_private_chat)}
options={privateChatOptions}
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
/>
+46 -8
View File
@@ -11,6 +11,7 @@ import { GroupAdminModal } from '@/features/groups/GroupAdminModal';
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
import { canEditGroupAvatar, groupAvatarError } from '@/features/groups/group-settings';
import {
canRemoveMember,
disbandGroup,
@@ -28,6 +29,7 @@ import {
type GroupSettings,
} from '@/lib/api/groups';
import type { MediaType } from '@/lib/api/messages';
import { uploadFile } from '@/lib/api/upload';
import { MAX_TITLE_LENGTH } from '@/lib/config';
import { useTinode } from '@/lib/tinode/hooks';
import { forgetTopic } from '@/lib/tinode/topics';
@@ -129,6 +131,10 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const myRole = settingsQuery.data?.my_role;
const isOwner = myRole === 'owner';
const isAdmin = myRole === 'admin' || isOwner;
const canEditAvatar = canEditGroupAvatar(
settingsQuery.data?.permissions?.can_edit_profile ?? groupQuery.data?.permissions?.can_edit_profile,
myRole,
);
// GroupAdminModal's "邀请入群权限" governance toggle (所有成员/仅管理员) was added without ever
// being read anywhere -- every real gate on the invite affordance stayed hardcoded to
// owner/admin-only, so flipping that toggle to "所有成员" had zero effect on what a plain member
@@ -186,6 +192,30 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
onError: (err) => showError(err, '群名保存失败'),
});
const avatarMutation = useMutation({
mutationFn: async ({ file, topic, version }: { file: File; topic: string; version: number }) => {
const uploaded = await uploadFile(file, undefined, 'group_avatar');
return { ...(await updateGroup(topic, { avatar_file_id: uploaded.file_id, version })), avatar_file_id: uploaded.file_id };
},
onSuccess: (data, { topic }) => {
queryClient.setQueryData(['group', topic], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['group', topic] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topic] });
queryClient.invalidateQueries({ queryKey: ['groups', 'all'] });
},
onError: (err) => showError(err, '群头像修改失败'),
});
const avatarUploading = avatarMutation.isPending && avatarMutation.variables?.topic === topicId;
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const error = groupAvatarError(file);
if (error) setErrorToast(error);
else if (groupQuery.data) avatarMutation.mutate({ file, topic: topicId, version: groupQuery.data.version });
};
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
@@ -304,12 +334,20 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<div className="bg-white md:contents">
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
<div className="flex items-center gap-3">
<Avatar
id={topicId}
name={groupQuery.data?.name || peerName}
avatarFileId={groupQuery.data?.avatar_file_id}
className="h-13 w-13 flex-none rounded-xl text-xl"
/>
<div className="relative flex-none">
<Avatar
id={topicId}
name={groupQuery.data?.name || peerName}
avatarFileId={groupQuery.data?.avatar_file_id}
className="h-13 w-13 rounded-xl text-xl"
/>
{canEditAvatar && groupQuery.data ? (
<label className="absolute -bottom-1 -right-1 grid h-6 w-6 cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm" aria-label="修改群头像">
{avatarUploading ? <span className="text-[9px]">上传中</span> : <Icon name="photo_camera" className="text-[14px]" />}
<input type="file" accept="image/*" onChange={handlePickAvatar} disabled={avatarUploading || nameMutation.isPending} className="hidden" />
</label>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col">
{nameEditing ? (
<div className="flex items-center gap-2">
@@ -317,13 +355,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && !avatarUploading && nameMutation.mutate(nameDraft.trim())}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button
type="button"
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
disabled={!nameDraft.trim() || nameMutation.isPending}
disabled={!nameDraft.trim() || nameMutation.isPending || avatarUploading}
className="flex-none text-sm text-accent disabled:opacity-40"
>
保存
+17
View File
@@ -0,0 +1,17 @@
export const privateChatOptions: { value: 'all' | 'friends'; label: string }[] = [
{ value: 'all', label: '全员' },
{ value: 'friends', label: '好友添加' },
];
export function privateChatOption(enabled: boolean): 'all' | 'friends' {
return enabled ? 'all' : 'friends';
}
export function canEditGroupAvatar(permission: boolean | undefined, role: 'owner' | 'admin' | 'member' | undefined): boolean {
return permission ?? (role === 'owner' || role === 'admin');
}
export function groupAvatarError(file: Pick<File, 'type' | 'size'>): string | null {
if (!file.type.startsWith('image/')) return '请选择图片文件';
return file.size > 5 * 1024 * 1024 ? '图片大小不能超过 5MB' : null;
}
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canEditGroupAvatar, groupAvatarError, privateChatOption, privateChatOptions } from '../src/features/groups/group-settings.ts';
test('member private chat labels keep the existing boolean mapping', () => {
assert.deepEqual(privateChatOptions, [
{ value: 'all', label: '全员' },
{ value: 'friends', label: '好友添加' },
]);
assert.equal(privateChatOption(true), 'all');
assert.equal(privateChatOption(false), 'friends');
});
test('server profile permission overrides role, with role fallback only when absent', () => {
assert.equal(canEditGroupAvatar(true, 'member'), true);
assert.equal(canEditGroupAvatar(false, 'owner'), false);
assert.equal(canEditGroupAvatar(false, 'admin'), false);
assert.equal(canEditGroupAvatar(undefined, 'owner'), true);
assert.equal(canEditGroupAvatar(undefined, 'admin'), true);
assert.equal(canEditGroupAvatar(undefined, 'member'), false);
});
test('group avatar accepts images up to 5MB and rejects other files', () => {
assert.equal(groupAvatarError({ type: 'image/png', size: 5 * 1024 * 1024 }), null);
assert.equal(groupAvatarError({ type: 'text/plain', size: 10 }), '请选择图片文件');
assert.equal(groupAvatarError({ type: 'image/jpeg', size: 5 * 1024 * 1024 + 1 }), '图片大小不能超过 5MB');
});