diff --git a/package.json b/package.json index 80cfd5c7..090fcaed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.3.60", + "version": "0.3.61", "homepage": "https://voce.chat", "dependencies": { "@emoji-mart/react": "^1.1.1", diff --git a/public/locales/en/chat.json b/public/locales/en/chat.json index 9122db9b..2be20306 100644 --- a/public/locales/en/chat.json +++ b/public/locales/en/chat.json @@ -40,6 +40,8 @@ "new_msg": "{{num}} new messages", "mark_read": "Mark As Read", + "pin_chat": "Sticky on top", + "unpin_chat": "Unsticky", "reply_msg_del": "This message has been deleted.", @@ -54,5 +56,10 @@ "unmute": "Unmute", "camera_on": "Turn on camera", "camera_off": "Turn off camera", - "leave_voice": "Leave Voice Chat" + "leave_voice": "Leave Voice Chat", + "add_contact": "Add to contacts", + "block": "Block", + "unblock": "Unblock", + "contact_tip": "This user is not in your contact", + "contact_block_tip": "This user has been blocked by you" } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 3a00324d..799afdd8 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -17,6 +17,7 @@ "new_private_channel": "New Private Channel", "new_msg": "New Message", "invite_people": "Invite People", + "search_people": "Search People", "create": "Create", "open": "Open", "update": "Update", diff --git a/public/locales/en/member.json b/public/locales/en/member.json index 344736c0..db330018 100644 --- a/public/locales/en/member.json +++ b/public/locales/en/member.json @@ -13,14 +13,19 @@ "new_pwd": "New Password", "confirm_new_pwd": "Confirm New Password", - "manage_members": "Manage Members", + "manage_members": "Manage Contacts", "manage_tip": "Disabling your account means you can recover it at any time after taking this action.", "admin": "Admin", "user": "User", "copy_email": "Copy Email", "send_msg": "Send Message", - "remove": "Remove", + "remove": "Remove from Server", + "remove_from_contact": "Remove From Contact", "remove_account": "Remove Account", "remove_account_desc": "Are you sure remove this account?", - "remove_from_channel": "Remove From Channel" + "remove_from_channel": "Remove From Channel", + + "search_not_found": "Not found, or this user is not allowed to be searched.", + "search_by_id": "Search by ID", + "search_by_email": "Search by Email" } diff --git a/public/locales/en/setting.json b/public/locales/en/setting.json index 587811a8..a72b1721 100644 --- a/public/locales/en/setting.json +++ b/public/locales/en/setting.json @@ -6,7 +6,7 @@ "nav": { "general": "General", "overview": "Overview", - "members": "Members", + "members": "Contacts", "auto_delete_msg": "Auto-delete Messages", "user": "User", "my_account": "My Account", @@ -21,9 +21,7 @@ "license": "License", "about": "About", "api_doc": "API Document", - "version": "Version", - "faq": "FAQ", - "feedback": "Feedback" + "version": "Version" }, "channel": { "leave": "Leave Channel", diff --git a/public/locales/en/welcome.json b/public/locales/en/welcome.json index dff78d8a..c17413f3 100644 --- a/public/locales/en/welcome.json +++ b/public/locales/en/welcome.json @@ -1,6 +1,6 @@ { "title": "Welcome to {{name}} server", - "desc": "Here are some steps to help you get started. For more, check out our Getting Started guide", + "desc": "Here are some steps to help you get started.", "invite": "Invite your friends or teammates", "start_by_channel": "Create a Channel to Start a Conversation", "start_by_dm": "Send a Direct Message", diff --git a/public/locales/zh/chat.json b/public/locales/zh/chat.json index 5cc88b87..40824a42 100644 --- a/public/locales/zh/chat.json +++ b/public/locales/zh/chat.json @@ -41,7 +41,8 @@ "new_msg": "{{num}} 条新消息", "mark_read": "设为已读", - + "pin_chat": "置顶", + "unpin_chat": "取消置顶", "reply_msg_del": "被回复的消息已删除!", "file": "文件", @@ -55,5 +56,10 @@ "unmute": "取消静音", "camera_on": "开启摄像头", "camera_off": "关闭摄像头", - "leave_voice": "离开" + "leave_voice": "离开", + "add_contact": "加为联系人", + "block": "屏蔽", + "unblock": "解除屏蔽", + "contact_tip": "该用户不在您的联系人列表中", + "contact_block_tip": "该用户已被你屏蔽" } diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index e5bed199..97fb1362 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -1,6 +1,6 @@ { "chat": "聊天", - "members": "成员", + "members": "联系人", "favs": "收藏", "files": "文件", "setting": "设置", @@ -16,6 +16,7 @@ "new_private_channel": "新建私有频道", "new_msg": "发消息", "invite_people": "邀请TA人", + "search_people": "搜索用户", "search": "搜索", "create": "创建", "open": "打开", diff --git a/public/locales/zh/member.json b/public/locales/zh/member.json index 7eba8512..ee6fe7ba 100644 --- a/public/locales/zh/member.json +++ b/public/locales/zh/member.json @@ -12,14 +12,19 @@ "current_pwd": "当前密码", "new_pwd": "新密码", "confirm_new_pwd": "确认密码", - "manage_members": "成员管理", - "manage_tip": "请在此管理你的成员", + "manage_members": "联系人管理", + "manage_tip": "请在此管理你的联系人", "admin": "管理员", "user": "普通用户", "copy_email": "复制邮箱", "send_msg": "发消息", - "remove": "删除", + "remove": "从服务器删除", + "remove_from_contact": "从联系人中移除", "remove_account": "注销账号", "remove_account_desc": "确定永久注销该账号?", - "remove_from_channel": "从频道移除" + "remove_from_channel": "从频道移除", + + "search_not_found": "用户不存在,或者不允许搜索", + "search_by_id": "用户ID", + "search_by_email": "用户邮箱" } diff --git a/public/locales/zh/setting.json b/public/locales/zh/setting.json index f1475001..f22cd782 100644 --- a/public/locales/zh/setting.json +++ b/public/locales/zh/setting.json @@ -6,7 +6,7 @@ "nav": { "general": "通用", "overview": "概况", - "members": "成员", + "members": "联系人", "auto_delete_msg": "自动删除消息", "my_account": "我的账号", "config": "配置", diff --git a/src/app/listener.middleware/handler.users.ts b/src/app/listener.middleware/handler.users.ts index 65aa3fe8..cd0badef 100644 --- a/src/app/listener.middleware/handler.users.ts +++ b/src/app/listener.middleware/handler.users.ts @@ -1,5 +1,5 @@ import localforage from "localforage"; -import { User } from "../../types/user"; +import { ContactStatus, User } from "../../types/user"; import clearTable from "./clear.handler"; export default async function handler({ operation, data, payload }) { @@ -18,6 +18,17 @@ export default async function handler({ operation, data, payload }) { await table.setItems(arr); } break; + case "updateContactStatus": + { + const tmp = payload as { uid: number, status: ContactStatus } | { uid: number, status: ContactStatus }[]; + const arr = Array.isArray(tmp) ? tmp : [tmp]; + const opts = arr.map(({ uid, status }) => { + return { key: uid + "", value: { ...data.byId[uid], status } }; + }); + + await table.setItems(opts); + } + break; case "updateUsersByLogs": { const changeLogs = payload; diff --git a/src/app/services/base.query.ts b/src/app/services/base.query.ts index 8a9325c0..06674a47 100644 --- a/src/app/services/base.query.ts +++ b/src/app/services/base.query.ts @@ -102,8 +102,13 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) = } } break; - case 403: - toast.error("Request Not Allowed"); + case 403: { + const whiteList403 = ["sendMsg"]; + if (!whiteList403.includes(api.endpoint)) { + toast.error("Request Not Allowed"); + } + } + break; case 404: { diff --git a/src/app/services/channel.ts b/src/app/services/channel.ts index 41decd01..50097775 100644 --- a/src/app/services/channel.ts +++ b/src/app/services/channel.ts @@ -117,6 +117,25 @@ export const channelApi = createApi({ } } }), + createPrivateInviteLink: builder.query({ + query: (gid) => ({ + headers: { + "content-type": "text/plain", + accept: "text/plain" + }, + // 七天过期 + url: `/group/create_invite_private_magic_link?expired_in=604800&max_times=1&gid=${gid}`, + responseHandler: "text" + }), + transformResponse: (link: string) => { + // 确保http开头 + const _link = link.startsWith("http") ? link : `http://${link}`; + // return _link; + // 替换掉域名 + const invite = new URL(_link); + return `${location.origin}${invite.pathname}${invite.hash}${invite.search}`; + } + }), removeChannel: builder.query({ query: (id) => ({ url: `/group/${id}`, @@ -186,6 +205,13 @@ export const channelApi = createApi({ body: members }) }), + joinPrivateChannel: builder.mutation({ + query: (body) => ({ + url: `/user/join_private`, + method: "POST", + body + }) + }), updateIcon: builder.mutation({ query: ({ gid, image }) => ({ headers: { @@ -216,6 +242,8 @@ export const { useChangeChannelTypeMutation, useLazyLeaveChannelQuery, useLazyCreateInviteLinkQuery, + useJoinPrivateChannelMutation, + useLazyCreatePrivateInviteLinkQuery, useCreateInviteLinkQuery, useGetChannelQuery, useLazyGetChannelQuery, diff --git a/src/app/services/handlers.ts b/src/app/services/handlers.ts index 255a37ef..21530cfa 100644 --- a/src/app/services/handlers.ts +++ b/src/app/services/handlers.ts @@ -3,7 +3,7 @@ import { batch } from "react-redux"; import { ContentTypes } from "../config"; import { addChannelMsg, removeChannelMsg } from "../slices/message.channel"; import { addUserMsg, removeUserMsg } from "../slices/message.user"; -import { addMessage, removeMessage } from "../slices/message"; +import { addMessage, removeMessage, updateMessage } from "../slices/message"; // import { ContentType } from "../../types/message"; export const onMessageSendStarted = async ( @@ -57,10 +57,16 @@ export const onMessageSendStarted = async ( dispatch(removeMessage(ts)); }, 300); // dispatch(removePendingMessage({ id, mid:ts, type: from })); - } catch { - toast.error("Send Message Failed"); - dispatch(removeContextMessage({ id, mid: ts })); - dispatch(removeMessage(ts)); + } catch (error) { + if (error?.error?.status == 403) { + // 403 means blocked + // toast.error(`Failed to send, blocked maybe.`,); + dispatch(updateMessage({ mid: ts, failed: true })); + } else { + toast.error(`Send Message Failed ${JSON.stringify(error)}`,); + dispatch(removeContextMessage({ id, mid: ts })); + dispatch(removeMessage(ts)); + } // patchResult.undo(); } }; diff --git a/src/app/services/user.ts b/src/app/services/user.ts index 8b381996..88b32bde 100644 --- a/src/app/services/user.ts +++ b/src/app/services/user.ts @@ -2,18 +2,19 @@ import { createApi } from "@reduxjs/toolkit/query/react"; // import toast from "react-hot-toast"; import baseQuery from "./base.query"; import { updateAutoDeleteSetting, updateMute } from "../slices/footprint"; -import { fillUsers } from "../slices/users"; +import { fillUsers, updateContactStatus as updateStatus } from "../slices/users"; import BASE_URL, { ContentTypes } from "../config"; import { onMessageSendStarted } from "./handlers"; -import { AutoDeleteMsgDTO, BotAPIKey, User, UserCreateDTO, UserDTO, UserForAdmin, UserForAdminDTO } from "../../types/user"; +import { AutoDeleteMsgDTO, BotAPIKey, ContactAction, ContactResponse, ContactStatus, User, UserCreateDTO, UserDTO, UserForAdmin, UserForAdminDTO } from "../../types/user"; import { ContentTypeKey, MuteDTO } from "../../types/message"; +import { RootState } from "../store"; export const userApi = createApi({ reducerPath: "userApi", baseQuery, endpoints: (builder) => ({ getUsers: builder.query({ - query: () => ({ url: `user` }), + query: () => ({ url: `/user` }), transformResponse: (data: User[]) => { return data.map((user) => { return { @@ -25,12 +26,38 @@ export const userApi = createApi({ }; }); }, + async onQueryStarted(data, { dispatch, queryFulfilled, getState }) { + try { + const { data: users } = await queryFulfilled; + const { authData: { user: loginUser } } = getState() as RootState; + dispatch(fillUsers(users.map((u) => { + const status = loginUser?.uid == u.uid ? "added" : ""; + return { + ...u, + status + }; + }))); + } catch { + console.log("get user list error"); + } + } + }), + getContacts: builder.query({ + query: () => ({ url: `/user/contacts` }), async onQueryStarted(data, { dispatch, queryFulfilled }) { try { const { data: users } = await queryFulfilled; - dispatch(fillUsers(users)); + const payloads = users.map((c) => { + const uid = c.target_uid; + const status = c.contact_info.status; + return { + uid, + status + }; + }); + dispatch(updateStatus(payloads)); } catch { - console.log("get user list error"); + console.log("get contact list error"); } } }), @@ -44,6 +71,27 @@ export const userApi = createApi({ method: "POST" }) }), + searchUser: builder.mutation({ + query: (input) => ({ + url: `/user/search`, + body: input, + method: "POST" + }) + }), + pinChat: builder.mutation({ + query: (data) => ({ + url: `/user/pin_chat`, + method: "POST", + body: { target: data } + }) + }), + unpinChat: builder.mutation({ + query: (data) => ({ + url: `/user/unpin_chat`, + method: "POST", + body: { target: data } + }) + }), updateUser: builder.mutation({ query: ({ id, ...rest }) => ({ url: `/admin/user/${id}`, @@ -75,6 +123,28 @@ export const userApi = createApi({ } }), + updateContactStatus: builder.mutation({ + query: (payload) => ({ + url: `/user/update_contact_status`, + method: "POST", + body: payload + }), + async onQueryStarted(data, { dispatch, queryFulfilled }) { + const map = { + "add": "added", + "block": "blocked", + "remove": "", + "unblock": "", + }; + try { + await queryFulfilled; + const status = map[data.action] as ContactStatus; + dispatch(updateStatus({ uid: data.target_uid, status })); + } catch (error) { + console.log("update mute failed", error); + } + } + }), updateMuteSetting: builder.mutation({ query: (data) => ({ url: `/user/mute`, @@ -152,6 +222,7 @@ export const userApi = createApi({ body: type == "file" ? JSON.stringify(content) : content }), async onQueryStarted(param1, param2) { + // @ts-ignore await onMessageSendStarted.call(this, param1, param2, "user"); } }), @@ -160,6 +231,7 @@ export const userApi = createApi({ }); export const { + useLazyGetUsersQuery, useGetUserByAdminQuery, useUpdateAvatarByAdminMutation, useUpdateAutoDeleteMsgMutation, @@ -169,10 +241,13 @@ export const { useLazyDeleteUserQuery, useUpdateInfoMutation, useUpdateAvatarMutation, - useGetUsersQuery, - useLazyGetUsersQuery, + useLazyGetContactsQuery, useSendMsgMutation, useCreateBotAPIKeyMutation, useLazyDeleteBotAPIKeyQuery, - useGetBotAPIKeysQuery + useGetBotAPIKeysQuery, + useSearchUserMutation, + useUpdateContactStatusMutation, + usePinChatMutation, + useUnpinChatMutation } = userApi; diff --git a/src/app/slices/auth.data.ts b/src/app/slices/auth.data.ts index 52c4331c..ef03cc32 100644 --- a/src/app/slices/auth.data.ts +++ b/src/app/slices/auth.data.ts @@ -53,7 +53,7 @@ const authDataSlice = createSlice({ const { initialized = true, user, token, refresh_token, expired_in = 0 } = payload; const { uid, create_by } = user; state.initialized = initialized; - state.user = { ...state.user, ...user }; + state.user = { ...state.user, ...user, status: "added" }; state.guest = create_by == "guest"; state.token = token; state.refreshToken = refresh_token; diff --git a/src/app/slices/footprint.ts b/src/app/slices/footprint.ts index 644e8de6..161f93b7 100644 --- a/src/app/slices/footprint.ts +++ b/src/app/slices/footprint.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { MuteDTO } from "../../types/message"; import { OG } from "../../types/resource"; -import { AutoDeleteMessageSettingDTO, AutoDeleteMsgForGroup, AutoDeleteMsgForUser, AutoDeleteSettingForChannels, AutoDeleteSettingForUsers } from "../../types/sse"; +import { AutoDeleteMessageSettingDTO, AutoDeleteMsgForGroup, AutoDeleteMsgForUser, AutoDeleteSettingForChannels, AutoDeleteSettingForUsers, PinChat, PinChatTarget } from "../../types/sse"; import { resetAuthData } from "./auth.data"; type ChannelAside = "members" | "voice" | "voice_fullscreen" | null; @@ -20,6 +20,7 @@ export interface State { autoDeleteMsgChannels: AutoDeleteMsgForGroup[]; channelAsides: { [cid: number]: ChannelAside }; dmAsides: { [uid: number]: DMAside }; + pinChats: PinChat[]; } @@ -36,7 +37,8 @@ const initialState: State = { autoDeleteMsgUsers: [], autoDeleteMsgChannels: [], channelAsides: {}, - dmAsides: {} + dmAsides: {}, + pinChats: [] }; const footprintSlice = createSlice({ @@ -60,7 +62,8 @@ const footprintSlice = createSlice({ autoDeleteMsgUsers = [], autoDeleteMsgChannels = [], channelAsides = {}, - dmAsides = {} + dmAsides = {}, + pinChats = [] } = action.payload; return { og, @@ -75,7 +78,8 @@ const footprintSlice = createSlice({ autoDeleteMsgUsers, autoDeleteMsgChannels, channelAsides, - dmAsides + dmAsides, + pinChats }; }, updateUsersVersion(state, action: PayloadAction) { @@ -164,6 +168,22 @@ const footprintSlice = createSlice({ } }); }, + upsertPinChats(state, action: PayloadAction<{ pins: PinChat[], override?: boolean }>) { + const { pins, override = false } = action.payload; + if (override) { + state.pinChats = pins; + } else { + state.pinChats = [...pins, ...state.pinChats]; + } + }, + removePinChats(state, action: PayloadAction) { + const pins = action.payload; + state.pinChats = state.pinChats.filter(pin => { + const key = "uid" in pin.target ? "uid" : "gid"; + // @ts-ignore + return !pins.some(p => p[key] == pin.target[key]); + }); + }, upsertOG(state, action: PayloadAction<{ key: string, value: OG }>) { const { key, value } = action.payload; state.og[key] = value; @@ -228,7 +248,9 @@ export const { updateHistoryMark, upsertOG, updateChannelVisibleAside, - updateDMVisibleAside + updateDMVisibleAside, + upsertPinChats, + removePinChats } = footprintSlice.actions; export default footprintSlice.reducer; diff --git a/src/app/slices/message.ts b/src/app/slices/message.ts index 07a0ac15..006ffb73 100644 --- a/src/app/slices/message.ts +++ b/src/app/slices/message.ts @@ -22,6 +22,7 @@ export interface MessagePayload { thumbnail?: string; edited?: boolean | number; reply_mid?: number; + failed?: boolean; } export interface State { [key: number]: MessagePayload; diff --git a/src/app/slices/server.ts b/src/app/slices/server.ts index 90be2e66..a66885d7 100644 --- a/src/app/slices/server.ts +++ b/src/app/slices/server.ts @@ -9,8 +9,6 @@ export interface StoredServer extends Server { link: string; expire: number; }; - show_user_online_status: boolean, - webclient_auto_update: boolean } const initialState: StoredServer = { version: "", @@ -23,7 +21,7 @@ const initialState: StoredServer = { expire: 0 }, show_user_online_status: false, - webclient_auto_update: true + webclient_auto_update: true, }; const serverSlice = createSlice({ @@ -45,7 +43,7 @@ const serverSlice = createSlice({ name = "", description = "", show_user_online_status = true, - webclient_auto_update = true + webclient_auto_update = true, } = action.payload || {}; return { version, upgraded, name, logo, description, inviteLink, show_user_online_status, webclient_auto_update }; }, diff --git a/src/app/slices/users.ts b/src/app/slices/users.ts index 5a787ad0..3ff7875a 100644 --- a/src/app/slices/users.ts +++ b/src/app/slices/users.ts @@ -1,11 +1,11 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { isNull, omitBy } from "lodash"; import BASE_URL from "../config"; -import { User } from "../../types/user"; +import { Contact, ContactStatus } from "../../types/user"; import { UserLog, UserState } from "../../types/sse"; type DMAside = "voice" | null; -export interface StoredUser extends User { +export interface StoredUser extends Contact { online?: boolean; voice?: boolean; avatar?: string; @@ -16,7 +16,6 @@ export interface State { ids: number[]; byId: { [id: number]: StoredUser }; } - const initialState: State = { ids: [], byId: {} @@ -69,6 +68,7 @@ const usersSlice = createSlice({ ? "" : `${BASE_URL}/resource/avatar?uid=${uid}&t=${rest.avatar_updated_at}`, create_by: "", // todo: missing properties create_by + status: state.byId[uid]?.status ?? "", ...rest }; const idx = state.ids.findIndex((i) => i == uid); @@ -98,9 +98,17 @@ const usersSlice = createSlice({ state.byId[uid]!.online = online; } }); + }, + updateContactStatus(state, action: PayloadAction<{ uid: number, status: ContactStatus } | { uid: number, status: ContactStatus }[]>) { + const arr = Array.isArray(action.payload) ? action.payload : [action.payload]; + arr.forEach((data) => { + if (state.byId[data.uid]) { + state.byId[data.uid]!.status = data.status; + } + }); } } }); -export const { resetUsers, fillUsers, updateUsersByLogs, updateUsersStatus } = usersSlice.actions; +export const { updateContactStatus, resetUsers, fillUsers, updateUsersByLogs, updateUsersStatus } = usersSlice.actions; export default usersSlice.reducer; diff --git a/src/assets/icons/block.svg b/src/assets/icons/block.svg new file mode 100644 index 00000000..14a3ba37 --- /dev/null +++ b/src/assets/icons/block.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/info.svg b/src/assets/icons/info.svg index 2b75d1a4..fb567b59 100644 --- a/src/assets/icons/info.svg +++ b/src/assets/icons/info.svg @@ -1,4 +1,4 @@ - + diff --git a/src/common/component/AddEntriesMenu.tsx b/src/common/component/AddEntriesMenu.tsx index 63b106dc..2fa3deca 100644 --- a/src/common/component/AddEntriesMenu.tsx +++ b/src/common/component/AddEntriesMenu.tsx @@ -3,18 +3,21 @@ import { hideAll } from "tippy.js"; import { useAppSelector } from "../../app/store"; import IconInvite from "../../assets/icons/add.person.svg"; import IconMention from "../../assets/icons/mention.svg"; +import IconSearch from "../../assets/icons/search.svg"; import ChannelIcon from "./ChannelIcon"; import ChannelModal from "./ChannelModal"; import UsersModal from "./UsersModal"; import InviteModal from "./InviteModal"; import { useTranslation } from "react-i18next"; +import SearchUser from "./SearchUser"; +import ServerVersionChecker from "./ServerVersionChecker"; export default function AddEntriesMenu() { const { t } = useTranslation(); const currentUser = useAppSelector((store) => store.authData.user); const [isPrivate, setIsPrivate] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false); - + const [searchModalVisible, setSearchModalVisible] = useState(false); const [channelModalVisible, setChannelModalVisible] = useState(false); const [usersModalVisible, setUsersModalVisible] = useState(false); const toggleInviteModalVisible = () => { @@ -25,6 +28,14 @@ export default function AddEntriesMenu() { return !prev; }); }; + const toggleSearchModalVisible = () => { + setSearchModalVisible((prevVisible) => { + if (!prevVisible) { + hideAll(); + } + return !prevVisible; + }); + }; const toggleUsersModalVisible = () => { setUsersModalVisible((prevVisible) => { if (!prevVisible) { @@ -66,10 +77,17 @@ export default function AddEntriesMenu() { {t("action.invite_people")} + +
  • + + {t("action.search_people")} +
  • + {channelModalVisible && } {usersModalVisible && } {inviteModalVisible && } + {searchModalVisible && } ); } diff --git a/src/common/component/Message/Commands.tsx b/src/common/component/Message/Commands.tsx index aeaa5f45..824d3311 100644 --- a/src/common/component/Message/Commands.tsx +++ b/src/common/component/Message/Commands.tsx @@ -22,12 +22,13 @@ import useMessageOperation from "./useMessageOperation"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; type Props = { + isSelf: boolean; context: "user" | "channel"; contextId: number; mid: number; toggleEditMessage: () => void; }; -const Commands: FC = ({ context = "user", contextId = 0, mid = 0, toggleEditMessage }) => { +const Commands: FC = ({ isSelf, context = "user", contextId = 0, mid = 0, toggleEditMessage }) => { const { t } = useTranslation(); const { canDelete, @@ -87,7 +88,10 @@ const Commands: FC = ({ context = "user", contextId = 0, mid = 0, toggleE <>
      + className={clsx(`bg-white dark:bg-gray-900 rounded-md z-[999] absolute top-0 -translate-y-1/2 flex items-center border border-solid border-black/10 invisible group-hover:visible`, + tippyVisible && '!visible', + isSelf ? "left-2.5" : "right-2.5" + )}> = ({ const [edit, setEdit] = useState(false); const avatarRef = useRef(null); const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0); - const { message, reactionMessageData, usersData } = useAppSelector((store) => { + const { message, reactionMessageData, usersData, loginUid } = useAppSelector((store) => { return { reactionMessageData: store.reactionMessage, message: store.message[mid], - usersData: store.users.byId + usersData: store.users.byId, + loginUid: store.authData.user?.uid }; }); @@ -75,7 +77,9 @@ const Message: FC = ({ content_type = "text/plain", edited, properties, - expires_in = 0 + expires_in = 0, + failed = false + } = message; @@ -90,6 +94,7 @@ const Message: FC = ({ // return null; const _key = properties?.local_id || mid; const showExpire = (expires_in ?? 0) > 0; + const isSelf = fromUid == loginUid; return (
      = ({ className={clsx(`group w-full relative flex items-start gap-2 md:gap-4 p-1 md:p-2 my-2 rounded-lg md:dark:hover:bg-gray-800 md:hover:bg-gray-100`, readOnly && "hover:bg-transparent", showExpire && "bg-red-200 dark:bg-red-200/40", - pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7" + pinInfo && "bg-cyan-50 dark:bg-cyan-800 pt-7", + isSelf && "flex-row-reverse", )} > = ({ context={context} contextId={contextId} mid={mid} - visible={contextMenuVisible} + visible={contextMenuVisible && !failed} hide={hideContextMenu} >
      {pinInfo && {`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`} } -
      +
      {currUser?.name || "Deleted User"} = ({ : dayjsTime.format("YYYY-MM-DD h:mm:ss A")} + {failed && + Send Failed + }
      = ({ createAt={time ?? 0} /> )} - {!edit && !readOnly && ( + {!edit && !failed && !readOnly && ( = ({ @@ -45,6 +46,7 @@ const Plugins: FC = ({ id, placeholder = "Write some markdown...", sendMessages, + updateMessages, members = [] }) => { @@ -53,7 +55,6 @@ const Plugins: FC = ({ const { addStageFile } = useUploadFile({ context, id: to }); const enableMentions = members.length > 0; const userData = useAppSelector((store) => store.users.byId); - const [msgs, setMsgs] = useState([]); const editableRef = useRef(null); const initialProps = { ...CONFIG.editableProps, @@ -94,23 +95,13 @@ const Plugins: FC = ({ } evt.preventDefault(); // return true; - handleSend(); + sendMessages(); }, { when: !isMobile(), target: editableRef, } ); - const handleSend = () => { - sendMessages(msgs); - // 清空 - Transforms.delete(plateEditor, { - at: { - anchor: Editor.start(plateEditor, []), - focus: Editor.end(plateEditor, []) - } - }); - }; const pluginArr = [ createParagraphPlugin(), createNodeIdPlugin(), @@ -142,55 +133,52 @@ const Plugins: FC = ({ } ); - const handleChange = useCallback( - async (val: any) => { - // console.log("tmps changed", val); - const tmps = []; - const getMixedText = (children: any) => { - const mentions: any = []; - const arr = children.map(({ type, text, uid }: any) => { - if (type == "mention") { - mentions.push(uid); - return ` @${uid} `; - } - return text; - }); - return { value: arr.join(""), mentions }; - }; - for (const v of val) { - // p - const { value, mentions } = getMixedText(v.children); - const prev = tmps[tmps.length - 1]; - if (!prev) { - tmps.push([{ type: "text", content: value, properties: { mentions } }]); + const handleChange = (val: any) => { + // console.log("tmps changed", val); + const tmps = []; + const getMixedText = (children: any) => { + const mentions: any = []; + const arr = children.map(({ type, text, uid }: any) => { + if (type == "mention") { + mentions.push(uid); + return ` @${uid} `; + } + return text; + }); + return { value: arr.join(""), mentions }; + }; + for (const v of val) { + // p + const { value, mentions } = getMixedText(v.children); + const prev = tmps[tmps.length - 1]; + if (!prev) { + tmps.push([{ type: "text", content: value, properties: { mentions } }]); + } else { + if (Array.isArray(prev)) { + tmps[tmps.length - 1].push({ + type: "text", + content: value, + properties: { mentions } + }); } else { - if (Array.isArray(prev)) { - tmps[tmps.length - 1].push({ - type: "text", - content: value, - properties: { mentions } - }); - } else { - tmps.push([{ type: "text", content: value, properties: { mentions } }]); - } + tmps.push([{ type: "text", content: value, properties: { mentions } }]); } } - const arr = tmps.map((tmp) => { - return Array.isArray(tmp) - ? { - type: "text", - content: tmp.map((t) => t.content).join("\n"), - properties: { - mentions: tmp.map((t) => t.properties?.mentions || []).flat() - } + } + const arr = tmps.map((tmp) => { + return Array.isArray(tmp) + ? { + type: "text", + content: tmp.map((t) => t.content).join("\n"), + properties: { + mentions: tmp.map((t) => t.properties?.mentions || []).flat() } - : tmp; - }); - const msgs = arr.filter(({ content }) => !!content); - setMsgs(msgs); - }, - [msgs] - ); + } + : tmp; + }); + const msgs = arr.filter(({ content }) => !!content.trim()); + updateMessages(msgs); + }; return ( @@ -245,7 +233,18 @@ export const useMixedEditor = (key: string) => { editorRef.insertText(txt); } }; + const resetInput = () => { + if (!editorRef) return; + // 清空 + Transforms.delete(editorRef, { + at: { + anchor: Editor.start(editorRef, []), + focus: Editor.end(editorRef, []) + } + }); + }; return { + resetInput, focus, insertText }; diff --git a/src/common/component/RequireSingleTab.tsx b/src/common/component/RequireSingleTab.tsx new file mode 100644 index 00000000..dc30b160 --- /dev/null +++ b/src/common/component/RequireSingleTab.tsx @@ -0,0 +1,14 @@ +import { FC, ReactElement } from "react"; +import useTabBroadcast from "../hook/useTabBroadcast"; +import InactiveScreen from "./InactiveScreen"; + +interface Props { + children: ReactElement; +} +const RequireSingleTab: FC = ({ children }) => { + const { tabActive } = useTabBroadcast(); + // console.log("authhhhh", allowGuest, token, guest); + return tabActive ? children : ; +}; + +export default RequireSingleTab; diff --git a/src/common/component/SearchUser.tsx b/src/common/component/SearchUser.tsx new file mode 100644 index 00000000..29ab30e1 --- /dev/null +++ b/src/common/component/SearchUser.tsx @@ -0,0 +1,108 @@ +// import Tippy from "@tippyjs/react"; +import clsx from "clsx"; +import { FC, ChangeEvent, useState, useRef, FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import Modal from "./Modal"; +import IconClose from "../../assets/icons/close.svg"; +import IconSearch from "../../assets/icons/search.svg"; + +import { useSearchUserMutation, useUpdateContactStatusMutation } from "../../app/services/user"; +import StyledButton from "./styled/Button"; +import Input from "./styled/Input"; +import Avatar from "./Avatar"; +import BASE_URL from "../../app/config"; +import { useAppSelector } from "../../app/store"; +import { useNavigate } from "react-router-dom"; +type Props = { + closeModal: () => void; +}; +type Type = "id" | "email"; +const SearchUser: FC = ({ closeModal }) => { + const [updateContactStatus, { isLoading: adding }] = useUpdateContactStatusMutation(); + const usersData = useAppSelector(store => store.users.byId); + const { t } = useTranslation(); + const navigateTo = useNavigate(); + const inputRef = useRef(null); + const [type, setType] = useState("id"); + const [input, setInput] = useState({ + id: "", + email: "" + }); + const [searchUser, { data, isSuccess, isLoading, reset }] = useSearchUserMutation(); + const handleInput = (evt: ChangeEvent) => { + const tmp = { + [type]: evt.target.value + }; + setInput(prev => ({ ...prev, ...tmp })); + }; + const resetInput = () => { + reset(); + setInput(prev => ({ ...prev, [type]: "" })); + }; + const handleSubmit = (evt: FormEvent) => { + evt.preventDefault(); + if (!inputRef.current) return; + const form = inputRef.current as HTMLFormElement; + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + const currInput = input[type]; + // if (!currInput) return; + // return true; + searchUser({ + search_type: type, + keyword: currInput + }); + }; + + const handleChangeKeyword = (type: Type) => { + setType(type); + }; + const handleChat = async (directChat: boolean) => { + if (!data) return; + if (!directChat) { + await updateContactStatus({ target_uid: data.uid, action: "add" }); + } + closeModal(); + navigateTo(`/chat/dm/${data.uid}`); + }; + // const tmpData = { "avatar_updated_at": 0, "birthday": 0, "create_by": "password", "email": "tristan@alex.com", "gender": 0, "is_admin": true, "is_bot": false, "language": "en-US", "name": "Admin", "uid": 1 }; + // const showResult = isSuccess && data; + const inContact = Boolean(data && usersData[data.uid] && usersData[data.uid].status == "added"); + const inputType = type == "id" ? "number" : "email"; + return ( + +
      +
      + + {t("search_by_id", { ns: "member" })} + + + {t("search_by_email", { ns: "member" })} + +
      +
      + } value={input[type]} placeholder={`${t("action.search")}...`} onChange={handleInput} /> +
      +
      + {isSuccess ? (data ?
      + + {data.name} +
      + {t("action.cancel")} + {inContact ? t(`chat`) : `Add to Contact`} +
      +
      :
      + + {t("search_not_found", { ns: "member" })} + + Ok +
      ) : null} +
      + +
      +
      + ); +}; +export default SearchUser; diff --git a/src/common/component/Send/Toolbar.tsx b/src/common/component/Send/Toolbar.tsx index d542b48e..e437e6fb 100644 --- a/src/common/component/Send/Toolbar.tsx +++ b/src/common/component/Send/Toolbar.tsx @@ -2,20 +2,25 @@ import { ChangeEvent, useRef, FC } from "react"; import Tooltip from "../Tooltip"; import AddIcon from "../../../assets/icons/add.solid.svg"; import MarkdownIcon from "../../../assets/icons/markdown.svg"; +import SendIcon from "../../../assets/icons/send.svg"; import FullscreenIcon from "../../../assets/icons/fullscreen.svg"; import ExitFullscreenIcon from "../../../assets/icons/fullscreen.exit.svg"; import useUploadFile from "../../hook/useUploadFile"; import { useTranslation } from "react-i18next"; type Props = { + sendMessages: () => void; toggleMarkdownFullscreen: () => void; fullscreen: boolean; toggleMode: () => void; mode: "markdown" | "text"; to: number; context: "user" | "channel"; + sendVisible: boolean }; const Toolbar: FC = ({ + sendMessages, + sendVisible, toggleMarkdownFullscreen, fullscreen, toggleMode, @@ -61,7 +66,7 @@ const Toolbar: FC = ({ ))}
      - {!isMarkdown && + {!isMarkdown && <>
      -
      } +
      + {sendVisible && + + } + }
      ); }; diff --git a/src/common/component/Send/index.tsx b/src/common/component/Send/index.tsx index f4dbb68d..986caba9 100644 --- a/src/common/component/Send/index.tsx +++ b/src/common/component/Send/index.tsx @@ -18,6 +18,8 @@ import useDraft from "../../hook/useDraft"; import useUploadFile from "../../hook/useUploadFile"; import { useAppDispatch, useAppSelector } from "../../../app/store"; import TextInput from "../TextInput"; +import useUserOperation from "../../hook/useUserOperation"; +import StyledButton from "../styled/Button"; const Modes = { text: "text", @@ -33,9 +35,11 @@ const Send: FC = ({ id }) => { const { t } = useTranslation("chat"); + const { unblockThisContact, blocked } = useUserOperation({ uid: context == "user" ? id : undefined, cid: context == "channel" ? id : undefined }); const { resetStageFiles } = useUploadFile({ context, id }); const { getDraft, getUpdateDraft } = useDraft({ context, id }); const editor = useMixedEditor(`${context}_${id}`); + const [msgs, setMsgs] = useState([]); const [markdownEditor, setMarkdownEditor] = useState(null); const [markdownFullscreen, setMarkdownFullscreen] = useState(false); const dispatch = useAppDispatch(); @@ -57,7 +61,7 @@ const Send: FC = ({ mode: store.ui.inputMode, from_uid: store.authData.user?.uid, replying_mid: store.message.replying[`${context}_${id}`], - uploadFiles: store.ui.uploadFiles[`${context}_${id}`] + uploadFiles: store.ui.uploadFiles[`${context}_${id}`] ?? [] }; }); const { sendMessage } = useSendMessage({ context, from: from_uid, to: id }); @@ -76,12 +80,14 @@ const Send: FC = ({ editor.insertText(emoji); } }; - const handleSendMessage = async (msgs = []) => { + const handleSendMessage = async () => { if (!id) return; + editor.resetInput(); if (msgs && msgs.length) { // send text msgs for await (const msg of msgs) { const { type: content_type, content, properties = {} } = msg; + if ((content as string).trim() === '') continue; // 空消息不发送 properties.local_id = properties.local_id ?? +new Date(); await sendMessage({ id, @@ -94,7 +100,7 @@ const Send: FC = ({ } } // send files - if (uploadFiles && uploadFiles.length !== 0) { + if (uploadFiles.length !== 0) { uploadFiles.forEach((fileInfo) => { const ts = +new Date(); const { url, name, size, type } = fileInfo; @@ -138,6 +144,12 @@ const Send: FC = ({ const members = context == "channel" ? (channelsData[id]?.is_public ? uids : channelsData[id]?.members) : []; const isMarkdownMode = mode == Modes.markdown; + if (context == "user" && blocked) { + return
      + {t("contact_block_tip")} + {t("unblock")} +
      ; + } return ( <> {/* mobile input */} @@ -154,6 +166,7 @@ const Send: FC = ({ {mode == Modes.text && ( = ({ /> )} 0 || uploadFiles.length > 0} context={context} to={id} mode={mode} diff --git a/src/common/component/User/ContextMenu.tsx b/src/common/component/User/ContextMenu.tsx index 8224cabc..874b1bfe 100644 --- a/src/common/component/User/ContextMenu.tsx +++ b/src/common/component/User/ContextMenu.tsx @@ -15,11 +15,16 @@ interface Props { const UserContextMenu: FC = ({ enable = false, uid, cid, visible, hide, children }) => { const { t } = useTranslation("member"); + const { t: chatTran } = useTranslation("chat"); const { + blockThisContact, + removeFromContact, copyEmail, canCopyEmail, startChat, canRemove, + canRemoveFromContact, + canBlock, canRemoveFromChannel, removeFromChannel, removeUser @@ -55,11 +60,22 @@ const UserContextMenu: FC = ({ enable = false, uid, cid, visible, hide, c title: t("remove_from_channel"), handler: removeFromChannel }, + canRemoveFromContact && { + danger: true, + title: t("remove_from_contact"), + handler: removeFromContact + }, + canBlock && { + danger: true, + title: chatTran("block"), + handler: blockThisContact + }, canRemove && { danger: true, title: t("remove"), handler: removeUser } + ].filter(Boolean) as Item[] } /> diff --git a/src/common/component/UsersModal.tsx b/src/common/component/UsersModal.tsx index 264bed89..cca24611 100644 --- a/src/common/component/UsersModal.tsx +++ b/src/common/component/UsersModal.tsx @@ -26,7 +26,7 @@ const UsersModal: FC = ({ closeModal }) => {
      - +
      {users && (
        diff --git a/src/common/component/Version.tsx b/src/common/component/Version.tsx index 8364bfa7..f9b0792d 100644 --- a/src/common/component/Version.tsx +++ b/src/common/component/Version.tsx @@ -23,11 +23,6 @@ const Version: FC = () => {
      • {t("client_version")}: {process.env.VERSION}
      • {t("server_version")}: {serverVersion}
      • {t("build_time")}: {ts} ({dayjs(ts * 1000).fromNow()})
      • -
      • GitHub:  - - vocechat-web/issues - -
      • diff --git a/src/common/component/VoiceMessage.tsx b/src/common/component/VoiceMessage.tsx index 95affcf9..d55442c2 100644 --- a/src/common/component/VoiceMessage.tsx +++ b/src/common/component/VoiceMessage.tsx @@ -38,6 +38,7 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => { hideScrollbar: true, // mediaControls: true, normalize: true, + responsive: true }); wave.load(audioSrc); @@ -64,6 +65,12 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => { const durDisplay = dayjs.duration(num, 'seconds').format('mm:ss'); setDuration(durDisplay); } + // tricky + if (containerRef.current) { + const container = containerRef.current as HTMLDivElement; + const w = (container.querySelector("wave>canvas") as HTMLCanvasElement)?.width; + container.style.width = `${w}px`; + } }); VoiceMap[file_path] = wave; }; @@ -103,11 +110,11 @@ const VoiceMessage = ({ file_path }: { file_path: string }) => { }; const notReady = status !== "ready"; return ( -
        +
        -
        +
        {status == "loading" && Loading voice message...} {status == "error" && Load voice message error}
        diff --git a/src/common/component/styled/Input.tsx b/src/common/component/styled/Input.tsx index 1ff79147..3453cf4d 100644 --- a/src/common/component/styled/Input.tsx +++ b/src/common/component/styled/Input.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useState, FC, DetailedHTMLProps, InputHTMLAttributes } from "react"; +import { useState, FC, DetailedHTMLProps, InputHTMLAttributes, ReactElement } from "react"; import IconEyeOpen from "../../../assets/icons/eye.open.svg"; import IconEyeClose from "../../../assets/icons/eye.close.svg"; interface Props @@ -24,7 +24,7 @@ interface Props >, HTMLInputElement > { - prefix?: string; + prefix?: string | ReactElement; ref?: any; } @@ -46,7 +46,7 @@ const Input: FC = ({ type = "text", prefix = "", className = "", ...rest placeholder:text-gray-400`, // noInner && 'rounded border border-solid border-gray-200 shadow', isLarge && 'py-3', - isNone && "border-none bg-transparent shadow-none", + isNone && "!border-none bg-transparent shadow-none", isPwd && "pr-[30px]" ); return type == "password" ? ( @@ -59,7 +59,7 @@ const Input: FC = ({ type = "text", prefix = "", className = "", ...rest ) : prefix ? (
        {typeof prefix === "string" ? {prefix} : {prefix}} - +
        ) : ( diff --git a/src/common/hook/useFilteredUsers.ts b/src/common/hook/useFilteredUsers.ts index c4abd0f1..1fd804d6 100644 --- a/src/common/hook/useFilteredUsers.ts +++ b/src/common/hook/useFilteredUsers.ts @@ -1,11 +1,19 @@ import { useState, useEffect } from "react"; import { StoredUser } from "../../app/slices/users"; import { useAppSelector } from "../../app/store"; +import { compareVersion } from "../utils"; export default function useFilteredUsers() { const [input, setInput] = useState(""); - const users = useAppSelector((store) => Object.values(store.users.byId)); + const { originUsers, serverVersion } = useAppSelector((store) => { + return { + originUsers: Object.values(store.users.byId), + serverVersion: store.server.version + }; + }); + const enableContact = compareVersion(serverVersion, "0.3.7") >= 0; const [filteredUsers, setFilteredUsers] = useState([]); + const users = enableContact ? originUsers.filter((u) => u.status == "added") : originUsers; useEffect(() => { if (!input) { setFilteredUsers(users); @@ -19,7 +27,7 @@ export default function useFilteredUsers() { }) ); } - }, [input]); + }, [input, users.length]); const updateInput = (val: string) => { setInput(val); diff --git a/src/common/hook/useInviteLink.ts b/src/common/hook/useInviteLink.ts index b72431ed..542bf23a 100644 --- a/src/common/hook/useInviteLink.ts +++ b/src/common/hook/useInviteLink.ts @@ -1,35 +1,42 @@ import { useState, useEffect } from "react"; import useCopy from "./useCopy"; import { useGetSMTPStatusQuery } from "../../app/services/server"; -import { useLazyCreateInviteLinkQuery as useCreateChannelInviteLinkQuery } from "../../app/services/channel"; +import { useLazyCreateInviteLinkQuery as useCreateChannelInviteLinkQuery, useLazyCreatePrivateInviteLinkQuery } from "../../app/services/channel"; +import { useAppSelector } from "../../app/store"; export default function useInviteLink(cid?: number) { const [finalLink, setFinalLink] = useState(""); + const channel = useAppSelector(store => store.channels.byId[cid ?? 0]); const { data: SMTPEnabled, isSuccess: smtpStatusFetchSuccess } = useGetSMTPStatusQuery(); const [generateChannelInviteLink, { data: channelInviteLink, isLoading: generatingChannelLink }] = useCreateChannelInviteLinkQuery(); + const [generatePrivateInviteLink, { data: privateInviteLink, isLoading: generatingPrivateLink }] = useLazyCreatePrivateInviteLinkQuery(); const { copied, copy } = useCopy({ enableToast: false }); const copyLink = () => { copy(finalLink); }; useEffect(() => { - generateChannelInviteLink(cid); - }, [cid]); + if (!cid || channel?.is_public) { + generateChannelInviteLink(cid); + } else { + generatePrivateInviteLink(cid); + } + }, [cid, channel]); useEffect(() => { - const _link = channelInviteLink; + const _link = channelInviteLink || privateInviteLink; if (_link && smtpStatusFetchSuccess) { setFinalLink(_link); } - }, [channelInviteLink, smtpStatusFetchSuccess]); + }, [channelInviteLink, privateInviteLink, smtpStatusFetchSuccess]); const genServerLink = () => { generateChannelInviteLink(); }; - + const generating = generatingPrivateLink || generatingChannelLink; return { enableSMTP: SMTPEnabled, - generating: generatingChannelLink, + generating, generateNewLink: cid ? generateChannelInviteLink.bind(null, cid) : genServerLink, link: finalLink, linkCopied: copied, diff --git a/src/common/hook/usePreload.ts b/src/common/hook/usePreload.ts index c17a8a6f..b4a4e763 100644 --- a/src/common/hook/usePreload.ts +++ b/src/common/hook/usePreload.ts @@ -2,7 +2,7 @@ import { useEffect } from "react"; import dayjs from "dayjs"; import initCache, { useRehydrate } from "../../app/cache"; import { useLazyGetFavoritesQuery } from "../../app/services/message"; -import { useLazyGetUsersQuery } from "../../app/services/user"; +import { useLazyGetContactsQuery, useLazyGetUsersQuery } from "../../app/services/user"; import { useLazyGetSystemCommonQuery, useLazyGetServerQuery, useLazyGetServerVersionQuery } from "../../app/services/server"; import useStreaming from "./useStreaming"; import { useAppSelector } from "../../app/store"; @@ -44,19 +44,21 @@ export default function usePreload() { data: favorites } ] = useLazyGetFavoritesQuery(); + const [getUsers, { isLoading: usersLoading, isSuccess: usersSuccess, isError: usersError, data: users }] = useLazyGetUsersQuery(); const [ - getUsers, - { isLoading: usersLoading, isSuccess: usersSuccess, isError: usersError, data: users } - ] = useLazyGetUsersQuery(); + getContacts, + { isLoading: contactsLoading, isSuccess: contactsSuccess, isError: contactsError, data: contacts } + ] = useLazyGetContactsQuery(); const [ getServer, { isLoading: serverLoading, isSuccess: serverSuccess, isError: serverError, data: server } ] = useLazyGetServerQuery(); - const [getServerVersion, { isSuccess: serverVersionSuccess, isLoading: loadingServerVersion }] = useLazyGetServerVersionQuery(); + const [getServerVersion, { data: serverVersion, isSuccess: serverVersionSuccess, isLoading: loadingServerVersion }] = useLazyGetServerVersionQuery(); const [getSystemCommon] = useLazyGetSystemCommonQuery(); useEffect(() => { initCache(); rehydrate(); + getServerVersion(); return () => { setStreamingReady(false); }; @@ -75,18 +77,21 @@ export default function usePreload() { } }, [channelIds, channelMessageData, isGuest]); useEffect(() => { - if (rehydrated) { - getUsers(); - getServer(); - getFavorites(); - getServerVersion().then((resp) => { - if (resp.data && compareVersion(resp.data, '0.3.4') >= 0) { - // 根据版本号判断是否需要调用system common api - getSystemCommon(); + if (rehydrated && serverVersion) { + getUsers().then(() => { + if (compareVersion(serverVersion, '0.3.7') >= 0) { + // 根据版本号判断是否需要初始化contact + getContacts(); } }); + getServer(); + getFavorites(); + if (compareVersion(serverVersion, '0.3.4') >= 0) { + // 根据版本号判断是否需要调用system common api + getSystemCommon(); + } } - }, [rehydrated]); + }, [rehydrated, serverVersion]); const tokenAlmostExpire = dayjs().isAfter(new Date(expireTime - 20 * 1000)); const canStreaming = !!loginUid && rehydrated && !!token && !tokenAlmostExpire; // console.log("ttt", loginUid, rehydrated, token); @@ -94,13 +99,13 @@ export default function usePreload() { useEffect(() => { setStreamingReady(canStreaming); }, [canStreaming]); - + const enableContact = serverVersion ? compareVersion(serverVersion, '0.3.7') >= 0 : false; return { loading: usersLoading || serverLoading || favoritesLoading || !rehydrated || loadingLicense || loadingServerVersion, error: usersError && serverError && favoritesError, success: usersSuccess && serverSuccess && favoritesSuccess && serverVersionSuccess, data: { - users, + users: enableContact ? contacts : users, server, favorites } diff --git a/src/common/hook/useStreaming/index.ts b/src/common/hook/useStreaming/index.ts index 53e92d74..32652aa6 100644 --- a/src/common/hook/useStreaming/index.ts +++ b/src/common/hook/useStreaming/index.ts @@ -17,13 +17,15 @@ import { updateReadChannels, updateReadUsers, updateMute, - updateAutoDeleteSetting + updateAutoDeleteSetting, + upsertPinChats, + removePinChats } from "../../../app/slices/footprint"; -import { updateUsersByLogs, updateUsersStatus } from "../../../app/slices/users"; +import { updateContactStatus, updateUsersByLogs, updateUsersStatus } from "../../../app/slices/users"; import { resetAuthData, updateLoginUser, updateRoleChanged } from "../../../app/slices/auth.data"; import chatMessageHandler from "./chat.handler"; import { useAppDispatch, useAppSelector } from "../../../app/store"; -import { ServerEvent, UsersStateEvent } from "../../../types/sse"; +import { ServerEvent, UserSettingsChangedEvent, UsersStateEvent } from "../../../types/sse"; import { useRenewMutation } from "../../../app/services/auth"; import { getLocalAuthData } from "../../utils"; import { removeUserSession } from "../../../app/slices/message.user"; @@ -149,6 +151,11 @@ export default function useStreaming() { dispatch(clearChannelMessage(gid)); break; } + case "pinned_chats": { + const { chats } = data; + dispatch(upsertPinChats({ pins: chats, override: true })); + break; + } case "users_snapshot": { const { version } = data; dispatch(updateUsersVersion(version)); @@ -187,6 +194,20 @@ export default function useStreaming() { case "read_index_users": dispatch(updateReadUsers(data[key])); break; + case "add_pin_chats": { + const pins = (data as UserSettingsChangedEvent).add_pin_chats ?? []; + if (pins.length) { + dispatch(upsertPinChats({ pins })); + } + } + break; + case "remove_pin_chats": { + const pins = (data as UserSettingsChangedEvent).remove_pin_chats ?? []; + if (pins.length) { + dispatch(removePinChats(pins)); + } + } + break; case "add_mute_users": case "mute_users": case "add_mute_groups": @@ -199,6 +220,31 @@ export default function useStreaming() { } } break; + case "remove_contacts": { + const arr = (data as UserSettingsChangedEvent).remove_contacts ?? []; + if (arr.length) { + dispatch(updateContactStatus( + arr.map( + (uid: number) => { + return { uid, status: "" }; + } + ) + )); + } + } + break; + case "add_contacts": { + const arr = (data as UserSettingsChangedEvent).add_contacts ?? []; + if (arr.length) { + const transformed = arr.map( + ({ target_uid, info: { status } }) => { + return { uid: target_uid, status }; + } + ); + dispatch(updateContactStatus(transformed)); + } + } + break; case "remove_mute_users": case "remove_mute_groups": { diff --git a/src/common/hook/useUserOperation.ts b/src/common/hook/useUserOperation.ts index 5646dd61..9312d7ce 100644 --- a/src/common/hook/useUserOperation.ts +++ b/src/common/hook/useUserOperation.ts @@ -4,7 +4,7 @@ import toast from "react-hot-toast"; import { useNavigate, useMatch } from "react-router-dom"; import { hideAll } from "tippy.js"; import { useRemoveMembersMutation } from "../../app/services/channel"; -import { useLazyDeleteUserQuery } from "../../app/services/user"; +import { useLazyDeleteUserQuery, useUpdateContactStatusMutation } from "../../app/services/user"; // import useConfig from "./useConfig"; import useCopy from "./useCopy"; import { useAppSelector } from "../../app/store"; @@ -18,6 +18,7 @@ const useUserOperation = ({ uid, cid }: IProps) => { const { t: ct } = useTranslation(); const [passedUid, setPassedUid] = useState(undefined); const isUserDetailPath = useMatch(`/users/${uid}`); + const [updateContactStatus] = useUpdateContactStatusMutation(); const [removeUser, { isSuccess: removeUserSuccess }] = useLazyDeleteUserQuery(); const [removeInChannel, { isSuccess: removeSuccess }] = useRemoveMembersMutation(); const navigateTo = useNavigate(); @@ -71,6 +72,19 @@ const useUserOperation = ({ uid, cid }: IProps) => { const startChat = () => { navigateTo(`/chat/dm/${uid}`); }; + const removeFromContact = () => { + if (uid) { + updateContactStatus({ target_uid: uid, action: "remove" }); + } + }; + const blockThisContact = () => { + if (uid === undefined) return; + updateContactStatus({ target_uid: uid, action: "block" }); + }; + const unblockThisContact = () => { + if (uid === undefined) return; + updateContactStatus({ target_uid: uid, action: "unblock" }); + }; const isAdmin = !!loginUser?.is_admin; const loginUid = loginUser?.uid; @@ -81,8 +95,16 @@ const useUserOperation = ({ uid, cid }: IProps) => { (isAdmin || channel?.owner == loginUid) && uid != channel?.owner; const canRemove: boolean = isAdmin && loginUid != uid && !cid && uid !== 1; + const canBlock: boolean = loginUid != uid; + const canRemoveFromContact: boolean = loginUid != uid; const canInviteChannel = !!cid && (loginUser?.is_admin || channel?.owner == loginUser?.uid); return { + removeFromContact, + canBlock, + canRemoveFromContact, + blocked: user?.status == "blocked", + blockThisContact, + unblockThisContact, canInviteChannel, canDeleteChannel, canRemove, diff --git a/src/routes/chat/Layout/AddContactTip.tsx b/src/routes/chat/Layout/AddContactTip.tsx new file mode 100644 index 00000000..e1767840 --- /dev/null +++ b/src/routes/chat/Layout/AddContactTip.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next'; +// import React from 'react'; +import IconAdd from '../../../assets/icons/add.person.svg'; +import IconBlock from '../../../assets/icons/block.svg'; +import { useAppSelector } from '../../../app/store'; +import { useUpdateContactStatusMutation } from '../../../app/services/user'; +import { ContactAction } from '../../../types/user'; +import ServerVersionChecker from '../../../common/component/ServerVersionChecker'; +// import toast from 'react-hot-toast'; + +type Props = { + uid: number +} + +const AddContactTip = (props: Props) => { + const { t } = useTranslation("chat"); + const [updateContactStatus] = useUpdateContactStatusMutation(); + const targetUser = useAppSelector(store => store.users.byId[props.uid]); + const handleContactStatus = (action: ContactAction) => { + updateContactStatus({ target_uid: props.uid, action }); + }; + // useEffect(() => { + // if(isSuccess){ + // toast.success(t("tip")) + // } + // }, [isSuccess]) + + const itemClass = `cursor-pointer flex flex-col items-center gap-1 rounded-lg w-32 text-primary-400 bg-gray-50 dark:bg-gray-800 text-sm pt-3.5 pb-3`; + if (!targetUser) return null; + if (targetUser.status == "added") return null; + const blocked = targetUser.status == "blocked"; + return ( + +
        +

        {blocked ? t("contact_block_tip") : t("contact_tip")}

        +
          + {!blocked &&
        • + + {t("add_contact")} +
        • } +
        • + + {blocked ? t("unblock") : t("block")} +
        • +
        +
        +
        + ); +}; + +export default AddContactTip; \ No newline at end of file diff --git a/src/routes/chat/Layout/index.tsx b/src/routes/chat/Layout/index.tsx index 8ca929f3..4e5511cf 100644 --- a/src/routes/chat/Layout/index.tsx +++ b/src/routes/chat/Layout/index.tsx @@ -18,6 +18,7 @@ import ImagePreview from "../../../common/component/ImagePreview"; import VirtualMessageFeed from "./VirtualMessageFeed"; import DMVoice from "./DMVoicing"; +import AddContactTip from "./AddContactTip"; interface Props { readonly?: boolean; @@ -100,6 +101,7 @@ const Layout: FC = ({
        {context == "user" && } + {context == "user" && } {/* 消息流 */} {/* 发送框 */} diff --git a/src/routes/chat/SessionList/ContextMenu.tsx b/src/routes/chat/SessionList/ContextMenu.tsx index b9320f41..f39e37f5 100644 --- a/src/routes/chat/SessionList/ContextMenu.tsx +++ b/src/routes/chat/SessionList/ContextMenu.tsx @@ -2,7 +2,7 @@ import { FC, ReactElement } from "react"; import Tippy from "@tippyjs/react"; import { useDispatch } from "react-redux"; import { useNavigate, useLocation, useMatch } from "react-router-dom"; -import { useUpdateMuteSettingMutation } from "../../../app/services/user"; +import { usePinChatMutation, useUnpinChatMutation, useUpdateMuteSettingMutation } from "../../../app/services/user"; import { useReadMessageMutation } from "../../../app/services/message"; import { removeUserSession } from "../../../app/slices/message.user"; import ContextMenu, { Item } from "../../../common/component/ContextMenu"; @@ -10,8 +10,10 @@ import useUserOperation from "../../../common/hook/useUserOperation"; import { useAppSelector } from "../../../app/store"; import { useTranslation } from "react-i18next"; import { ChatContext } from "../../../types/common"; +import { compareVersion } from "../../../common/utils"; type Props = { context: ChatContext; + pinned: boolean; id: number; visible: boolean; mid: number; @@ -21,6 +23,7 @@ type Props = { children: ReactElement; }; const SessionContextMenu: FC = ({ + pinned, context = "dm", id, visible, @@ -36,13 +39,16 @@ const SessionContextMenu: FC = ({ cid: context == "channel" ? id : undefined }); const [muteChannel] = useUpdateMuteSettingMutation(); + const [pinChat] = usePinChatMutation(); + const [unpinChat] = useUnpinChatMutation(); const [updateReadIndex] = useReadMessageMutation(); const pathMatched = useMatch(`/chat/dm/${id}`); const dispatch = useDispatch(); const navigateTo = useNavigate(); const { pathname } = useLocation(); - const { channelMuted } = useAppSelector((store) => { + const { channelMuted, serverVersion } = useAppSelector((store) => { return { + serverVersion: store.server.version, channelMuted: context == "channel" ? store.footprint.muteChannels[id] : false }; }); @@ -70,13 +76,27 @@ const SessionContextMenu: FC = ({ const data = channelMuted ? { remove_groups: [id] } : { add_groups: [{ gid: id }] }; muteChannel(data); }; + const handlePinChat = () => { + const data = context == "dm" ? { uid: id } : { gid: id }; + if (pinned) { + unpinChat(data); + } else { + pinChat(data); + } + }; const handleDMSetting = () => { navigateTo(`/setting/dm/${id}/overview?f=${pathname}`); }; + const pinTxt = t(pinned ? "unpin_chat" : "pin_chat", { ns: "chat" }); + const pinVisible = compareVersion(serverVersion, "0.3.7") >= 0; const items = context == "dm" ? [ + pinVisible && { + title: pinTxt, + handler: handlePinChat + }, { title: t("action.mark_read"), handler: handleReadAll @@ -96,6 +116,10 @@ const SessionContextMenu: FC = ({ } ] : [ + pinVisible && { + title: pinTxt, + handler: handlePinChat + }, { title: t("setting"), underline: true, diff --git a/src/routes/chat/SessionList/Session.tsx b/src/routes/chat/SessionList/Session.tsx index f9c263c1..810dd5c6 100644 --- a/src/routes/chat/SessionList/Session.tsx +++ b/src/routes/chat/SessionList/Session.tsx @@ -21,11 +21,13 @@ interface IProps { type?: ChatContext; id: number; mid: number; + pinned?: boolean; setDeleteChannelId: (param: number) => void; setInviteChannelId: (param: number) => void; } const Session: FC = ({ type = "dm", + pinned = false, id, mid, setDeleteChannelId, @@ -85,7 +87,7 @@ const Session: FC = ({ useEffect(() => { const tmp = type == "dm" ? userData[id] : channelData[id]; if (!tmp) return; - if ("avatar" in tmp) { + if (type == "dm") { // user const { name, avatar } = tmp; setData({ name, icon: avatar, mid, is_public: true }); @@ -110,13 +112,14 @@ const Session: FC = ({ console.log("unreads", unreads, isCurrentPath); return ( -
      • +
      • diff --git a/src/routes/chat/SessionList/index.tsx b/src/routes/chat/SessionList/index.tsx index cf312a1a..098f7f9a 100644 --- a/src/routes/chat/SessionList/index.tsx +++ b/src/routes/chat/SessionList/index.tsx @@ -6,6 +6,7 @@ import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal"; import InviteModal from "../../../common/component/InviteModal"; import { useAppSelector } from "../../../app/store"; import { ChatContext } from "../../../types/common"; +import { PinChatTargetChannel, PinChatTargetUser } from "../../../types/sse"; export interface ChatSession { type: ChatContext; id: number; @@ -20,9 +21,11 @@ const SessionList: FC = ({ tempSession }) => { const [deleteId, setDeleteId] = useState(); const [inviteChannelId, setInviteChannelId] = useState(); const [sessions, setSessions] = useState([]); - const { channelIDs, DMs, readChannels, readUsers, channelMessage, userMessage, loginUid } = + const [pinSessions, setPinSessions] = useState([]); + const { pins, channelIDs, DMs, readChannels, readUsers, channelMessage, userMessage, loginUid } = useAppSelector((store) => { return { + pins: store.footprint.pinChats, loginUid: store.authData.user?.uid, channelIDs: store.channels.ids, DMs: store.userMessage.ids, @@ -34,24 +37,49 @@ const SessionList: FC = ({ tempSession }) => { }); useEffect(() => { - const cSessions = channelIDs.map((id) => { - const mids = channelMessage[id]; + // const pinDMs= + const getSessionObj = (id: number, type: "dm" | "channel") => { + const mids = type == "dm" ? userMessage[id] : channelMessage[id]; if (!mids || mids.length == 0) { - return { unreads: 0, id, type: "channel" }; + return { unread: 0, id, type } as ChatSession; } // 先转换成数字,再排序 const mid = [...mids].sort((a, b) => +a - +b).pop(); - return { id, mid, type: "channel" }; + return { id, mid, type } as ChatSession; + }; + const pinTmps = pins.map((p) => { + const { target } = p; + if ("uid" in target) { + return getSessionObj((target as PinChatTargetUser).uid, "dm"); + } + if ("gid" in target) { + return getSessionObj((target as PinChatTargetChannel).gid, "channel"); + } + return null; + }).filter((p) => !!p); + const channelPinIds = pins.map(p => { + if (p.target.gid) { + return p.target.gid; + } + return null; + + }).filter(id => !!id); + const dmPinIds = pins.map(p => { + if (p.target.uid) { + return p.target.uid; + } + return null; + + }).filter(id => !!id); + const cSessions = channelIDs.filter(id => { + return !channelPinIds.includes(id); + }).map((id) => { + return getSessionObj(id, "channel"); }); - const uSessions = DMs.map((id) => { - // console.log("adddd", id); - const mids = userMessage[id]; - if (!mids || mids.length == 0) { - return { unreads: 0, id, type: "dm" }; - } - // 先转换成数字,再排序 - const mid = [...mids].sort((a, b) => +a - +b).pop(); - return { type: "dm", id, mid }; + const uSessions = DMs.filter(id => { + return !dmPinIds.includes(id); + }).map((id) => { + return getSessionObj(id, "dm"); }); const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort((a, b) => { const { mid: aMid = 0 } = a; @@ -62,6 +90,7 @@ const SessionList: FC = ({ tempSession }) => { const newSessions = tempSession ? [tempSession, ...temps] : temps; // console.log("qqqq", newSessions); setSessions(newSessions); + setPinSessions(pinTmps); }, [ channelIDs, DMs, @@ -70,11 +99,29 @@ const SessionList: FC = ({ tempSession }) => { readUsers, loginUid, userMessage, - tempSession + tempSession, + pins ]); return ( <> + {pinSessions.length ?
          + {pinSessions.map((p) => { + const { type, id, mid = 0 } = p; + const key = `${type}_${id}`; + return ( + + ); + })} +
        : null}
          {isGuest ? : } - - {isGuest ? : } +
      • {voiceFullscreenVisible && } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 800d5de8..fd12d635 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -32,11 +32,10 @@ import LazyIt from './lazy'; import store, { useAppSelector } from "../app/store"; import useDeviceToken from "../common/component/Notification/useDeviceToken"; import { vapidKey } from "../app/config"; -import useTabBroadcast from "../common/hook/useTabBroadcast"; -import InactiveScreen from "../common/component/InactiveScreen"; +import RequireSingleTab from "../common/component/RequireSingleTab"; +import InvitePrivate from "./invitePrivate"; let toastId: string; const PageRoutes = () => { - const { ui: { online }, fileMessages @@ -59,6 +58,7 @@ const PageRoutes = () => { } /> + } /> } /> } /> { element={ - + {/* 只允许活跃一个tab标签 */} + + + } @@ -199,11 +202,11 @@ const PageRoutes = () => { }; export default function ReduxRoutes() { - const { tabActive } = useTabBroadcast(); + return ( - {tabActive ? : } + ); } diff --git a/src/routes/invitePrivate/index.tsx b/src/routes/invitePrivate/index.tsx new file mode 100644 index 00000000..561c112c --- /dev/null +++ b/src/routes/invitePrivate/index.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useJoinPrivateChannelMutation, useLazyGetChannelQuery } from '../../app/services/channel'; +import StyledButton from '../../common/component/styled/Button'; +import { useCheckMagicTokenValidMutation } from '../../app/services/auth'; +import { useAppSelector } from '../../app/store'; + +const InvitePrivate = () => { + const { channel_id } = useParams(); + const server = useAppSelector(store => store.server); + const navigateTo = useNavigate(); + const [joinChannel, { isLoading, data, isSuccess }] = useJoinPrivateChannelMutation(); + const [fetchChannelInfo, { data: channel, isSuccess: fetchChannelSuccess }] = useLazyGetChannelQuery(); + const [checkTokenInvalid, { data: isTokenValid, isLoading: checkingToken }] = + useCheckMagicTokenValidMutation(); + let [searchParams] = useSearchParams(new URLSearchParams(location.search)); + const magic_token = searchParams.get("magic_token") ?? ""; + useEffect(() => { + if (channel_id) { + fetchChannelInfo(+channel_id); + } + }, [channel_id]); + useEffect(() => { + if (magic_token) { + checkTokenInvalid(magic_token); + } + }, [magic_token]); + useEffect(() => { + if (data && isSuccess) { + // joinChannel(data) + navigateTo(`/chat/channel/${data.gid}`); + } + }, [isSuccess, data]); + const handleJoin = async () => { + const resp = await joinChannel({ magic_token }); + if ("error" in resp) { + if (resp.error.originalStatus === 409) { + // alert("The invite link is invalid or expired"); + } + } + }; + if (!fetchChannelSuccess) return null; + return ( +
        +
        +
        + server logo +

        + {server.name} +

        +
        + + {checkingToken ? "Checking..." : isTokenValid ? <>You are invited to join private channel #{channel?.name} : "The invite link is invalid or expired"} + + Join +
        +
        + ); +}; + +export default InvitePrivate; \ No newline at end of file diff --git a/src/routes/setting/navs.tsx b/src/routes/setting/navs.tsx index 71c04eb7..2a15fc6e 100644 --- a/src/routes/setting/navs.tsx +++ b/src/routes/setting/navs.tsx @@ -88,14 +88,6 @@ const navs = [ name: "version", component: }, - { - name: "faq", - link: "https://doc.voce.chat/faq", - }, - { - name: "feedback", - link: `https://privoce.voce.chat/widget?from=${encodeURIComponent(location.host)}`, - }, ] } ]; diff --git a/src/routes/users/Search.tsx b/src/routes/users/Search.tsx index 3922f7ab..212b0f8f 100644 --- a/src/routes/users/Search.tsx +++ b/src/routes/users/Search.tsx @@ -1,30 +1,36 @@ +import { FC, ChangeEvent } from "react"; import Tippy from "@tippyjs/react"; import IconSearch from "../../assets/icons/search.svg"; import IconAdd from "../../assets/icons/add.svg"; -import AddEntriesMenu from "../../common/component/AddEntriesMenu"; -import Tooltip from "../../common/component/Tooltip"; -import { FC, ChangeEvent } from "react"; import { useTranslation } from "react-i18next"; +import { useAppSelector } from "../../app/store"; +import { compareVersion } from "../../common/utils"; +import Tooltip from "../../common/component/Tooltip"; +import AddEntriesMenu from "../../common/component/AddEntriesMenu"; type Props = { input: string, + openModal: () => void, updateInput: (input: string) => void }; -const Search: FC = ({ input, updateInput }) => { +const Search: FC = ({ input, updateInput, openModal }) => { + const serverVersion = useAppSelector(store => store.server.version); const { t } = useTranslation(); const handleInput = (evt: ChangeEvent) => { updateInput(evt.target.value); }; + const enableContact = compareVersion(serverVersion, "0.3.7") >= 0; return ( +
        - +
        - + {enableContact ? : }> - + }
        ); }; diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index 0374db64..a32d28b8 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { NavLink, useParams, useLocation } from "react-router-dom"; import { useDispatch } from "react-redux"; import { ViewportList } from 'react-viewport-list'; @@ -12,6 +12,7 @@ import BlankPlaceholder from "../../common/component/BlankPlaceholder"; import useFilteredUsers from "../../common/hook/useFilteredUsers"; import clsx from "clsx"; import GoBackNav from "../../common/component/GoBackNav"; +import SearchUser from "../../common/component/SearchUser"; function UsersPage() { const ref = useRef( @@ -19,6 +20,7 @@ function UsersPage() { ); const dispatch = useDispatch(); const { pathname } = useLocation(); + const [modalVisible, setModalVisible] = useState(false); const { input, updateInput, users } = useFilteredUsers(); const { user_id } = useParams(); // 记住路由 @@ -28,7 +30,9 @@ function UsersPage() { dispatch(updateRememberedNavs({ key: "user", path: pathname })); }; }, [pathname]); - + const toggleModal = () => { + setModalVisible(prev => !prev); + }; if (!users) return null; const isUserDetail = !!user_id; return ( @@ -36,7 +40,8 @@ function UsersPage() {
        - + + {modalVisible && }