feat: 增加禁言功能

pull/49/head
moonrailgun 3 years ago
parent 5dfc9785f5
commit 43c5c04056

@ -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<number>();
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;
}

@ -0,0 +1,17 @@
import { useLayoutEffect, useState } from 'react';
import { shallowEqual } from 'react-redux';
/**
* , ()
*/
export function useShallowObject<T>(object: T): T {
const [state, setState] = useState<T>(object);
useLayoutEffect(() => {
if (!shallowEqual(state, object)) {
setState(object);
}
}, [object]);
return state;
}

@ -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",

@ -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": "退出登录",

@ -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,

@ -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,
});
}

@ -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;
}

@ -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();
}

@ -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<ChatInputMentionsContextProps | null>(null);
@ -31,7 +34,7 @@ ChatInputMentionsContext.displayName = 'ChatInputMentionsContext';
export const ChatInputMentionsContextProvider: React.FC<ChatInputMentionsContextProps> =
React.memo((props) => {
return (
<ChatInputMentionsContext.Provider value={{ users: props.users }}>
<ChatInputMentionsContext.Provider value={useShallowObject({ ...props })}>
{props.children}
</ChatInputMentionsContext.Provider>
);
@ -44,5 +47,7 @@ export function useChatInputMentionsContext(): ChatInputMentionsContextProps {
return {
users: context?.users ?? [],
placeholder: context?.placeholder,
disabled: context?.disabled,
};
}

@ -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<ChatInputBoxInputProps> = React.memo(
(props) => {
const allMentions = useChatInputMentionsContext();
const { users, placeholder, disabled } = useChatInputMentionsContext();
return (
<MentionsInput
inputRef={props.inputRef}
className="chatbox-mention-input"
placeholder={t('输入一些什么')}
placeholder={placeholder ?? t('输入一些什么')}
disabled={disabled}
singleLine={true}
maxLength={1000}
value={props.value}
@ -43,7 +43,7 @@ export const ChatInputBoxInput: React.FC<ChatInputBoxInputProps> = React.memo(
>
<Mention
trigger="@"
data={allMentions.users}
data={users}
displayTransform={(id, display) => `@${display}`}
appendSpaceOnAdd={true}
renderSuggestion={(suggestion) => (

@ -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<OpenReconfirmModalProps, 'onConfirm'>
props?: Omit<OpenReconfirmModalProps, 'onConfirm' | 'onCancel'>
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
openReconfirmModal({

@ -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<MembersPanelProps> = 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<MembersPanelProps> = React.memo((props) => {
};
}, [userInfoList, membersOnlineStatus]);
const { handleMuteMember, handleUnmuteMember } = useMemberMuteAction(
groupId,
userInfoList
);
if (userInfoList.length === 0) {
return <Skeleton />;
}
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<MembersPanelProps> = React.memo((props) => {
trigger={['contextMenu']}
overlay={
<Menu>
{muteUntil ? (
<Menu.Item>{t('解除禁言')}</Menu.Item>
{hasMute ? (
<Menu.Item onClick={() => handleUnmuteMember(member._id)}>
{t('解除禁言')}
</Menu.Item>
) : (
<Menu.SubMenu title={t('禁言')}>
<Menu.Item>{t('1分钟')}</Menu.Item>
<Menu.Item>{t('5分钟')}</Menu.Item>
<Menu.Item>{t('10分钟')}</Menu.Item>
<Menu.Item>{t('30分钟')}</Menu.Item>
<Menu.Item>{t('1天')}</Menu.Item>
<Menu.Item>{t('7天')}</Menu.Item>
<Menu.Item>{t('30天')}</Menu.Item>
<Menu.Item
onClick={() => handleMuteMember(member._id, 1 * 60 * 1000)}
>
{t('1分钟')}
</Menu.Item>
<Menu.Item
onClick={() => handleMuteMember(member._id, 5 * 60 * 1000)}
>
{t('5分钟')}
</Menu.Item>
<Menu.Item
onClick={() => handleMuteMember(member._id, 10 * 60 * 1000)}
>
{t('10分钟')}
</Menu.Item>
<Menu.Item
onClick={() => handleMuteMember(member._id, 30 * 60 * 1000)}
>
{t('30分钟')}
</Menu.Item>
<Menu.Item
onClick={() =>
handleMuteMember(member._id, 1 * 24 * 60 * 60 * 1000)
}
>
{t('1天')}
</Menu.Item>
<Menu.Item
onClick={() =>
handleMuteMember(member._id, 7 * 24 * 60 * 60 * 1000)
}
>
{t('7天')}
</Menu.Item>
<Menu.Item
onClick={() =>
handleMuteMember(member._id, 30 * 24 * 60 * 60 * 1000)
}
>
{t('30天')}
</Menu.Item>
</Menu.SubMenu>
)}
</Menu>

@ -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<TextPanelProps> = 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<string | undefined>(
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<TextPanelProps> = React.memo(
id: m._id,
display: m.nickname,
}))}
disabled={Boolean(muteUntil)}
placeholder={placeholder}
>
<ChatBox converseId={panelId} isGroup={true} groupId={groupId} />
</ChatInputMentionsContextProvider>

Loading…
Cancel
Save