diff --git a/public/locales/en/chat.json b/public/locales/en/chat.json index f1512feb..733e603c 100644 --- a/public/locales/en/chat.json +++ b/public/locales/en/chat.json @@ -64,5 +64,8 @@ "contact_tip": "This user is not in your contact", "contact_block_tip": "This user has been blocked by you", "file_expired": "File Expired", - "only_owner_can_send": "only Channel owner can send message!" + "only_owner_can_send": "only Channel owner can send message!", + + "remark": "remark", + "remark_placeholder": "remark this user" } diff --git a/public/locales/zh/chat.json b/public/locales/zh/chat.json index 98cc0afa..0df89c32 100644 --- a/public/locales/zh/chat.json +++ b/public/locales/zh/chat.json @@ -63,5 +63,8 @@ "contact_tip": "该用户不在您的联系人列表中", "contact_block_tip": "该用户已被你屏蔽", "file_expired": "文件已过期", - "only_owner_can_send": "只有频道创建者才能发消息!" + "only_owner_can_send": "只有频道创建者才能发消息!", + + "remark": "备注", + "remark_placeholder": "备注该用户" } diff --git a/src/app/config.ts b/src/app/config.ts index 1913999a..3e6c6ff8 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -12,7 +12,7 @@ const prices: Price[] = [ }, ]; const official_dev = `https://dev.voce.chat`; -const local_dev = `https://dev.voce.chat`; +const local_dev = `http://localhost:3000`; // const local_dev = `http://chat.jcdl369.top:3009`; const dev_origin = process.env.REACT_APP_OFFICIAL_DEMO ? official_dev : local_dev; diff --git a/src/app/services/user.ts b/src/app/services/user.ts index 09a525ed..22a8f57c 100644 --- a/src/app/services/user.ts +++ b/src/app/services/user.ts @@ -11,7 +11,8 @@ import { UserCreateDTO, UserDTO, UserForAdmin, - UserForAdminDTO + UserForAdminDTO, + UserRemarkDTO, } from "@/types/user"; import BASE_URL, { ContentTypes } from "../config"; import { updateAutoDeleteSetting, updateMute } from "../slices/footprint"; @@ -36,7 +37,7 @@ export const userApi = createApi({ avatar: user.avatar_updated_at == 0 ? "" - : `${BASE_URL}/resource/avatar?uid=${user.uid}&t=${user.avatar_updated_at}` + : `${BASE_URL}/resource/avatar?uid=${user.uid}&t=${user.avatar_updated_at}`, }; }); }, @@ -44,7 +45,7 @@ export const userApi = createApi({ try { const { data: users } = await queryFulfilled; const { - authData: { user: loginUser } + authData: { user: loginUser }, } = getState() as RootState; dispatch( fillUsers( @@ -52,7 +53,7 @@ export const userApi = createApi({ const status = loginUser?.uid == u.uid ? "added" : ""; return { ...u, - status + status, }; }) ) @@ -60,7 +61,7 @@ export const userApi = createApi({ } catch { console.log("get user list error"); } - } + }, }), getContacts: builder.query({ query: () => ({ url: `/user/contacts` }), @@ -72,59 +73,66 @@ export const userApi = createApi({ const status = c.contact_info.status; return { uid, - status + status, }; }); dispatch(updateStatus(payloads)); } catch { console.log("get contact list error"); } - } + }, }), deleteUser: builder.query({ - query: (uid) => ({ url: `/admin/user/${uid}`, method: "DELETE" }) + query: (uid) => ({ url: `/admin/user/${uid}`, method: "DELETE" }), }), createUser: builder.mutation({ query: (data) => ({ url: `/admin/user`, body: data, - method: "POST" - }) + method: "POST", + }), }), searchUser: builder.mutation({ query: (input) => ({ url: `/user/search`, body: input, - method: "POST" - }) + method: "POST", + }), }), pinChat: builder.mutation({ query: (data) => ({ url: `/user/pin_chat`, method: "POST", - body: { target: data } - }) + body: { target: data }, + }), }), unpinChat: builder.mutation({ query: (data) => ({ url: `/user/unpin_chat`, method: "POST", - body: { target: data } - }) + body: { target: data }, + }), }), updateUser: builder.mutation({ query: ({ id, ...rest }) => ({ url: `/admin/user/${id}`, body: rest, - method: "PUT" - }) + method: "PUT", + }), + }), + updateRemark: builder.mutation({ + query: (data) => ({ + url: `/user/contact_remark`, + body: data, + method: "PUT", + }), }), updateAutoDeleteMsg: builder.mutation({ query: (data) => ({ url: `/user/burn-after-reading`, body: data, - method: "POST" + method: "POST", }), async onQueryStarted(data, { dispatch, queryFulfilled }) { try { @@ -140,21 +148,21 @@ export const userApi = createApi({ } catch { console.log("update auto delete message setting error"); } - } + }, }), updateContactStatus: builder.mutation({ query: (payload) => ({ url: `/user/update_contact_status`, method: "POST", - body: payload + body: payload, }), async onQueryStarted(data, { dispatch, queryFulfilled }) { const map = { add: "added", block: "blocked", remove: "", - unblock: "" + unblock: "", }; try { await queryFulfilled; @@ -163,13 +171,13 @@ export const userApi = createApi({ } catch (error) { console.log("update mute failed", error); } - } + }, }), updateMuteSetting: builder.mutation({ query: (data) => ({ url: `/user/mute`, method: "POST", - body: data + body: data, }), async onQueryStarted(data, { dispatch, queryFulfilled }) { try { @@ -178,51 +186,51 @@ export const userApi = createApi({ } catch (error) { console.log("update mute failed", error); } - } + }, }), updateAvatar: builder.mutation({ query: (data) => ({ headers: { - "content-type": "image/png" + "content-type": "image/png", }, url: `/user/avatar`, method: "POST", - body: data - }) + body: data, + }), }), updateAvatarByAdmin: builder.mutation({ query: ({ uid, file }) => ({ headers: { - "content-type": "image/png" + "content-type": "image/png", }, url: `/admin/user/${uid}/avatar`, method: "POST", - body: file - }) + body: file, + }), }), getUserByAdmin: builder.query({ - query: (uid) => ({ url: `/admin/user/${uid}` }) + query: (uid) => ({ url: `/admin/user/${uid}` }), }), // bot operations createBotAPIKey: builder.mutation({ query: ({ uid, name }) => ({ url: `/admin/user/bot-api-key/${uid}`, method: "POST", - body: { name } - }) + body: { name }, + }), }), getBotAPIKeys: builder.query({ - query: (uid) => ({ url: `/admin/user/bot-api-key/${uid}` }) + query: (uid) => ({ url: `/admin/user/bot-api-key/${uid}` }), }), deleteBotAPIKey: builder.query({ - query: ({ uid, kid }) => ({ url: `/admin/user/bot-api-key/${uid}/${kid}`, method: "DELETE" }) + query: ({ uid, kid }) => ({ url: `/admin/user/bot-api-key/${uid}/${kid}`, method: "DELETE" }), }), // bot operations end updateInfo: builder.mutation({ query: (data) => ({ url: `/user`, method: "PUT", - body: data + body: data, }), async onQueryStarted(params, { dispatch, queryFulfilled }) { try { @@ -231,7 +239,7 @@ export const userApi = createApi({ } catch (error) { console.log("update login user failed", error); } - } + }, }), sendMsg: builder.mutation< number, @@ -240,21 +248,22 @@ export const userApi = createApi({ query: ({ id, content, type = "text", properties = "" }) => ({ headers: { "content-type": ContentTypes[type], - "X-Properties": properties ? encodeBase64(JSON.stringify(properties)) : "" + "X-Properties": properties ? encodeBase64(JSON.stringify(properties)) : "", }, url: `/user/${id}/send`, method: "POST", - body: type == "file" ? JSON.stringify(content) : content + body: type == "file" ? JSON.stringify(content) : content, }), async onQueryStarted(param1, param2) { // @ts-ignore await onMessageSendStarted.call(this, param1, param2, "user"); - } - }) - }) + }, + }), + }), }); export const { + useUpdateRemarkMutation, useLazyGetUsersQuery, useGetUserByAdminQuery, useUpdateAvatarByAdminMutation, @@ -273,5 +282,5 @@ export const { useSearchUserMutation, useUpdateContactStatusMutation, usePinChatMutation, - useUnpinChatMutation + useUnpinChatMutation, } = userApi; diff --git a/src/app/slices/footprint.ts b/src/app/slices/footprint.ts index 85cc03bf..80a76fbd 100644 --- a/src/app/slices/footprint.ts +++ b/src/app/slices/footprint.ts @@ -10,7 +10,7 @@ import { AutoDeleteSettingForChannels, AutoDeleteSettingForUsers, PinChat, - PinChatTarget + PinChatTarget, } from "@/types/sse"; import { resetAuthData } from "./auth.data"; @@ -31,6 +31,7 @@ export interface State { channelAsides: { [cid: number]: ChannelAside }; dmAsides: { [uid: number]: DMAside }; pinChats: PinChat[]; + remarkMap: Record; } const initialState: State = { @@ -47,7 +48,8 @@ const initialState: State = { autoDeleteMsgChannels: [], channelAsides: {}, dmAsides: {}, - pinChats: [] + pinChats: [], + remarkMap: {}, }; const footprintSlice = createSlice({ @@ -72,7 +74,8 @@ const footprintSlice = createSlice({ autoDeleteMsgChannels = [], channelAsides = {}, dmAsides = {}, - pinChats = [] + pinChats = [], + remarkMap = {}, } = action.payload; // 初始化全局变量 window.USERS_VERSION = usersVersion; @@ -91,7 +94,8 @@ const footprintSlice = createSlice({ autoDeleteMsgChannels, channelAsides, dmAsides, - pinChats + pinChats, + remarkMap, }; }, updateUsersVersion(state, action: PayloadAction) { @@ -100,7 +104,7 @@ const footprintSlice = createSlice({ }, updateAfterMid(state, action: PayloadAction) { const newMid = action.payload; - // 如果新mid小于已有的afterMid,则不必更新 + // 如果新 mid 小于已有的 afterMid,则不必更新 if (state.afterMid < newMid) { state.afterMid = action.payload; window.AFTER_MID = action.payload; @@ -220,6 +224,10 @@ const footprintSlice = createSlice({ state.readUsers[uid] = mid; }); }, + updateRemarkByUid(state, action: PayloadAction<{ uid: number; remark: string }>) { + const { uid, remark } = action.payload; + state.remarkMap[uid] = remark; + }, updateReadChannels(state, action: PayloadAction<{ gid: number; mid: number }[] | undefined>) { const reads = action.payload || []; if (reads.length == 0) return; @@ -234,11 +242,11 @@ const footprintSlice = createSlice({ updateDMVisibleAside(state, action: PayloadAction<{ id: number; aside: DMAside }>) { const { id, aside } = action.payload; state.dmAsides[id] = aside; - } + }, }, extraReducers: (builder) => { builder.addCase(resetAuthData, (state) => { - // 如果有aside是voice的,就把它关掉 + // 如果有 aside 是 voice 的,就把它关掉 Object.keys(state.channelAsides).forEach((channel_id: string) => { if (state.channelAsides[+channel_id] === "voice") { state.channelAsides[+channel_id] = null; @@ -250,7 +258,7 @@ const footprintSlice = createSlice({ } }); }); - } + }, }); export const { @@ -267,7 +275,8 @@ export const { updateChannelVisibleAside, updateDMVisibleAside, upsertPinChats, - removePinChats + removePinChats, + updateRemarkByUid, } = footprintSlice.actions; export default footprintSlice.reducer; diff --git a/src/components/ManageMembers/MemberList.tsx b/src/components/ManageMembers/MemberList.tsx index 2a47f657..82f09bf6 100644 --- a/src/components/ManageMembers/MemberList.tsx +++ b/src/components/ManageMembers/MemberList.tsx @@ -18,6 +18,7 @@ import User from "../User"; import { shallowEqual } from "react-redux"; // import ViewPassword from "./ViewPassword"; import UpdatePassword from "./UpdatePassword"; +import NameWithRemark from "../NameWithRemark"; interface Props { cid?: number; @@ -38,9 +39,9 @@ const MemberList: FC = ({ cid }) => { canCopyEmail, removeFromChannel, removeUser, - showEmailInChannel + showEmailInChannel, } = useUserOperation({ - cid + cid, }); const [updateUser, { isSuccess: updateSuccess }] = useUpdateUserMutation(); @@ -53,7 +54,7 @@ const MemberList: FC = ({ cid }) => { const handleToggleRole = ({ ignore = false, uid, - isAdmin = true + isAdmin = true, }: { ignore: boolean; uid: number; @@ -95,7 +96,7 @@ const MemberList: FC = ({ cid }) => {
- {name} {owner && } + {owner && } {showEmailInChannel && ( @@ -117,7 +118,7 @@ const MemberList: FC = ({ cid }) => { onClick={handleToggleRole.bind(null, { ignore: is_admin, uid, - isAdmin: true + isAdmin: true, })} > {t("admin")} @@ -128,7 +129,7 @@ const MemberList: FC = ({ cid }) => { onClick={handleToggleRole.bind(null, { ignore: !is_admin, uid, - isAdmin: false + isAdmin: false, })} > {t("user")} diff --git a/src/components/Message/index.tsx b/src/components/Message/index.tsx index 58730264..160c8668 100644 --- a/src/components/Message/index.tsx +++ b/src/components/Message/index.tsx @@ -20,6 +20,7 @@ import renderContent from "./renderContent"; import Reply from "./Reply"; import useInView from "./useInView"; import { shallowEqual } from "react-redux"; +import NameWithRemark from "../NameWithRemark"; interface IProps { readOnly?: boolean; @@ -35,7 +36,7 @@ const Message: FC = ({ mid, context = "dm", updateReadIndex, - read = true + read = true, }) => { const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu(); const inViewRef = useInView(); @@ -80,7 +81,7 @@ const Message: FC = ({ edited, properties, expires_in = 0, - failed = false + failed = false, } = message; const reactions = reactionMessageData[mid]; @@ -159,7 +160,13 @@ const Message: FC = ({
- {currUser?.name || "Deleted User"} + + {currUser?.name ? ( + + ) : ( + "Deleted User" + )} + {currUser?.is_admin && } = ({ content, thumbnail, download, - edited + edited, }) )}
diff --git a/src/components/NameWithRemark.tsx b/src/components/NameWithRemark.tsx new file mode 100644 index 00000000..ac0e7f49 --- /dev/null +++ b/src/components/NameWithRemark.tsx @@ -0,0 +1,19 @@ +import { useAppSelector } from "../app/store"; + +type Props = { + uid: number; + name?: string; + showName?: boolean; +}; + +const NameWithRemark = ({ uid, name = "", showName = true }: Props) => { + const remark = useAppSelector((store) => store.footprint.remarkMap[uid]); + if (!remark) return name; + return ( + <> + {remark} {showName ? `(${name})` : null} + + ); +}; + +export default NameWithRemark; diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 17760d52..41f4a383 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -13,6 +13,7 @@ import IconMore from "@/assets/icons/more.svg"; import Avatar from "../Avatar"; import ContextMenu, { Item } from "../ContextMenu"; import { shallowEqual } from "react-redux"; +import Remark from "./remark"; interface Props { uid: number; @@ -35,7 +36,7 @@ const Profile: FC = ({ uid, type = "embed", cid }) => { removeUser, isAdmin, canUpdateRole, - updateRole + updateRole, } = useUserOperation({ uid, cid }); const data = useAppSelector((store) => store.users.byId[uid], shallowEqual); if (!data) return null; @@ -43,7 +44,7 @@ const Profile: FC = ({ uid, type = "embed", cid }) => { const { name, email, - avatar + avatar, // introduction = "This guy has nothing to introduce", } = data; const isCard = type == "card"; @@ -64,6 +65,7 @@ const Profile: FC = ({ uid, type = "embed", cid }) => { src={avatar} name={name} /> +

{name} {canDM && #{uid}}

@@ -101,11 +103,11 @@ const Profile: FC = ({ uid, type = "embed", cid }) => { agoraEnabled && type == "card" && { title: t("call"), - handler: startCall + handler: startCall, }, canCopyEmail && { title: t("copy_email"), - handler: copyEmail + handler: copyEmail, }, canUpdateRole && { title: t("roles"), @@ -114,25 +116,25 @@ const Profile: FC = ({ uid, type = "embed", cid }) => { { title: t("set_normal"), checked: !isAdmin, - handler: updateRole + handler: updateRole, }, { title: t("set_admin"), checked: isAdmin, - handler: updateRole - } - ] + handler: updateRole, + }, + ], }, canRemoveFromChannel && { title: t("remove_from_channel"), danger: true, - handler: removeFromChannel + handler: removeFromChannel, }, canRemoveFromServer && { title: t("remove"), handler: removeUser, - danger: true - } + danger: true, + }, ].filter(Boolean) as Item[] } /> diff --git a/src/components/Profile/remark.tsx b/src/components/Profile/remark.tsx new file mode 100644 index 00000000..2c9aacbd --- /dev/null +++ b/src/components/Profile/remark.tsx @@ -0,0 +1,64 @@ +import React, { ChangeEvent, useState } from "react"; +import { useAppSelector } from "../../app/store"; +import Input from "../styled/Input"; +import IconEdit from "@/assets/icons/edit.svg"; +import StyledButton from "../styled/Button"; +import { useUpdateRemarkMutation } from "../../app/services/user"; +import { useTranslation } from "react-i18next"; +import ServerVersionChecker from "../ServerVersionChecker"; + +type Props = { + uid: number; +}; + +const Remark = ({ uid }: Props) => { + const { t } = useTranslation("chat"); + const { t: ct } = useTranslation("common", { keyPrefix: "action" }); + const [updateRemark, { isLoading }] = useUpdateRemarkMutation(); + const [editMode, setEditMode] = useState(false); + const remark = useAppSelector((store) => store.footprint.remarkMap[uid] || ""); + const [input, setInput] = useState(remark); + const handleChange = (evt: ChangeEvent) => { + setInput(evt.target.value); + }; + const handleOK = async () => { + const { error } = await updateRemark({ contact_uid: uid, remark: input }); + if (!error) { + setEditMode(false); + } + }; + return ( + +
+ {editMode ? ( +
+ +
+ + {ct("yes")} + + + {ct("cancel")} + +
+
+ ) : ( +
+ {remark} + +
+ )} +
+
+ ); +}; + +export default Remark; diff --git a/src/components/User/index.tsx b/src/components/User/index.tsx index dbfcbeba..926c7d1a 100644 --- a/src/components/User/index.tsx +++ b/src/components/User/index.tsx @@ -12,6 +12,7 @@ import Profile from "../Profile"; import ContextMenu from "./ContextMenu"; import { shallowEqual } from "react-redux"; import { cn } from "@/utils"; +import NameWithRemark from "../NameWithRemark"; interface Props extends React.HTMLAttributes { uid: number; @@ -111,7 +112,7 @@ const User: FC = ({
{!compact && ( - {curr?.name} + )} {!compact && curr.is_admin && !curr.is_bot && } @@ -155,7 +156,7 @@ const User: FC = ({ {!compact && ( - {curr?.name} + )} {!compact && curr.is_admin && !curr.is_bot && } diff --git a/src/hooks/useStreaming/index.ts b/src/hooks/useStreaming/index.ts index 04e35937..9a5be298 100644 --- a/src/hooks/useStreaming/index.ts +++ b/src/hooks/useStreaming/index.ts @@ -11,7 +11,7 @@ import { fillChannels, removeChannel, updateChannel, - updatePinMessage + updatePinMessage, } from "@/app/slices/channels"; import { removePinChats, @@ -20,14 +20,15 @@ import { updateMute, updateReadChannels, updateReadUsers, + updateRemarkByUid, updateUsersVersion, - upsertPinChats + upsertPinChats, } from "@/app/slices/footprint"; import { resetMessage } from "@/app/slices/message"; import { clearChannelMessage, removeChannelSession, - resetChannelMsg + resetChannelMsg, } from "@/app/slices/message.channel"; import { resetFileMessage } from "@/app/slices/message.file"; import { resetReactionMessage } from "@/app/slices/message.reaction"; @@ -41,7 +42,7 @@ import { ServerEvent, UserSettingsChangedEvent, UserSettingsEvent, - UsersStateEvent + UsersStateEvent, } from "@/types/sse"; import { getLocalAuthData, isElectronContext } from "@/utils"; import chatMessageHandler from "./chat.handler"; @@ -123,7 +124,7 @@ export default function useStreaming() { users_version?: string; } = { limit: "500", - "api-key": _token + "api-key": _token, }; // 如果 afterMid 是 0,则不传该参数 if (window.AFTER_MID) { @@ -168,6 +169,11 @@ export default function useStreaming() { case "heartbeat": keepAlive(); break; + case "user_remark": { + const { contact_uid, remark } = data; + dispatch(updateRemarkByUid({ uid: contact_uid, remark })); + break; + } case "message_cleared": { dispatch(resetFootprint()); dispatch(resetChannelMsg()); @@ -192,7 +198,7 @@ export default function useStreaming() { const transformed = { name: organization_name, description: organization_description, - ...tmp + ...tmp, }; const purified = omitBy(transformed, isNull); dispatch(updateInfo(purified)); @@ -409,7 +415,7 @@ export default function useStreaming() { ready, loginUid, readUsers, - readChannels + readChannels, }); break; } @@ -493,6 +499,6 @@ export default function useStreaming() { return { startStreaming, - stopStreaming + stopStreaming, }; } diff --git a/src/routes/chat/SessionList/Session.tsx b/src/routes/chat/SessionList/Session.tsx index 291c22b1..9637daed 100644 --- a/src/routes/chat/SessionList/Session.tsx +++ b/src/routes/chat/SessionList/Session.tsx @@ -18,6 +18,7 @@ import IconVoicing from "@/assets/icons/voicing.svg"; import getUnreadCount, { renderPreviewMessage } from "../utils"; import ContextMenu from "./ContextMenu"; import { shallowEqual } from "react-redux"; +import NameWithRemark from "../../../components/NameWithRemark"; interface IProps { type?: ChatContext; @@ -33,7 +34,7 @@ const Session: FC = ({ id, mid, setDeleteChannelId, - setInviteChannelId + setInviteChannelId, }) => { const navPath = type == "dm" ? `/chat/dm/${id}` : `/chat/channel/${id}`; // const { pathname } = useLocation(); @@ -56,8 +57,8 @@ const Session: FC = ({ } }, collect: (monitor) => ({ - isActive: monitor.canDrop() && monitor.isOver() - }) + isActive: monitor.canDrop() && monitor.isOver(), + }), }), [type, id] ); @@ -108,7 +109,7 @@ const Session: FC = ({ mids, readIndex, messageData, - loginUid + loginUid, }); const isVoicing = type == "channel" @@ -167,7 +168,7 @@ const Session: FC = ({ !previewMsg.created_at && "max-w-[190px]" )} > - {name} + {type == "dm" ? : name} {!is_public && } diff --git a/src/types/sse.ts b/src/types/sse.ts index a413a253..231020ec 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -195,6 +195,12 @@ interface GroupChangedEvent { avatar_updated_at: number; } +interface UserRemark { + type: "user_remark"; + contact_uid: number; + remark: string; +} + interface PinnedMessageUpdatedEvent { type: "pinned_message_updated"; gid: number; @@ -248,4 +254,5 @@ export type ServerEvent = | GroupClearEvent | UserCallEvent | ServerConfigChangedEvent - | MessageClearedEvent; + | MessageClearedEvent + | UserRemark; diff --git a/src/types/user.ts b/src/types/user.ts index cf23850d..e8d0e5eb 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -57,6 +57,10 @@ export interface UserForAdmin extends User { status: UserStatus; online_devices: UserDevice[]; } +export interface UserRemarkDTO { + contact_uid: number; + remark: string; +} export interface UserForAdminDTO extends Partial { id?: number; }