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 (
<>
@@ -83,20 +90,24 @@ export default function AddEntriesMenu() {
{t("action.new_private_channel")}
)}
- -
-
- {t("action.new_msg")}
-
+ {showNewMsg && (
+ -
+
+ {t("action.new_msg")}
+
+ )}
{showInvite && (
-
{t("action.invite_people")}
)}
- -
-
- {t("action.search_people")}
-
+ {showSearchPeople && (
+ -
+
+ {t("action.search_people")}
+
+ )}
{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;