From 43c5c04056395cd9682d94789bcfed61df4d8c3e Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sat, 23 Jul 2022 18:01:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=A6=81=E8=A8=80?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/hooks/useInterval.ts | 41 ++++++ shared/hooks/useShallowObject.ts | 17 +++ shared/i18n/langs/en-US/translation.json | 14 ++ shared/i18n/langs/zh-CN/translation.json | 14 ++ shared/index.tsx | 4 + shared/model/group.ts | 18 +++ shared/redux/hooks/useGroupMemberMute.ts | 24 ++++ shared/utils/date-helper.ts | 9 ++ .../ChatBox/ChatInputBox/context.tsx | 7 +- .../components/ChatBox/ChatInputBox/input.tsx | 8 +- web/src/components/Modal.tsx | 8 +- .../components/Panel/group/MembersPanel.tsx | 132 +++++++++++++++--- web/src/components/Panel/group/TextPanel.tsx | 37 ++++- 13 files changed, 303 insertions(+), 30 deletions(-) create mode 100644 shared/hooks/useInterval.ts create mode 100644 shared/hooks/useShallowObject.ts create mode 100644 shared/redux/hooks/useGroupMemberMute.ts diff --git a/shared/hooks/useInterval.ts b/shared/hooks/useInterval.ts new file mode 100644 index 00000000..53740165 --- /dev/null +++ b/shared/hooks/useInterval.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useUpdateRef } from './useUpdateRef'; + +export function useInterval( + fn: () => void, + delay: number | undefined, + options?: { + immediate?: boolean; + } +) { + const immediate = options?.immediate; + + const fnRef = useUpdateRef(fn); + const timerRef = useRef(); + + useEffect(() => { + if (typeof delay !== 'number' || delay < 0) { + return; + } + + if (immediate) { + fnRef.current(); + } + timerRef.current = window.setInterval(() => { + fnRef.current(); + }, delay); + return () => { + if (timerRef.current) { + window.clearInterval(timerRef.current); + } + }; + }, [delay]); + + const clear = useCallback(() => { + if (timerRef.current) { + window.clearInterval(timerRef.current); + } + }, []); + + return clear; +} diff --git a/shared/hooks/useShallowObject.ts b/shared/hooks/useShallowObject.ts new file mode 100644 index 00000000..c5c47a64 --- /dev/null +++ b/shared/hooks/useShallowObject.ts @@ -0,0 +1,17 @@ +import { useLayoutEffect, useState } from 'react'; +import { shallowEqual } from 'react-redux'; + +/** + * 对输入对象增加一层浅比较, 如果对象浅比较结果一致则返回原对象(防止更新) + */ +export function useShallowObject(object: T): T { + const [state, setState] = useState(object); + + useLayoutEffect(() => { + if (!shallowEqual(state, object)) { + setState(object); + } + }, [object]); + + return state; +} diff --git a/shared/i18n/langs/en-US/translation.json b/shared/i18n/langs/en-US/translation.json index a2d8964b..c66e3478 100644 --- a/shared/i18n/langs/en-US/translation.json +++ b/shared/i18n/langs/en-US/translation.json @@ -6,6 +6,7 @@ "k1252f904": "Gateway", "k131598d0": "A new version is detected, whether to refresh immediately to upgrade to the latest content", "k13ae6a93": "Copy", + "k13bea6d2": "User not found", "k1596c75c": "Only allow specified users to speak", "k162e37f1": "Plugin is successfully uninstalled, and it needs to be restarted to take effect", "k1704ea49": "Install", @@ -15,8 +16,10 @@ "k1885734a": "Effective after refreshing the page", "k18c716ce": "Password cannot be less than 6 digits", "k19885be1": "Panel name is too long", + "k19a1647f": "1 minute", "k1a377364": "Message List Virtualization", "k1a78e6f0": "Expiration", + "k1ac0bd00": "30 days", "k1b38bb5c": "Register Now", "k1bc58056": "Private Message Service", "k1bd56481": "Close independent window", @@ -47,6 +50,7 @@ "k35f486ba": "Nickname", "k35f990b0": "View Detail", "k3662c0d4": "Please select a panel", + "k378f66fc": "Unmute", "k393892b6": "Upload original image", "k3ac17670": "An exception occurred, store create failed", "k3b4b656d": "About", @@ -55,6 +59,7 @@ "k3c502edb": "E-mail can not be empty", "k3c7c48f8": "Invite not found", "k3c8fd4c2": "Nickname", + "k3c960528": "Mute", "k3e0c272": "Service Url", "k3e514bd0": "Panel name cannot be empty", "k3e7f3579": "Cannot send empty message", @@ -71,6 +76,7 @@ "k48a38bc1": "Group not found", "k49721de0": "Reset Password", "k4a573576": "Unable to modify the display name of the Everyone permission group", + "k4cda3b42": "30 minutes", "k4d32a754": "Group Name", "k4f672109": "Failed to load global configuration", "k4f69cbc9": "Forget Password", @@ -123,8 +129,10 @@ "k7a2ccf9b": "Search Friends", "k7a89720": "Open in new window", "k7c232f9e": "Panel", + "k7daa1233": "Are you sure you want to mute {{name}}", "k7daefc98": "Invite you to join the group", "k7ec9199a": "Friend request waiting for process", + "k7f0c746d": "Operation successful", "k7fc7e508": "Dark Mode", "k814bdc7a": "Is not a valid JSON string", "k81662255": "Create invitation code", @@ -132,6 +140,7 @@ "k8266bcf2": "New password", "k83ede286": "Ack Service", "k8582af3f": "Refuse", + "k86f06fee": "7 days", "k87a609ad": "Please do not install plugins from unknown sources, it may steal your personal information in Tailchat", "k89df1d1e": "The network is abnormal", "k8abdba5c": "Has been sent", @@ -159,9 +168,11 @@ "ka2c48894": "Customize your group", "ka2ed2b61": "Are you sure you want to do this?", "ka30dd0bc": "This action cannot be undo", + "ka39b3032": "1 day", "ka49ec177": "Input somthing", "ka4cebef8": "New Role", "ka50c7408": "Please enter the JSON information manually, if you are not sure what you are doing, please do not use this function", + "ka5616453": "Mute {{length}}, expected until {{until}}", "ka5d64ee9": "Choose the following template and start creating your own group!", "ka62886b9": "Load File Failed", "ka697d1d8": "Password modify complete", @@ -238,6 +249,7 @@ "ke0161a83": "Reset to default address", "ke17b2c87": "Do not upload pictures that violate local laws and regulations", "ke187440d": "Panel type cannot be empty", + "ke59ffe49": "Muted, there are {{remain}} left", "kea977d95": "The following users are offline", "kec46a57f": "Add members", "kecb51e2c": "Old password", @@ -245,11 +257,13 @@ "ked2baf28": "Loading...", "ked5385d5": "Create Panel", "keda14478": "You are the group manager, leaving the group will cause the group to be dissolved", + "kedee406c": "5 minutes", "kee9108f1": "Modify success", "kef25594f": "Nickname#0000", "kef3676e1": "The invitation code has expired", "kefaf9956": "Created", "kefc07278": "Back to login", + "keff3c3f": "10 minutes", "kf02c6db": "Friend List", "kf0d97e0b": "Friend deleted successfully", "kf15499b4": "Logout", diff --git a/shared/i18n/langs/zh-CN/translation.json b/shared/i18n/langs/zh-CN/translation.json index 663feb81..1660a6b8 100644 --- a/shared/i18n/langs/zh-CN/translation.json +++ b/shared/i18n/langs/zh-CN/translation.json @@ -6,6 +6,7 @@ "k1252f904": "服务网关", "k131598d0": "检测到有新版本, 是否立即刷新以升级到最新内容", "k13ae6a93": "复制", + "k13bea6d2": "没有找到用户", "k1596c75c": "仅允许指定用户发言", "k162e37f1": "插件卸载成功, 需要重启后生效", "k1704ea49": "安装", @@ -15,8 +16,10 @@ "k1885734a": "刷新页面后生效", "k18c716ce": "密码不能低于6位", "k19885be1": "面板名过长", + "k19a1647f": "1分钟", "k1a377364": "聊天列表虚拟化", "k1a78e6f0": "过期时间", + "k1ac0bd00": "30天", "k1b38bb5c": "立即注册", "k1bc58056": "私信服务", "k1bd56481": "关闭独立窗口", @@ -47,6 +50,7 @@ "k35f486ba": "用户昵称", "k35f990b0": "查看详情", "k3662c0d4": "请选择面板", + "k378f66fc": "解除禁言", "k393892b6": "上传原图", "k3ac17670": "出现异常, Store 创建失败", "k3b4b656d": "关于", @@ -55,6 +59,7 @@ "k3c502edb": "邮箱不能为空", "k3c7c48f8": "找不到邀请信息", "k3c8fd4c2": "昵称", + "k3c960528": "禁言", "k3e0c272": "服务端地址", "k3e514bd0": "面板名不能为空", "k3e7f3579": "无法发送空消息", @@ -71,6 +76,7 @@ "k48a38bc1": "群组不存在", "k49721de0": "重设密码", "k4a573576": "无法修改所有人权限组的显示名称", + "k4cda3b42": "30分钟", "k4d32a754": "群组名称", "k4f672109": "全局配置加载失败", "k4f69cbc9": "忘记密码", @@ -123,8 +129,10 @@ "k7a2ccf9b": "搜索好友", "k7a89720": "在新窗口打开", "k7c232f9e": "面板", + "k7daa1233": "确定要禁言 {{name}} 么", "k7daefc98": "邀请您加入群组", "k7ec9199a": "等待对方处理的好友请求", + "k7f0c746d": "操作成功", "k7fc7e508": "暗黑模式", "k814bdc7a": "不是一个合法的JSON字符串", "k81662255": "创建邀请码", @@ -132,6 +140,7 @@ "k8266bcf2": "新密码", "k83ede286": "已读服务", "k8582af3f": "拒绝", + "k86f06fee": "7天", "k87a609ad": "请不要安装不明来源的插件,这可能会盗取你在 Tailchat 的个人信息", "k89df1d1e": "网络出现异常", "k8abdba5c": "已发送", @@ -159,9 +168,11 @@ "ka2c48894": "自定义你的群组", "ka2ed2b61": "确认要进行该操作么?", "ka30dd0bc": "该操作无法被撤回", + "ka39b3032": "1天", "ka49ec177": "输入一些什么", "ka4cebef8": "新身份组", "ka50c7408": "请手动输入JSON信息,如果你不明确你在做什么请不要使用该功能", + "ka5616453": "禁言 {{length}}, 预计到 {{until}} 为止", "ka5d64ee9": "选择以下模板, 开始创建属于自己的群组吧!", "ka62886b9": "文件读取失败", "ka697d1d8": "密码修改成功", @@ -238,6 +249,7 @@ "ke0161a83": "重置为默认地址", "ke17b2c87": "请勿上传违反当地法律法规的图片", "ke187440d": "面板类型不能为空", + "ke59ffe49": "禁言中, 还剩 {{remain}}", "kea977d95": "以下用户已离线", "kec46a57f": "添加成员", "kecb51e2c": "旧密码", @@ -245,11 +257,13 @@ "ked2baf28": "加载中...", "ked5385d5": "创建面板", "keda14478": "您是群组管理者,退出群组会导致解散群组", + "kedee406c": "5分钟", "kee9108f1": "修改成功", "kef25594f": "用户昵称#0000", "kef3676e1": "该邀请码已过期", "kefaf9956": "创建时间", "kefc07278": "返回登录", + "keff3c3f": "10分钟", "kf02c6db": "好友列表", "kf0d97e0b": "好友删除成功", "kf15499b4": "退出登录", diff --git a/shared/index.tsx b/shared/index.tsx index d4aee9a5..db133432 100644 --- a/shared/index.tsx +++ b/shared/index.tsx @@ -59,11 +59,13 @@ export { useAsyncFn } from './hooks/useAsyncFn'; export { useAsyncRefresh } from './hooks/useAsyncRefresh'; export { useAsyncRequest } from './hooks/useAsyncRequest'; export { useDebounce } from './hooks/useDebounce'; +export { useInterval } from './hooks/useInterval'; export { useMemoizedFn } from './hooks/useMemoizedFn'; export { useMountedState } from './hooks/useMountedState'; export { usePrevious } from './hooks/usePrevious'; export { useRafState } from './hooks/useRafState'; export { useSearch } from './hooks/useSearch'; +export { useShallowObject } from './hooks/useShallowObject'; export { useUpdateRef } from './hooks/useUpdateRef'; export { useWhyDidYouUpdate } from './hooks/useWhyDidYouUpdate'; @@ -179,6 +181,7 @@ export { useGroupUnread, useGroupTextPanelUnread, } from './redux/hooks/useGroup'; +export { useGroupMemberMute } from './redux/hooks/useGroupMemberMute'; export { useUserInfo, useUserId, @@ -206,6 +209,7 @@ export { formatFullTime, datetimeToNow, datetimeFromNow, + humanizeMsDuration, } from './utils/date-helper'; export { isBrowser, diff --git a/shared/model/group.ts b/shared/model/group.ts index 96af1e2a..dfd482bd 100644 --- a/shared/model/group.ts +++ b/shared/model/group.ts @@ -326,3 +326,21 @@ export async function updateGroupRolePermission( permissions, }); } + +/** + * 禁言群组成员 + * @param groupId 群组ID + * @param memberId 成员ID + * @param muteMs 禁言到xxx, 精确到毫秒 + */ +export async function muteGroupMember( + groupId: string, + memberId: string, + muteMs: number +) { + await request.post('/api/group/muteGroupMember', { + groupId, + memberId, + muteMs, + }); +} diff --git a/shared/redux/hooks/useGroupMemberMute.ts b/shared/redux/hooks/useGroupMemberMute.ts new file mode 100644 index 00000000..dba6a683 --- /dev/null +++ b/shared/redux/hooks/useGroupMemberMute.ts @@ -0,0 +1,24 @@ +import { useAppSelector } from './useAppSelector'; + +/** + * 获取用户禁言状态 + * @param groupId 群组ID + * @param userId 用户ID + * @returns 如果没有禁言状态或者有禁言但是已过期则返回false,否则返回禁言到的时间 + */ +export function useGroupMemberMute( + groupId: string, + userId: string +): string | false { + const muteUntil = useAppSelector( + (state) => + state.group.groups[groupId]?.members.find((m) => m.userId === userId) + ?.muteUntil + ); + + if (!muteUntil || new Date(muteUntil).valueOf() < new Date().valueOf()) { + return false; + } + + return muteUntil; +} diff --git a/shared/utils/date-helper.ts b/shared/utils/date-helper.ts index b4492fac..0bee8689 100644 --- a/shared/utils/date-helper.ts +++ b/shared/utils/date-helper.ts @@ -1,5 +1,6 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; // 导入插件 +import duration from 'dayjs/plugin/duration'; // 导入插件 import 'dayjs/locale/zh-cn'; // 导入本地化语言 import { onLanguageChange } from '../i18n'; @@ -8,6 +9,7 @@ import { onLanguageChange } from '../i18n'; */ dayjs.extend(relativeTime); +dayjs.extend(duration); dayjs.locale('zh-cn'); // 默认使用中文 onLanguageChange((lang) => { if (lang === 'en-US') { @@ -79,3 +81,10 @@ export function datetimeFromNow(input: dayjs.ConfigType): string { const date = dayjs(input); return date.fromNow(); } + +/** + * 将毫秒转换为易读的人类语言 + */ +export function humanizeMsDuration(ms: number): string { + return dayjs.duration(ms, 'ms').humanize(); +} diff --git a/web/src/components/ChatBox/ChatInputBox/context.tsx b/web/src/components/ChatBox/ChatInputBox/context.tsx index 9b0268fe..65f75de8 100644 --- a/web/src/components/ChatBox/ChatInputBox/context.tsx +++ b/web/src/components/ChatBox/ChatInputBox/context.tsx @@ -1,5 +1,6 @@ import React, { useContext } from 'react'; import type { SuggestionDataItem } from 'react-mentions'; +import { useShallowObject } from 'tailchat-shared'; /** * Input Actions @@ -23,6 +24,8 @@ export function useChatInputActionContext() { */ interface ChatInputMentionsContextProps { users: SuggestionDataItem[]; + placeholder?: string; + disabled?: boolean; } const ChatInputMentionsContext = React.createContext(null); @@ -31,7 +34,7 @@ ChatInputMentionsContext.displayName = 'ChatInputMentionsContext'; export const ChatInputMentionsContextProvider: React.FC = React.memo((props) => { return ( - + {props.children} ); @@ -44,5 +47,7 @@ export function useChatInputMentionsContext(): ChatInputMentionsContextProps { return { users: context?.users ?? [], + placeholder: context?.placeholder, + disabled: context?.disabled, }; } diff --git a/web/src/components/ChatBox/ChatInputBox/input.tsx b/web/src/components/ChatBox/ChatInputBox/input.tsx index e06c467f..e11b9b26 100644 --- a/web/src/components/ChatBox/ChatInputBox/input.tsx +++ b/web/src/components/ChatBox/ChatInputBox/input.tsx @@ -5,7 +5,6 @@ import React from 'react'; import { Mention, MentionsInput } from 'react-mentions'; import { t } from 'tailchat-shared'; import { useChatInputMentionsContext } from './context'; - import './input.less'; interface ChatInputBoxInputProps @@ -19,13 +18,14 @@ interface ChatInputBoxInputProps } export const ChatInputBoxInput: React.FC = React.memo( (props) => { - const allMentions = useChatInputMentionsContext(); + const { users, placeholder, disabled } = useChatInputMentionsContext(); return ( = React.memo( > `@${display}`} appendSpaceOnAdd={true} renderSuggestion={(suggestion) => ( diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index cf1b704d..3745ce58 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -197,7 +197,7 @@ export function openConfirmModal(props: OpenConfirmModalProps) { type OpenReconfirmModalProps = Pick< OpenConfirmModalProps, - 'onConfirm' | 'onCancel' + 'title' | 'content' | 'onConfirm' | 'onCancel' >; /** * 打开再次确认操作modal @@ -206,8 +206,8 @@ export function openReconfirmModal(props: OpenReconfirmModalProps) { openConfirmModal({ onConfirm: props.onConfirm, onCancel: props.onCancel, - title: t('确认要进行该操作么?'), - content: t('该操作无法被撤回'), + title: props.title ?? t('确认要进行该操作么?'), + content: props.content ?? t('该操作无法被撤回'), }); } /** @@ -218,7 +218,7 @@ export function openReconfirmModal(props: OpenReconfirmModalProps) { * } */ export function openReconfirmModalP( - props?: Omit + props?: Omit ): Promise { return new Promise((resolve) => { openReconfirmModal({ diff --git a/web/src/components/Panel/group/MembersPanel.tsx b/web/src/components/Panel/group/MembersPanel.tsx index 5f40f265..4aa5fb91 100644 --- a/web/src/components/Panel/group/MembersPanel.tsx +++ b/web/src/components/Panel/group/MembersPanel.tsx @@ -1,11 +1,16 @@ import { Icon } from '@/components/Icon'; +import { openReconfirmModalP } from '@/components/Modal'; import { GroupUserPopover } from '@/components/popover/GroupUserPopover'; import { UserListItem } from '@/components/UserListItem'; import { Divider, Dropdown, Input, Menu, Skeleton } from 'antd'; import React, { useMemo } from 'react'; import { + formatFullTime, GroupMember, + humanizeMsDuration, isDevelopment, + model, + showToasts, t, useAsyncRequest, useCachedOnlineStatus, @@ -20,30 +25,78 @@ interface MembersPanelProps { groupId: string; } -function getMembersMuteUntil( - members: GroupMember[], - userId: string -): string | undefined { +function getMembersHasMute(members: GroupMember[], userId: string): boolean { const member = members.find((m) => m.userId === userId); - if (!member) { - return undefined; + if (!member || !member.muteUntil) { + return false; } - return member.muteUntil; + const muteUntil = member.muteUntil; + + return new Date(muteUntil).valueOf() > new Date().valueOf(); +} + +/** + * 禁言相关 + */ +function useMemberMuteAction( + groupId: string, + userInfoList: model.user.UserBaseInfo[] +) { + /** + * 禁言 + */ + const [, handleMuteMember] = useAsyncRequest( + async (memberId: string, ms: number) => { + const memberInfo = userInfoList.find((m) => m._id === memberId); + + if (!memberInfo) { + throw new Error(t('没有找到用户')); + } + + if ( + await openReconfirmModalP({ + title: t('确定要禁言 {{name}} 么', { name: memberInfo.nickname }), + content: t('禁言 {{length}}, 预计到 {{until}} 为止', { + length: humanizeMsDuration(ms), + until: formatFullTime(new Date().valueOf() + ms), + }), + }) + ) { + await model.group.muteGroupMember(groupId, memberId, ms); + showToasts(t('操作成功'), 'success'); + } + }, + [groupId, userInfoList] + ); + + /** + * 解除禁言 + */ + const [, handleUnmuteMember] = useAsyncRequest( + async (memberId: string) => { + await model.group.muteGroupMember(groupId, memberId, -1); + showToasts(t('操作成功'), 'success'); + }, + [groupId] + ); + + return { handleMuteMember, handleUnmuteMember }; } /** * 用户面板 */ export const MembersPanel: React.FC = React.memo((props) => { - const groupInfo = useGroupInfo(props.groupId); + const groupId = props.groupId; + const groupInfo = useGroupInfo(groupId); const members = groupInfo?.members ?? []; const userInfoList = useUserInfoList(members.map((m) => m.userId)); const membersOnlineStatus = useCachedOnlineStatus( members.map((m) => m.userId) ); - const isGroupOwner = useIsGroupOwner(props.groupId); + const isGroupOwner = useIsGroupOwner(groupId); const { searchText, @@ -73,12 +126,17 @@ export const MembersPanel: React.FC = React.memo((props) => { }; }, [userInfoList, membersOnlineStatus]); + const { handleMuteMember, handleUnmuteMember } = useMemberMuteAction( + groupId, + userInfoList + ); + if (userInfoList.length === 0) { return ; } const renderUser = (member: UserBaseInfo) => { - const muteUntil = getMembersMuteUntil(members, member._id); + const hasMute = getMembersHasMute(members, member._id); if (isGroupOwner && isDevelopment) { return ( @@ -87,17 +145,53 @@ export const MembersPanel: React.FC = React.memo((props) => { trigger={['contextMenu']} overlay={ - {muteUntil ? ( - {t('解除禁言')} + {hasMute ? ( + handleUnmuteMember(member._id)}> + {t('解除禁言')} + ) : ( - {t('1分钟')} - {t('5分钟')} - {t('10分钟')} - {t('30分钟')} - {t('1天')} - {t('7天')} - {t('30天')} + handleMuteMember(member._id, 1 * 60 * 1000)} + > + {t('1分钟')} + + handleMuteMember(member._id, 5 * 60 * 1000)} + > + {t('5分钟')} + + handleMuteMember(member._id, 10 * 60 * 1000)} + > + {t('10分钟')} + + handleMuteMember(member._id, 30 * 60 * 1000)} + > + {t('30分钟')} + + + handleMuteMember(member._id, 1 * 24 * 60 * 60 * 1000) + } + > + {t('1天')} + + + handleMuteMember(member._id, 7 * 24 * 60 * 60 * 1000) + } + > + {t('7天')} + + + handleMuteMember(member._id, 30 * 24 * 60 * 60 * 1000) + } + > + {t('30天')} + )} diff --git a/web/src/components/Panel/group/TextPanel.tsx b/web/src/components/Panel/group/TextPanel.tsx index 02d7e04e..7967ebf7 100644 --- a/web/src/components/Panel/group/TextPanel.tsx +++ b/web/src/components/Panel/group/TextPanel.tsx @@ -1,7 +1,15 @@ import { ChatBox } from '@/components/ChatBox'; import { ChatInputMentionsContextProvider } from '@/components/ChatBox/ChatInputBox/context'; -import React from 'react'; -import { useGroupPanelInfo, useGroupMemberInfos } from 'tailchat-shared'; +import React, { useState } from 'react'; +import { + useGroupPanelInfo, + useGroupMemberInfos, + useGroupMemberMute, + useUserId, + t, + humanizeMsDuration, + useInterval, +} from 'tailchat-shared'; import { GroupPanelWrapper } from './Wrapper'; interface TextPanelProps { @@ -12,6 +20,29 @@ export const TextPanel: React.FC = React.memo( ({ groupId, panelId }) => { const groupMembers = useGroupMemberInfos(groupId); const panelInfo = useGroupPanelInfo(groupId, panelId); + const userId = useUserId(); + const muteUntil = useGroupMemberMute(groupId, userId ?? ''); + + const [placeholder, setPlaceholder] = useState( + undefined + ); + useInterval(() => { + if (muteUntil) { + setPlaceholder( + muteUntil + ? t('禁言中, 还剩 {{remain}}', { + remain: humanizeMsDuration( + new Date().valueOf() - new Date(muteUntil).valueOf() + ), + }) + : undefined + ); + } else { + setPlaceholder(undefined); + } + // 10s 检查一次,因为 humanizeMsDuration 不会精确到秒 + }, 10000); + if (panelInfo === undefined) { return null; } @@ -23,6 +54,8 @@ export const TextPanel: React.FC = React.memo( id: m._id, display: m.nickname, }))} + disabled={Boolean(muteUntil)} + placeholder={placeholder} >