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",
"version": "0.9.70",
"version": "0.9.71",
"homepage": "https://voce.chat",
"dependencies": {
"@metamask/onboarding": "^1.0.1",
+6
View File
@@ -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",
+12
View File
@@ -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.",
+6
View File
@@ -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": "用户邮箱",
+12
View File
@@ -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": "如果开启,消息中的网址会显示预览内容",
+1 -1
View File
@@ -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");
}
+12 -5
View File
@@ -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));
+10 -1
View File
@@ -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
};
},
+19 -8
View File
@@ -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 (
<>
<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")}
</li>
)}
<li className={itemClass} onClick={toggleUsersModalVisible}>
<IconMention className={iconClass} />
{t("action.new_msg")}
</li>
{showNewMsg && (
<li className={itemClass} onClick={toggleUsersModalVisible}>
<IconMention className={iconClass} />
{t("action.new_msg")}
</li>
)}
{showInvite && (
<li className={itemClass} onClick={toggleInviteModalVisible}>
<IconInvite className={iconClass} />
{t("action.invite_people")}
</li>
)}
<li className={itemClass} onClick={toggleSearchModalVisible}>
<IconSearch className={iconClass} />
{t("action.search_people")}
</li>
{showSearchPeople && (
<li className={itemClass} onClick={toggleSearchModalVisible}>
<IconSearch className={iconClass} />
{t("action.search_people")}
</li>
)}
</ul>
{channelModalVisible && <ChannelModal personal={isPrivate} closeModal={handleCloseModal} />}
{usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />}
+51 -8
View File
@@ -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<Props> = ({ 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<Props> = ({ 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<HTMLInputElement>) => {
const tmp = {
[type]: evt.target.value
@@ -69,7 +100,8 @@ const SearchUser: FC<Props> = ({ 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<Props> = ({ closeModal }) => {
/>
</form>
<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 ? (
<div className="flex flex-col items-center pt-10">
<Avatar
@@ -139,13 +182,13 @@ const SearchUser: FC<Props> = ({ closeModal }) => {
<StyledButton className="mini ghost" onClick={handleSendMsg}>
{t("send_msg", { ns: "member" })}
</StyledButton>
{!inContact && (
{!inContact && (isAdmin || addFriendEnable) && (
<StyledButton
disabled={adding}
onClick={handleChat.bind(null, inContact)}
className={clsx("mini", inContact && "ghost")}
>
Add to contact
{t("add_to_contact", { ns: "member" })}
</StyledButton>
)}
</div>
+24 -3
View File
@@ -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")}
</h3>
<ul className="flex gap-4">
{!blocked && (
{!blocked && (isAdmin || addFriendEnable) && (
<li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
<IconAdd className="fill-primary-400" />
<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 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() {
<EnableURLPreviewInMsg />
<WhoCanInviteUsers />
<DMEnable />
<AddFriendEnable />
<SearchUserEnable />
<OnlyAdminCanSeeChannelMembers />
{/* 访客模式 */}
<GuestMode />
+3
View File
@@ -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;