feat: remark

This commit is contained in:
Tristan Yang
2025-07-16 17:52:57 +08:00
parent 5e537cae39
commit 2f9e0ccaab
15 changed files with 229 additions and 93 deletions
+1 -1
View File
@@ -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;
+53 -44
View File
@@ -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<ContactResponse[], void>({
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<void, number>({
query: (uid) => ({ url: `/admin/user/${uid}`, method: "DELETE" })
query: (uid) => ({ url: `/admin/user/${uid}`, method: "DELETE" }),
}),
createUser: builder.mutation<UserForAdmin, UserCreateDTO>({
query: (data) => ({
url: `/admin/user`,
body: data,
method: "POST"
})
method: "POST",
}),
}),
searchUser: builder.mutation<User, { search_type: "id" | "email" | "name"; keyword: string }>({
query: (input) => ({
url: `/user/search`,
body: input,
method: "POST"
})
method: "POST",
}),
}),
pinChat: builder.mutation<void, { uid: number } | { gid: number }>({
query: (data) => ({
url: `/user/pin_chat`,
method: "POST",
body: { target: data }
})
body: { target: data },
}),
}),
unpinChat: builder.mutation<void, { uid: number } | { gid: number }>({
query: (data) => ({
url: `/user/unpin_chat`,
method: "POST",
body: { target: data }
})
body: { target: data },
}),
}),
updateUser: builder.mutation<UserForAdmin, UserForAdminDTO>({
query: ({ id, ...rest }) => ({
url: `/admin/user/${id}`,
body: rest,
method: "PUT"
})
method: "PUT",
}),
}),
updateRemark: builder.mutation<void, UserRemarkDTO>({
query: (data) => ({
url: `/user/contact_remark`,
body: data,
method: "PUT",
}),
}),
updateAutoDeleteMsg: builder.mutation<void, AutoDeleteMsgDTO>({
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<void, { action: ContactAction; target_uid: number }>({
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<void, MuteDTO>({
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<void, File>({
query: (data) => ({
headers: {
"content-type": "image/png"
"content-type": "image/png",
},
url: `/user/avatar`,
method: "POST",
body: data
})
body: data,
}),
}),
updateAvatarByAdmin: builder.mutation<void, { uid: number; file: File }>({
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<UserForAdmin, number>({
query: (uid) => ({ url: `/admin/user/${uid}` })
query: (uid) => ({ url: `/admin/user/${uid}` }),
}),
// bot operations
createBotAPIKey: builder.mutation<void, { uid: number; name: string }>({
query: ({ uid, name }) => ({
url: `/admin/user/bot-api-key/${uid}`,
method: "POST",
body: { name }
})
body: { name },
}),
}),
getBotAPIKeys: builder.query<BotAPIKey[], number>({
query: (uid) => ({ url: `/admin/user/bot-api-key/${uid}` })
query: (uid) => ({ url: `/admin/user/bot-api-key/${uid}` }),
}),
deleteBotAPIKey: builder.query<void, { uid: number; kid: number }>({
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<User, UserDTO>({
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;
+18 -9
View File
@@ -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<number, string>;
}
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<number>) {
@@ -100,7 +104,7 @@ const footprintSlice = createSlice({
},
updateAfterMid(state, action: PayloadAction<number>) {
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) => {
// 如果有asidevoice的,就把它关掉
// 如果有 asidevoice 的,就把它关掉
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;
+7 -6
View File
@@ -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<Props> = ({ cid }) => {
canCopyEmail,
removeFromChannel,
removeUser,
showEmailInChannel
showEmailInChannel,
} = useUserOperation({
cid
cid,
});
const [updateUser, { isSuccess: updateSuccess }] = useUpdateUserMutation();
@@ -53,7 +54,7 @@ const MemberList: FC<Props> = ({ cid }) => {
const handleToggleRole = ({
ignore = false,
uid,
isAdmin = true
isAdmin = true,
}: {
ignore: boolean;
uid: number;
@@ -95,7 +96,7 @@ const MemberList: FC<Props> = ({ cid }) => {
<User compact uid={uid} interactive={false} />
<div className="flex flex-col">
<span className="font-bold text-sm text-gray-600 dark:text-white flex items-center gap-1">
{name} {owner && <IconOwner />}
<NameWithRemark name={name} uid={uid} /> {owner && <IconOwner />}
</span>
{showEmailInChannel && (
<span className="hidden md:block text-xs text-gray-500 dark:text-slate-50">
@@ -117,7 +118,7 @@ const MemberList: FC<Props> = ({ cid }) => {
onClick={handleToggleRole.bind(null, {
ignore: is_admin,
uid,
isAdmin: true
isAdmin: true,
})}
>
{t("admin")}
@@ -128,7 +129,7 @@ const MemberList: FC<Props> = ({ cid }) => {
onClick={handleToggleRole.bind(null, {
ignore: !is_admin,
uid,
isAdmin: false
isAdmin: false,
})}
>
{t("user")}
+11 -4
View File
@@ -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<IProps> = ({
mid,
context = "dm",
updateReadIndex,
read = true
read = true,
}) => {
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
const inViewRef = useInView<HTMLDivElement>();
@@ -80,7 +81,7 @@ const Message: FC<IProps> = ({
edited,
properties,
expires_in = 0,
failed = false
failed = false,
} = message;
const reactions = reactionMessageData[mid];
@@ -159,7 +160,13 @@ const Message: FC<IProps> = ({
<div
className={clsx(`flex items-center gap-2 font-semibold`, isSelf && "flex-row-reverse")}
>
<span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
<span className="text-primary-500 text-sm">
{currUser?.name ? (
<NameWithRemark uid={currUser.uid} showName={false} name={currUser.name} />
) : (
"Deleted User"
)}
</span>
{currUser?.is_admin && <IconAdmin />}
<Tooltip
delay={200}
@@ -201,7 +208,7 @@ const Message: FC<IProps> = ({
content,
thumbnail,
download,
edited
edited,
})
)}
</div>
+19
View File
@@ -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;
+13 -11
View File
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ uid, type = "embed", cid }) => {
src={avatar}
name={name}
/>
<Remark uid={uid} />
<h2 className="text-lg select-text font-bold text-gray-900 dark:text-white">
{name} {canDM && <span className="font-normal text-gray-500">#{uid}</span>}
</h2>
@@ -101,11 +103,11 @@ const Profile: FC<Props> = ({ 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<Props> = ({ 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[]
}
/>
+64
View File
@@ -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<HTMLInputElement>) => {
setInput(evt.target.value);
};
const handleOK = async () => {
const { error } = await updateRemark({ contact_uid: uid, remark: input });
if (!error) {
setEditMode(false);
}
};
return (
<ServerVersionChecker empty version="0.5.0">
<div className="flex items-center gap-1 text-white py-2">
{editMode ? (
<div className="flex flex-col gap-2">
<Input
onChange={handleChange}
value={input}
placeholder={t("remark_placeholder")}
className="small"
/>
<div className="flex items-center gap-2">
<StyledButton disabled={isLoading} onClick={handleOK} className="small">
{ct("yes")}
</StyledButton>
<StyledButton onClick={setEditMode.bind(null, false)} className="small ghost">
{ct("cancel")}
</StyledButton>
</div>
</div>
) : (
<div className="flex items-center gap-1">
<span>{remark}</span>
<button className="flex items-center gap-1" onClick={setEditMode.bind(null, true)}>
{!remark && t("remark")}
<IconEdit />
</button>
</div>
)}
</div>
</ServerVersionChecker>
);
};
export default Remark;
+3 -2
View File
@@ -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<HTMLDivElement> {
uid: number;
@@ -111,7 +112,7 @@ const User: FC<Props> = ({
</div>
{!compact && (
<span className={nameClass} title={curr?.name}>
{curr?.name}
<NameWithRemark uid={uid} name={curr?.name || ""} />
</span>
)}
{!compact && curr.is_admin && !curr.is_bot && <IconAdmin />}
@@ -155,7 +156,7 @@ const User: FC<Props> = ({
</div>
{!compact && (
<span className={nameClass} title={curr?.name}>
{curr?.name}
<NameWithRemark uid={uid} name={curr?.name || ""} />
</span>
)}
{!compact && curr.is_admin && !curr.is_bot && <IconAdmin />}
+14 -8
View File
@@ -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,
};
}
+6 -5
View File
@@ -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<IProps> = ({
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<IProps> = ({
}
},
collect: (monitor) => ({
isActive: monitor.canDrop() && monitor.isOver()
})
isActive: monitor.canDrop() && monitor.isOver(),
}),
}),
[type, id]
);
@@ -108,7 +109,7 @@ const Session: FC<IProps> = ({
mids,
readIndex,
messageData,
loginUid
loginUid,
});
const isVoicing =
type == "channel"
@@ -167,7 +168,7 @@ const Session: FC<IProps> = ({
!previewMsg.created_at && "max-w-[190px]"
)}
>
{name}
{type == "dm" ? <NameWithRemark uid={id} showName={false} name={name} /> : name}
</i>
{!is_public && <IconLock className="dark:fill-gray-400" />}
</span>
+8 -1
View File
@@ -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;
+4
View File
@@ -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<UserForAdmin> {
id?: number;
}