diff --git a/package.json b/package.json index 0aaffcd6..bd654101 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.70", + "version": "0.9.71", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", diff --git a/public/locales/en/member.json b/public/locales/en/member.json index b2fb80f9..178671e4 100644 --- a/public/locales/en/member.json +++ b/public/locales/en/member.json @@ -30,6 +30,12 @@ "set_normal": "User", "search_not_found": "Not found, or this user is not allowed to be searched.", + "search_disabled": "User search has been disabled by the administrator.", + "add_to_contact": "Add to contact", + "add_friend_disabled": "Adding friends has been disabled by the administrator.", + "dm_disabled": "Direct messaging has been disabled by the administrator.", + "send_failed_blocked": "Send failed, blocked maybe", + "send_failed": "Send message failed", "search_by_id": "Search by ID", "search_by_id_ph": "Input user ID", "search_by_email": "Search by email", diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index 5f51f4e2..52c579c4 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -100,6 +100,18 @@ "enable": "Enable", "disable": "Disable" }, + "dm_enable": { + "title": "Direct Messaging", + "desc": "If disabled, members cannot send direct messages to each other." + }, + "add_friend_enable": { + "title": "Add Friend", + "desc": "If disabled, members cannot add other users as friends." + }, + "search_user_enable": { + "title": "Search Users", + "desc": "If disabled, members cannot search for other users." + }, "enable_msg_url_preview": { "title": "Enable URL Preview in Message", "desc": "If enabled, url in message will show preview content.", diff --git a/public/locales/zh/member.json b/public/locales/zh/member.json index 4bfea8a5..53c76d8a 100644 --- a/public/locales/zh/member.json +++ b/public/locales/zh/member.json @@ -29,6 +29,12 @@ "set_normal": "普通用户", "search_not_found": "用户不存在,或者不允许搜索", + "search_disabled": "管理员已关闭用户搜索功能", + "add_to_contact": "加为联系人", + "add_friend_disabled": "管理员已关闭加好友功能", + "dm_disabled": "管理员已关闭私聊功能", + "send_failed_blocked": "发送失败,可能被对方屏蔽", + "send_failed": "消息发送失败", "search_by_id": "用户ID", "search_by_id_ph": "请输入用户ID", "search_by_email": "用户邮箱", diff --git a/public/locales/zh/setting.json b/public/locales/zh/setting.json index 354125c1..6d3aa9b6 100644 --- a/public/locales/zh/setting.json +++ b/public/locales/zh/setting.json @@ -98,6 +98,18 @@ "enable": "开启", "disable": "关闭" }, + "dm_enable": { + "title": "私聊(直接消息)", + "desc": "关闭后,普通成员之间无法互发私信" + }, + "add_friend_enable": { + "title": "加好友", + "desc": "关闭后,普通成员无法将其他用户添加为好友" + }, + "search_user_enable": { + "title": "搜索用户", + "desc": "关闭后,普通成员无法搜索其他用户" + }, "enable_msg_url_preview": { "title": "开启消息网址预览", "desc": "如果开启,消息中的网址会显示预览内容", diff --git a/src/app/services/base.query.ts b/src/app/services/base.query.ts index d106e388..88cbc6e6 100644 --- a/src/app/services/base.query.ts +++ b/src/app/services/base.query.ts @@ -133,7 +133,7 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) = break; case 403: { - const whiteList403 = ["sendMsg", "login"]; + const whiteList403 = ["sendMsg", "login", "searchUser", "updateContactStatus"]; if (!whiteList403.includes(api.endpoint)) { toast.error("Request Not Allowed"); } diff --git a/src/app/services/handlers.ts b/src/app/services/handlers.ts index 5839979d..29fec478 100644 --- a/src/app/services/handlers.ts +++ b/src/app/services/handlers.ts @@ -1,6 +1,7 @@ import toast from "react-hot-toast"; import { batch } from "react-redux"; +import i18n from "@/i18n"; import { ContentTypes } from "../config"; import { addMessage, removeMessage } from "../slices/message"; import { addChannelMsg, removeChannelMsg } from "../slices/message.channel"; @@ -60,12 +61,18 @@ export const onMessageSendStarted = async ( }, 300); // dispatch(removePendingMessage({ id, mid:ts, type: from })); } catch (error) { - if (error?.error?.status == 403) { - // 403 means blocked - toast.error(`Send failed, blocked maybe`); - // dispatch(updateMessage({ mid: ts, failed: true })); + const httpStatus = error?.error?.originalStatus ?? error?.error?.status; + const errData: string = + typeof error?.error?.data === "string" ? error.error.data : ""; + if (httpStatus === 403) { + if (errData.includes("disabled by the administrator")) { + toast.error(i18n.t("dm_disabled", { ns: "member" })); + } else { + // 403 means blocked + toast.error(i18n.t("send_failed_blocked", { ns: "member", defaultValue: "Send failed, blocked maybe" })); + } } else { - toast.error(`Send Message Failed ${JSON.stringify(error)}`); + toast.error(i18n.t("send_failed", { ns: "member", defaultValue: "Send Message Failed" })); } dispatch(removeContextMessage({ id, mid: ts })); dispatch(removeMessage(ts)); diff --git a/src/app/slices/server.ts b/src/app/slices/server.ts index 8e035236..7eff833f 100644 --- a/src/app/slices/server.ts +++ b/src/app/slices/server.ts @@ -29,7 +29,10 @@ const initialState: StoredServer = { loginConfig: null, ext_setting: null, msg_smtp_notify_delay_seconds: 0, - msg_smtp_notify_enable: false + msg_smtp_notify_enable: false, + dm_enable: true, + add_friend_enable: true, + search_user_enable: true }; const serverSlice = createSlice({ @@ -57,6 +60,9 @@ const serverSlice = createSlice({ only_admin_can_create_group = false, loginConfig = state.loginConfig || null, ext_setting = null, + dm_enable = true, + add_friend_enable = true, + search_user_enable = true, ...rest } = action.payload || {}; return { @@ -73,6 +79,9 @@ const serverSlice = createSlice({ chat_layout_mode, loginConfig, ext_setting, + dm_enable, + add_friend_enable, + search_user_enable, ...rest }; }, diff --git a/src/components/AddEntriesMenu.tsx b/src/components/AddEntriesMenu.tsx index d75cd2ce..1d784b3b 100644 --- a/src/components/AddEntriesMenu.tsx +++ b/src/components/AddEntriesMenu.tsx @@ -24,6 +24,11 @@ export default function AddEntriesMenu() { (store) => store.server.only_admin_can_create_group, shallowEqual ); + const dmEnable = useAppSelector((store) => store.server.dm_enable ?? true, shallowEqual); + const searchUserEnable = useAppSelector( + (store) => store.server.search_user_enable ?? true, + shallowEqual + ); const [isPrivate, setIsPrivate] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false); @@ -67,6 +72,8 @@ export default function AddEntriesMenu() { const iconClass = "w-5 h-5 dark:fill-gray-300"; const canPrivateGroup = onlyAdminCreateGroup ? isAdmin : true; const showInvite = isAdmin || !onlyAdminCanInvite; + const showNewMsg = isAdmin || dmEnable; + const showSearchPeople = isAdmin || searchUserEnable; return ( <> {channelModalVisible && } {usersModalVisible && } diff --git a/src/components/SearchUser.tsx b/src/components/SearchUser.tsx index 61a2f0c4..8d539da7 100644 --- a/src/components/SearchUser.tsx +++ b/src/components/SearchUser.tsx @@ -1,8 +1,8 @@ -// import Tippy from "@tippyjs/react"; -import { ChangeEvent, FC, FormEvent, useRef, useState } from "react"; +import { ChangeEvent, FC, FormEvent, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import clsx from "clsx"; +import toast from "react-hot-toast"; import BASE_URL from "@/app/config"; import { useSearchUserMutation, useUpdateContactStatusMutation } from "@/app/services/user"; @@ -21,8 +21,14 @@ type Props = { type Type = "id" | "email" | "name"; const SearchUser: FC = ({ closeModal }) => { - const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation(); + const [updateContactStatus, { isLoading: adding, error: addContactError }] = + useUpdateContactStatusMutation(); const usersData = useAppSelector((store) => store.users.byId, shallowEqual); + const isAdmin = useAppSelector((store) => store.authData.user?.is_admin, shallowEqual); + const addFriendEnable = useAppSelector( + (store) => store.server.add_friend_enable ?? true, + shallowEqual + ); const { t } = useTranslation(); const navigateTo = useNavigate(); const inputRef = useRef(null); @@ -32,7 +38,32 @@ const SearchUser: FC = ({ closeModal }) => { email: "", name: "" }); - const [searchUser, { data, isSuccess, isLoading, reset }] = useSearchUserMutation(); + const [searchUser, { data, isSuccess, isLoading, isError, reset, error: searchError }] = + useSearchUserMutation(); + const isSearchDisabled = + !!searchError && + "data" in searchError && + typeof searchError.data === "string" && + (searchError.data as string).includes("disabled by the administrator") && + // text/plain 403 → status='PARSING_ERROR', originalStatus=403 + // json 403 → status=403 + (("originalStatus" in searchError && searchError.originalStatus === 403) || + ("status" in searchError && searchError.status === 403)); + useEffect(() => { + if (!isSearchDisabled) return; + toast.error(t("search_disabled", { ns: "member" })); + }, [isSearchDisabled]); + useEffect(() => { + if (!addContactError) return; + const err = addContactError as any; + const httpStatus: number = err?.originalStatus ?? err?.status; + const errData: string = typeof err?.data === "string" ? err.data : ""; + toast.error( + httpStatus === 403 && errData.includes("disabled by the administrator") + ? t("add_friend_disabled", { ns: "member" }) + : t("tip.update_failed", { ns: "common", defaultValue: "Operation failed" }) + ); + }, [addContactError]); const handleInput = (evt: ChangeEvent) => { const tmp = { [type]: evt.target.value @@ -69,7 +100,8 @@ const SearchUser: FC = ({ closeModal }) => { const handleChat = async (directChat: boolean) => { if (!data) return; if (!directChat) { - await updateContactStatus({ target_uid: data.uid, action: "add" }); + const result = await updateContactStatus({ target_uid: data.uid, action: "add" }); + if ("error" in result) return; // toast 由上方 useEffect 处理 } closeModal(); navigateTo(`/chat/dm/${data.uid}`); @@ -120,7 +152,18 @@ const SearchUser: FC = ({ closeModal }) => { />
- {isSuccess ? ( + {isError ? ( +
+ + {isSearchDisabled + ? t("search_disabled", { ns: "member" }) + : t("tip.error", { ns: "common", defaultValue: "Something went wrong" })} + + + Ok + +
+ ) : isSuccess ? ( data ? (
= ({ closeModal }) => { {t("send_msg", { ns: "member" })} - {!inContact && ( + {!inContact && (isAdmin || addFriendEnable) && ( - Add to contact + {t("add_to_contact", { ns: "member" })} )}
diff --git a/src/routes/chat/Layout/AddContactTip.tsx b/src/routes/chat/Layout/AddContactTip.tsx index 10b6f65b..4eb32a57 100644 --- a/src/routes/chat/Layout/AddContactTip.tsx +++ b/src/routes/chat/Layout/AddContactTip.tsx @@ -1,11 +1,13 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import toast from "react-hot-toast"; +import { shallowEqual } from "react-redux"; import IconAdd from "@/assets/icons/add.person.svg"; import IconBlock from "@/assets/icons/block.svg"; import { useUpdateContactStatusMutation } from "../../../app/services/user"; import { useAppSelector } from "../../../app/store"; import { ContactAction } from "../../../types/user"; -import { shallowEqual } from "react-redux"; type Props = { uid: number; @@ -13,12 +15,31 @@ type Props = { const AddContactTip = (props: Props) => { const { t } = useTranslation("chat"); - const [updateContactStatus] = useUpdateContactStatusMutation(); + const { t: tMember } = useTranslation("member"); + const [updateContactStatus, { error: addContactError }] = useUpdateContactStatusMutation(); const enableContact = useAppSelector( (store) => store.server.contact_verification_enable, shallowEqual ); + const isAdmin = useAppSelector((store) => store.authData.user?.is_admin, shallowEqual); + const addFriendEnable = useAppSelector( + (store) => store.server.add_friend_enable ?? true, + shallowEqual + ); const targetUser = useAppSelector((store) => store.users.byId[props.uid], shallowEqual); + + useEffect(() => { + if (!addContactError) return; + const err = addContactError as any; + const httpStatus: number = err?.originalStatus ?? err?.status; + const errData: string = typeof err?.data === "string" ? err.data : ""; + toast.error( + httpStatus === 403 && errData.includes("disabled by the administrator") + ? tMember("add_friend_disabled") + : tMember("tip.update_failed", { ns: "common", defaultValue: "Operation failed" }) + ); + }, [addContactError]); + const handleContactStatus = (action: ContactAction) => { updateContactStatus({ target_uid: props.uid, action }); }; @@ -32,7 +53,7 @@ const AddContactTip = (props: Props) => { {blocked ? t("contact_block_tip") : t("contact_tip")}
    - {!blocked && ( + {!blocked && (isAdmin || addFriendEnable) && (
  • {t("add_contact")} diff --git a/src/routes/setting/Overview/AddFriendEnable.tsx b/src/routes/setting/Overview/AddFriendEnable.tsx new file mode 100644 index 00000000..58c4d73e --- /dev/null +++ b/src/routes/setting/Overview/AddFriendEnable.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { shallowEqual } from "react-redux"; + +import SettingBlock from "@/components/SettingBlock"; +import Toggle from "@/components/styled/Toggle"; +import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from "@/app/services/server"; +import { useAppSelector } from "@/app/store"; + +const AddFriendEnable = () => { + const { t } = useTranslation("setting", { keyPrefix: "overview.add_friend_enable" }); + const { t: ct } = useTranslation(); + const { refetch } = useGetSystemCommonQuery(); + const addFriendEnable = useAppSelector( + (store) => store.server.add_friend_enable ?? true, + shallowEqual + ); + const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation(); + + useEffect(() => { + if (isSuccess) { + refetch(); + toast.success(ct("tip.update")); + } + }, [isSuccess]); + + const handleToggle = () => { + updateSetting({ add_friend_enable: !addFriendEnable }); + }; + + return ( + } + /> + ); +}; + +export default AddFriendEnable; diff --git a/src/routes/setting/Overview/DMEnable.tsx b/src/routes/setting/Overview/DMEnable.tsx new file mode 100644 index 00000000..a694e67e --- /dev/null +++ b/src/routes/setting/Overview/DMEnable.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { shallowEqual } from "react-redux"; + +import SettingBlock from "@/components/SettingBlock"; +import Toggle from "@/components/styled/Toggle"; +import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from "@/app/services/server"; +import { useAppSelector } from "@/app/store"; + +const DMEnable = () => { + const { t } = useTranslation("setting", { keyPrefix: "overview.dm_enable" }); + const { t: ct } = useTranslation(); + const { refetch } = useGetSystemCommonQuery(); + const dmEnable = useAppSelector((store) => store.server.dm_enable ?? true, shallowEqual); + const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation(); + + useEffect(() => { + if (isSuccess) { + refetch(); + toast.success(ct("tip.update")); + } + }, [isSuccess]); + + const handleToggle = () => { + updateSetting({ dm_enable: !dmEnable }); + }; + + return ( + } + /> + ); +}; + +export default DMEnable; diff --git a/src/routes/setting/Overview/SearchUserEnable.tsx b/src/routes/setting/Overview/SearchUserEnable.tsx new file mode 100644 index 00000000..9349c9e7 --- /dev/null +++ b/src/routes/setting/Overview/SearchUserEnable.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { shallowEqual } from "react-redux"; + +import SettingBlock from "@/components/SettingBlock"; +import Toggle from "@/components/styled/Toggle"; +import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from "@/app/services/server"; +import { useAppSelector } from "@/app/store"; + +const SearchUserEnable = () => { + const { t } = useTranslation("setting", { keyPrefix: "overview.search_user_enable" }); + const { t: ct } = useTranslation(); + const { refetch } = useGetSystemCommonQuery(); + const searchUserEnable = useAppSelector( + (store) => store.server.search_user_enable ?? true, + shallowEqual + ); + const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation(); + + useEffect(() => { + if (isSuccess) { + refetch(); + toast.success(ct("tip.update")); + } + }, [isSuccess]); + + const handleToggle = () => { + updateSetting({ search_user_enable: !searchUserEnable }); + }; + + return ( + } + /> + ); +}; + +export default SearchUserEnable; diff --git a/src/routes/setting/Overview/index.tsx b/src/routes/setting/Overview/index.tsx index 043a74ff..18eb4c14 100644 --- a/src/routes/setting/Overview/index.tsx +++ b/src/routes/setting/Overview/index.tsx @@ -20,6 +20,9 @@ import WhoCanSignUpSetting from "./WhoCanSignUpSetting"; import UserMsgEmailNotify from "./UserMsgEmailNotify"; import WhoCanInviteUsers from "./WhoCanInviteUsers"; import WebClientAutoUpdate from "./WebClientAutoUpdate"; +import DMEnable from "./DMEnable"; +import AddFriendEnable from "./AddFriendEnable"; +import SearchUserEnable from "./SearchUserEnable"; export default function Overview() { const { t } = useTranslation("setting"); @@ -45,6 +48,9 @@ export default function Overview() { + + + {/* 访客模式 */} diff --git a/src/types/server.ts b/src/types/server.ts index 57447b5b..36905756 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -19,6 +19,9 @@ export interface SystemCommon { ext_setting: null | string; msg_smtp_notify_enable: boolean; msg_smtp_notify_delay_seconds: number; + dm_enable?: boolean; + add_friend_enable?: boolean; + search_user_enable?: boolean; } export interface GithubAuthConfig { client_id?: string;