feat(web): support group avatar updates
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
<div className="relative flex-none">
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
保存
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user