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 { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/app/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||||
|
import { privateChatOption, privateChatOptions } from '@/features/groups/group-settings';
|
||||||
import {
|
import {
|
||||||
addGroupAdmins,
|
addGroupAdmins,
|
||||||
getAllGroupMembers,
|
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">
|
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||||
<span className="text-text-body">成员私聊权限</span>
|
<span className="text-text-body">成员私聊权限</span>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={govQuery.data?.member_private_chat ? 'all' : 'friends'}
|
value={privateChatOption(!!govQuery.data?.member_private_chat)}
|
||||||
options={[
|
options={privateChatOptions}
|
||||||
{ value: 'all', label: '允许' },
|
|
||||||
{ value: 'friends', label: '仅好友' },
|
|
||||||
]}
|
|
||||||
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
||||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
|
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 { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||||
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
||||||
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
||||||
|
import { canEditGroupAvatar, groupAvatarError } from '@/features/groups/group-settings';
|
||||||
import {
|
import {
|
||||||
canRemoveMember,
|
canRemoveMember,
|
||||||
disbandGroup,
|
disbandGroup,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
type GroupSettings,
|
type GroupSettings,
|
||||||
} from '@/lib/api/groups';
|
} from '@/lib/api/groups';
|
||||||
import type { MediaType } from '@/lib/api/messages';
|
import type { MediaType } from '@/lib/api/messages';
|
||||||
|
import { uploadFile } from '@/lib/api/upload';
|
||||||
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||||
import { useTinode } from '@/lib/tinode/hooks';
|
import { useTinode } from '@/lib/tinode/hooks';
|
||||||
import { forgetTopic } from '@/lib/tinode/topics';
|
import { forgetTopic } from '@/lib/tinode/topics';
|
||||||
@@ -129,6 +131,10 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
const myRole = settingsQuery.data?.my_role;
|
const myRole = settingsQuery.data?.my_role;
|
||||||
const isOwner = myRole === 'owner';
|
const isOwner = myRole === 'owner';
|
||||||
const isAdmin = myRole === 'admin' || isOwner;
|
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
|
// GroupAdminModal's "邀请入群权限" governance toggle (所有成员/仅管理员) was added without ever
|
||||||
// being read anywhere -- every real gate on the invite affordance stayed hardcoded to
|
// 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
|
// 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, '群名保存失败'),
|
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
|
// 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
|
// 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
|
// 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="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 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="flex items-center gap-3">
|
||||||
<Avatar
|
<div className="relative flex-none">
|
||||||
id={topicId}
|
<Avatar
|
||||||
name={groupQuery.data?.name || peerName}
|
id={topicId}
|
||||||
avatarFileId={groupQuery.data?.avatar_file_id}
|
name={groupQuery.data?.name || peerName}
|
||||||
className="h-13 w-13 flex-none rounded-xl text-xl"
|
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">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{nameEditing ? (
|
{nameEditing ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -317,13 +355,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={nameDraft}
|
value={nameDraft}
|
||||||
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
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"
|
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
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"
|
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