feat: add user search, direct messaging, and friend addition settings (#324)

* feat: add user search, direct messaging, and friend addition settings

* chore: bump version to v0.9.71
This commit is contained in:
haorwen
2026-04-04 18:24:25 +08:00
committed by GitHub
parent 079ddfbc6a
commit 91ba7c007e
16 changed files with 283 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vocechat-web", "name": "vocechat-web",
"version": "0.9.70", "version": "0.9.71",
"homepage": "https://voce.chat", "homepage": "https://voce.chat",
"dependencies": { "dependencies": {
"@metamask/onboarding": "^1.0.1", "@metamask/onboarding": "^1.0.1",
+6
View File
@@ -30,6 +30,12 @@
"set_normal": "User", "set_normal": "User",
"search_not_found": "Not found, or this user is not allowed to be searched.", "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": "Search by ID",
"search_by_id_ph": "Input user ID", "search_by_id_ph": "Input user ID",
"search_by_email": "Search by email", "search_by_email": "Search by email",
+12
View File
@@ -100,6 +100,18 @@
"enable": "Enable", "enable": "Enable",
"disable": "Disable" "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": { "enable_msg_url_preview": {
"title": "Enable URL Preview in Message", "title": "Enable URL Preview in Message",
"desc": "If enabled, url in message will show preview content.", "desc": "If enabled, url in message will show preview content.",
+6
View File
@@ -29,6 +29,12 @@
"set_normal": "普通用户", "set_normal": "普通用户",
"search_not_found": "用户不存在,或者不允许搜索", "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": "用户ID",
"search_by_id_ph": "请输入用户ID", "search_by_id_ph": "请输入用户ID",
"search_by_email": "用户邮箱", "search_by_email": "用户邮箱",
+12
View File
@@ -98,6 +98,18 @@
"enable": "开启", "enable": "开启",
"disable": "关闭" "disable": "关闭"
}, },
"dm_enable": {
"title": "私聊(直接消息)",
"desc": "关闭后,普通成员之间无法互发私信"
},
"add_friend_enable": {
"title": "加好友",
"desc": "关闭后,普通成员无法将其他用户添加为好友"
},
"search_user_enable": {
"title": "搜索用户",
"desc": "关闭后,普通成员无法搜索其他用户"
},
"enable_msg_url_preview": { "enable_msg_url_preview": {
"title": "开启消息网址预览", "title": "开启消息网址预览",
"desc": "如果开启,消息中的网址会显示预览内容", "desc": "如果开启,消息中的网址会显示预览内容",
+1 -1
View File
@@ -133,7 +133,7 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) =
break; break;
case 403: case 403:
{ {
const whiteList403 = ["sendMsg", "login"]; const whiteList403 = ["sendMsg", "login", "searchUser", "updateContactStatus"];
if (!whiteList403.includes(api.endpoint)) { if (!whiteList403.includes(api.endpoint)) {
toast.error("Request Not Allowed"); toast.error("Request Not Allowed");
} }
+12 -5
View File
@@ -1,6 +1,7 @@
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { batch } from "react-redux"; import { batch } from "react-redux";
import i18n from "@/i18n";
import { ContentTypes } from "../config"; import { ContentTypes } from "../config";
import { addMessage, removeMessage } from "../slices/message"; import { addMessage, removeMessage } from "../slices/message";
import { addChannelMsg, removeChannelMsg } from "../slices/message.channel"; import { addChannelMsg, removeChannelMsg } from "../slices/message.channel";
@@ -60,12 +61,18 @@ export const onMessageSendStarted = async (
}, 300); }, 300);
// dispatch(removePendingMessage({ id, mid:ts, type: from })); // dispatch(removePendingMessage({ id, mid:ts, type: from }));
} catch (error) { } catch (error) {
if (error?.error?.status == 403) { const httpStatus = error?.error?.originalStatus ?? error?.error?.status;
// 403 means blocked const errData: string =
toast.error(`Send failed, blocked maybe`); typeof error?.error?.data === "string" ? error.error.data : "";
// dispatch(updateMessage({ mid: ts, failed: true })); 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 { } 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(removeContextMessage({ id, mid: ts }));
dispatch(removeMessage(ts)); dispatch(removeMessage(ts));
+10 -1
View File
@@ -29,7 +29,10 @@ const initialState: StoredServer = {
loginConfig: null, loginConfig: null,
ext_setting: null, ext_setting: null,
msg_smtp_notify_delay_seconds: 0, 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({ const serverSlice = createSlice({
@@ -57,6 +60,9 @@ const serverSlice = createSlice({
only_admin_can_create_group = false, only_admin_can_create_group = false,
loginConfig = state.loginConfig || null, loginConfig = state.loginConfig || null,
ext_setting = null, ext_setting = null,
dm_enable = true,
add_friend_enable = true,
search_user_enable = true,
...rest ...rest
} = action.payload || {}; } = action.payload || {};
return { return {
@@ -73,6 +79,9 @@ const serverSlice = createSlice({
chat_layout_mode, chat_layout_mode,
loginConfig, loginConfig,
ext_setting, ext_setting,
dm_enable,
add_friend_enable,
search_user_enable,
...rest ...rest
}; };
}, },
+19 -8
View File
@@ -24,6 +24,11 @@ export default function AddEntriesMenu() {
(store) => store.server.only_admin_can_create_group, (store) => store.server.only_admin_can_create_group,
shallowEqual 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 [isPrivate, setIsPrivate] = useState(false);
const [inviteModalVisible, setInviteModalVisible] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [searchModalVisible, setSearchModalVisible] = 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 iconClass = "w-5 h-5 dark:fill-gray-300";
const canPrivateGroup = onlyAdminCreateGroup ? isAdmin : true; const canPrivateGroup = onlyAdminCreateGroup ? isAdmin : true;
const showInvite = isAdmin || !onlyAdminCanInvite; const showInvite = isAdmin || !onlyAdminCanInvite;
const showNewMsg = isAdmin || dmEnable;
const showSearchPeople = isAdmin || searchUserEnable;
return ( return (
<> <>
<ul className="flex flex-col rounded-xl drop-shadow p-1 select-none text-gray-500 dark:text-gray-300 bg-white dark:bg-black"> <ul className="flex flex-col rounded-xl drop-shadow p-1 select-none text-gray-500 dark:text-gray-300 bg-white dark:bg-black">
@@ -83,20 +90,24 @@ export default function AddEntriesMenu() {
{t("action.new_private_channel")} {t("action.new_private_channel")}
</li> </li>
)} )}
<li className={itemClass} onClick={toggleUsersModalVisible}> {showNewMsg && (
<IconMention className={iconClass} /> <li className={itemClass} onClick={toggleUsersModalVisible}>
{t("action.new_msg")} <IconMention className={iconClass} />
</li> {t("action.new_msg")}
</li>
)}
{showInvite && ( {showInvite && (
<li className={itemClass} onClick={toggleInviteModalVisible}> <li className={itemClass} onClick={toggleInviteModalVisible}>
<IconInvite className={iconClass} /> <IconInvite className={iconClass} />
{t("action.invite_people")} {t("action.invite_people")}
</li> </li>
)} )}
<li className={itemClass} onClick={toggleSearchModalVisible}> {showSearchPeople && (
<IconSearch className={iconClass} /> <li className={itemClass} onClick={toggleSearchModalVisible}>
{t("action.search_people")} <IconSearch className={iconClass} />
</li> {t("action.search_people")}
</li>
)}
</ul> </ul>
{channelModalVisible && <ChannelModal personal={isPrivate} closeModal={handleCloseModal} />} {channelModalVisible && <ChannelModal personal={isPrivate} closeModal={handleCloseModal} />}
{usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />} {usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />}
+51 -8
View File
@@ -1,8 +1,8 @@
// import Tippy from "@tippyjs/react"; import { ChangeEvent, FC, FormEvent, useEffect, useRef, useState } from "react";
import { ChangeEvent, FC, FormEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import clsx from "clsx"; import clsx from "clsx";
import toast from "react-hot-toast";
import BASE_URL from "@/app/config"; import BASE_URL from "@/app/config";
import { useSearchUserMutation, useUpdateContactStatusMutation } from "@/app/services/user"; import { useSearchUserMutation, useUpdateContactStatusMutation } from "@/app/services/user";
@@ -21,8 +21,14 @@ type Props = {
type Type = "id" | "email" | "name"; type Type = "id" | "email" | "name";
const SearchUser: FC<Props> = ({ closeModal }) => { const SearchUser: FC<Props> = ({ closeModal }) => {
const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation(); const [updateContactStatus, { isLoading: adding, error: addContactError }] =
useUpdateContactStatusMutation();
const usersData = useAppSelector((store) => store.users.byId, shallowEqual); 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 { t } = useTranslation();
const navigateTo = useNavigate(); const navigateTo = useNavigate();
const inputRef = useRef(null); const inputRef = useRef(null);
@@ -32,7 +38,32 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
email: "", email: "",
name: "" 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<HTMLInputElement>) => { const handleInput = (evt: ChangeEvent<HTMLInputElement>) => {
const tmp = { const tmp = {
[type]: evt.target.value [type]: evt.target.value
@@ -69,7 +100,8 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
const handleChat = async (directChat: boolean) => { const handleChat = async (directChat: boolean) => {
if (!data) return; if (!data) return;
if (!directChat) { 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(); closeModal();
navigateTo(`/chat/dm/${data.uid}`); navigateTo(`/chat/dm/${data.uid}`);
@@ -120,7 +152,18 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
/> />
</form> </form>
<div className="min-h-[280px] flex-center pb-10"> <div className="min-h-[280px] flex-center pb-10">
{isSuccess ? ( {isError ? (
<div className="w-full h-full text-center flex flex-col gap-3 items-center">
<span className="text-sm text-gray-800 dark:text-gray-200">
{isSearchDisabled
? t("search_disabled", { ns: "member" })
: t("tip.error", { ns: "common", defaultValue: "Something went wrong" })}
</span>
<StyledButton className="mini" onClick={resetInput}>
Ok
</StyledButton>
</div>
) : isSuccess ? (
data ? ( data ? (
<div className="flex flex-col items-center pt-10"> <div className="flex flex-col items-center pt-10">
<Avatar <Avatar
@@ -139,13 +182,13 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
<StyledButton className="mini ghost" onClick={handleSendMsg}> <StyledButton className="mini ghost" onClick={handleSendMsg}>
{t("send_msg", { ns: "member" })} {t("send_msg", { ns: "member" })}
</StyledButton> </StyledButton>
{!inContact && ( {!inContact && (isAdmin || addFriendEnable) && (
<StyledButton <StyledButton
disabled={adding} disabled={adding}
onClick={handleChat.bind(null, inContact)} onClick={handleChat.bind(null, inContact)}
className={clsx("mini", inContact && "ghost")} className={clsx("mini", inContact && "ghost")}
> >
Add to contact {t("add_to_contact", { ns: "member" })}
</StyledButton> </StyledButton>
)} )}
</div> </div>
+24 -3
View File
@@ -1,11 +1,13 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next"; 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 IconAdd from "@/assets/icons/add.person.svg";
import IconBlock from "@/assets/icons/block.svg"; import IconBlock from "@/assets/icons/block.svg";
import { useUpdateContactStatusMutation } from "../../../app/services/user"; import { useUpdateContactStatusMutation } from "../../../app/services/user";
import { useAppSelector } from "../../../app/store"; import { useAppSelector } from "../../../app/store";
import { ContactAction } from "../../../types/user"; import { ContactAction } from "../../../types/user";
import { shallowEqual } from "react-redux";
type Props = { type Props = {
uid: number; uid: number;
@@ -13,12 +15,31 @@ type Props = {
const AddContactTip = (props: Props) => { const AddContactTip = (props: Props) => {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const [updateContactStatus] = useUpdateContactStatusMutation(); const { t: tMember } = useTranslation("member");
const [updateContactStatus, { error: addContactError }] = useUpdateContactStatusMutation();
const enableContact = useAppSelector( const enableContact = useAppSelector(
(store) => store.server.contact_verification_enable, (store) => store.server.contact_verification_enable,
shallowEqual 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); 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) => { const handleContactStatus = (action: ContactAction) => {
updateContactStatus({ target_uid: props.uid, action }); updateContactStatus({ target_uid: props.uid, action });
}; };
@@ -32,7 +53,7 @@ const AddContactTip = (props: Props) => {
{blocked ? t("contact_block_tip") : t("contact_tip")} {blocked ? t("contact_block_tip") : t("contact_tip")}
</h3> </h3>
<ul className="flex gap-4"> <ul className="flex gap-4">
{!blocked && ( {!blocked && (isAdmin || addFriendEnable) && (
<li className={itemClass} onClick={handleContactStatus.bind(null, "add")}> <li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
<IconAdd className="fill-primary-400" /> <IconAdd className="fill-primary-400" />
<span>{t("add_contact")}</span> <span>{t("add_contact")}</span>
@@ -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 (
<SettingBlock
title={t("title")}
desc={t("desc")}
toggler={<Toggle onClick={handleToggle} checked={addFriendEnable} />}
/>
);
};
export default AddFriendEnable;
+38
View File
@@ -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 (
<SettingBlock
title={t("title")}
desc={t("desc")}
toggler={<Toggle onClick={handleToggle} checked={dmEnable} />}
/>
);
};
export default DMEnable;
@@ -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 (
<SettingBlock
title={t("title")}
desc={t("desc")}
toggler={<Toggle onClick={handleToggle} checked={searchUserEnable} />}
/>
);
};
export default SearchUserEnable;
+6
View File
@@ -20,6 +20,9 @@ import WhoCanSignUpSetting from "./WhoCanSignUpSetting";
import UserMsgEmailNotify from "./UserMsgEmailNotify"; import UserMsgEmailNotify from "./UserMsgEmailNotify";
import WhoCanInviteUsers from "./WhoCanInviteUsers"; import WhoCanInviteUsers from "./WhoCanInviteUsers";
import WebClientAutoUpdate from "./WebClientAutoUpdate"; import WebClientAutoUpdate from "./WebClientAutoUpdate";
import DMEnable from "./DMEnable";
import AddFriendEnable from "./AddFriendEnable";
import SearchUserEnable from "./SearchUserEnable";
export default function Overview() { export default function Overview() {
const { t } = useTranslation("setting"); const { t } = useTranslation("setting");
@@ -45,6 +48,9 @@ export default function Overview() {
<EnableURLPreviewInMsg /> <EnableURLPreviewInMsg />
<WhoCanInviteUsers /> <WhoCanInviteUsers />
<DMEnable />
<AddFriendEnable />
<SearchUserEnable />
<OnlyAdminCanSeeChannelMembers /> <OnlyAdminCanSeeChannelMembers />
{/* 访客模式 */} {/* 访客模式 */}
<GuestMode /> <GuestMode />
+3
View File
@@ -19,6 +19,9 @@ export interface SystemCommon {
ext_setting: null | string; ext_setting: null | string;
msg_smtp_notify_enable: boolean; msg_smtp_notify_enable: boolean;
msg_smtp_notify_delay_seconds: number; msg_smtp_notify_delay_seconds: number;
dm_enable?: boolean;
add_friend_enable?: boolean;
search_user_enable?: boolean;
} }
export interface GithubAuthConfig { export interface GithubAuthConfig {
client_id?: string; client_id?: string;