refactor: remark

This commit is contained in:
Tristan Yang
2025-07-16 22:25:02 +08:00
parent 4e67c5dca9
commit 4a2f98dfd2
7 changed files with 297 additions and 235 deletions
+4 -2
View File
@@ -66,6 +66,8 @@
"file_expired": "File Expired",
"only_owner_can_send": "only Channel owner can send message!",
"remark": "remark",
"remark_placeholder": "remark this user"
"remark": "add friend nickname",
"remark_clear": "reset friend nickname",
"remark_intro": "Find a friend faster with a personal nickname. It will only be visible to you in your direct messages.",
"remark_placeholder": "Set Nickname"
}
+2
View File
@@ -66,5 +66,7 @@
"only_owner_can_send": "只有频道创建者才能发消息!",
"remark": "备注",
"remark_clear": "重置备注",
"remark_intro": "给联系人备注,方便日后查找,备注仅您可见",
"remark_placeholder": "备注该用户"
}
+68
View File
@@ -0,0 +1,68 @@
import Modal from "./Modal";
import { useTranslation } from "react-i18next";
import StyledButton from "./styled/Button";
import Input from "./styled/Input";
import { useUpdateRemarkMutation } from "../app/services/user";
import { useAppSelector } from "../app/store";
import { ChangeEvent, useState } from "react";
import toast from "react-hot-toast";
type Props = {
uid: number;
visible: boolean;
updateVisible: (_visible: boolean) => void;
};
const NicknameModal = ({ visible, updateVisible, uid }: Props) => {
const remark = useAppSelector((store) => store.footprint.remarkMap[uid] || "");
const [updateRemark, { isLoading }] = useUpdateRemarkMutation();
const { t } = useTranslation("chat");
const { t: ct } = useTranslation("common", { keyPrefix: "action" });
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) {
updateVisible(false);
} else {
toast.error("failed");
}
};
if (!visible) return null;
return (
<Modal>
<div className="px-3 py-4 flex flex-col bg-white dark:bg-gray-900 rounded-lg max-h-[85vh] overflow-y-scroll md:min-w-[408px] relative">
<h2 className="z-50 py-2 bg-white dark:bg-gray-900 flex items-center justify-between text-lg text-gray-700 dark:text-gray-50 sticky top-0">
{t("remark")}
</h2>
<p className="text-sm text-gray-500">{t("remark_intro")}</p>
<div className="py-2 flex flex-col gap-2 items-start my-4">
{/* <label className="dark:text-white text-sm pb-2" htmlFor="nickname">
{t("remark")}
</label> */}
<Input
placeholder={t("remark_placeholder")}
name="nickname"
value={input}
onChange={handleChange}
/>
<button onClick={setInput.bind(null, "")} className="text-primary-500 text-sm">
{t("remark_clear")}
</button>
</div>
<div className="flex items-center gap-2 justify-end w-full">
<StyledButton disabled={isLoading} onClick={handleOK} className="small">
{ct("yes")}
</StyledButton>
<StyledButton onClick={updateVisible.bind(null, false)} className="small ghost">
{ct("cancel")}
</StyledButton>
</div>
</div>
</Modal>
);
};
export default NicknameModal;
+104 -91
View File
@@ -1,4 +1,4 @@
import { FC, memo } from "react";
import { FC, memo, useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router-dom";
import Tippy from "@tippyjs/react";
@@ -14,6 +14,7 @@ import Avatar from "../Avatar";
import ContextMenu, { Item } from "../ContextMenu";
import { shallowEqual } from "react-redux";
import Remark from "./remark";
import NicknameModal from "../NicknameModal";
interface Props {
uid: number;
@@ -22,8 +23,10 @@ interface Props {
}
const Profile: FC<Props> = ({ uid, type = "embed", cid }) => {
const [remarkVisible, setRemarkVisible] = useState(false);
const { data: agoraEnabled } = useGetAgoraStatusQuery();
const { t } = useTranslation("member");
const { t: chatTrans } = useTranslation("chat");
const { t: ct } = useTranslation();
const {
canDM,
@@ -57,97 +60,107 @@ const Profile: FC<Props> = ({ uid, type = "embed", cid }) => {
);
return (
<div className={containerClass}>
<Avatar
width={80}
height={80}
className="rounded-full w-20 h-20 object-cover"
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>
{canCopyEmail && (
<span className="text-sm text-gray-400 dark:text-gray-200 select-text">{email}</span>
)}
{/* <p className="intro">{introduction}</p> */}
{canDM && (
<ul
className={clsx("mt-6 flex flex-col md:flex-row items-center gap-2", isCard && "pb-0.5")}
>
<NavLink to={`/chat/dm/${uid}`}>
<li className={`${iconClass} icon chat`}>
<IconMessage />
<span>{t("send_msg")}</span>
</li>
</NavLink>
{agoraEnabled && type == "embed" && (
<li role="button" onClick={startCall} className={`${iconClass} icon chat`}>
<IconCall className="fill-primary-400" />
<span>{t("call")}</span>
</li>
)}
<Tippy
disabled={!hasMore}
interactive
popperOptions={{ strategy: "fixed" }}
placement="right"
trigger="click"
hideOnClick={true}
content={
<ContextMenu
items={
[
agoraEnabled &&
type == "card" && {
title: t("call"),
handler: startCall,
},
canCopyEmail && {
title: t("copy_email"),
handler: copyEmail,
},
canUpdateRole && {
title: t("roles"),
handler: updateRole,
subs: [
{
title: t("set_normal"),
checked: !isAdmin,
handler: updateRole,
},
{
title: t("set_admin"),
checked: isAdmin,
handler: updateRole,
},
],
},
canRemoveFromChannel && {
title: t("remove_from_channel"),
danger: true,
handler: removeFromChannel,
},
canRemoveFromServer && {
title: t("remove"),
handler: removeUser,
danger: true,
},
].filter(Boolean) as Item[]
}
/>
}
<>
<NicknameModal uid={uid} visible={remarkVisible} updateVisible={setRemarkVisible} />
<div className={containerClass}>
<Avatar
width={80}
height={80}
className="rounded-full w-20 h-20 object-cover"
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>
{canCopyEmail && (
<span className="text-sm text-gray-400 dark:text-gray-200 select-text">{email}</span>
)}
{/* <p className="intro">{introduction}</p> */}
{canDM && (
<ul
className={clsx(
"mt-6 flex flex-col md:flex-row items-center gap-2",
isCard && "pb-0.5"
)}
>
<li className={`${iconClass} icon ${hasMore ? "" : "text-gray-500"}`}>
<IconMore className={hasMore ? "fill-primary-500" : ""} />
<span>{ct("more")}</span>
</li>
</Tippy>
</ul>
)}
</div>
<NavLink to={`/chat/dm/${uid}`}>
<li className={`${iconClass} icon chat`}>
<IconMessage />
<span>{t("send_msg")}</span>
</li>
</NavLink>
{agoraEnabled && type == "embed" && (
<li role="button" onClick={startCall} className={`${iconClass} icon chat`}>
<IconCall className="fill-primary-400" />
<span>{t("call")}</span>
</li>
)}
<Tippy
disabled={!hasMore}
interactive
popperOptions={{ strategy: "fixed" }}
placement="right"
trigger="click"
hideOnClick={true}
content={
<ContextMenu
items={
[
{
title: chatTrans("remark"),
handler: setRemarkVisible.bind(null, true),
},
agoraEnabled &&
type == "card" && {
title: t("call"),
handler: startCall,
},
canCopyEmail && {
title: t("copy_email"),
handler: copyEmail,
},
canUpdateRole && {
title: t("roles"),
handler: updateRole,
subs: [
{
title: t("set_normal"),
checked: !isAdmin,
handler: updateRole,
},
{
title: t("set_admin"),
checked: isAdmin,
handler: updateRole,
},
],
},
canRemoveFromChannel && {
title: t("remove_from_channel"),
danger: true,
handler: removeFromChannel,
},
canRemoveFromServer && {
title: t("remove"),
handler: removeUser,
danger: true,
},
].filter(Boolean) as Item[]
}
/>
}
>
<li className={`${iconClass} icon ${hasMore ? "" : "text-gray-500"}`}>
<IconMore className={hasMore ? "fill-primary-500" : ""} />
<span>{ct("more")}</span>
</li>
</Tippy>
</ul>
)}
</div>
</>
);
};
+3 -46
View File
@@ -1,10 +1,4 @@
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 = {
@@ -12,50 +6,13 @@ type Props = {
};
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 className="flex items-center gap-1">
<span>{remark}</span>
</div>
</div>
</ServerVersionChecker>
);
+77 -68
View File
@@ -1,9 +1,10 @@
import { FC, ReactElement } from "react";
import { FC, ReactElement, useState } from "react";
import { useTranslation } from "react-i18next";
import Tippy from "@tippyjs/react";
import useUserOperation from "@/hooks/useUserOperation";
import ContextMenu, { Item } from "../ContextMenu";
import NicknameModal from "../NicknameModal";
interface Props {
enable?: boolean;
@@ -15,6 +16,7 @@ interface Props {
}
const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, children }) => {
const [remarkVisible, setRemarkVisible] = useState(false);
const { t } = useTranslation("member");
const { t: chatTran } = useTranslation("chat");
const {
@@ -31,77 +33,84 @@ const UserContextMenu: FC<Props> = ({ enable = false, uid, cid, visible, hide, c
removeUser,
isAdmin,
canUpdateRole,
updateRole
updateRole,
} = useUserOperation({
uid,
cid
cid,
});
return (
<Tippy
disabled={!enable}
visible={visible}
followCursor={"initial"}
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
onClickOutside={hide}
key={uid}
content={
<ContextMenu
hideMenu={hide}
items={
[
{
title: t("send_msg"),
handler: startChat
},
canCopyEmail && {
title: t("copy_email"),
handler: copyEmail
},
canUpdateRole && {
title: t("roles"),
handler: updateRole,
subs: [
{
title: t("set_normal"),
checked: !isAdmin,
handler: updateRole
},
{
title: t("set_admin"),
checked: isAdmin,
handler: updateRole
}
]
},
canRemoveFromChannel && {
danger: true,
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[]
}
/>
}
>
{children}
</Tippy>
<>
<NicknameModal uid={uid} visible={remarkVisible} updateVisible={setRemarkVisible} />
<Tippy
disabled={!enable}
visible={visible}
followCursor={"initial"}
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
onClickOutside={hide}
key={uid}
content={
<ContextMenu
hideMenu={hide}
items={
[
{
title: t("send_msg"),
handler: startChat,
},
{
title: chatTran("remark"),
handler: setRemarkVisible.bind(null, true),
},
canCopyEmail && {
title: t("copy_email"),
handler: copyEmail,
},
canUpdateRole && {
title: t("roles"),
handler: updateRole,
subs: [
{
title: t("set_normal"),
checked: !isAdmin,
handler: updateRole,
},
{
title: t("set_admin"),
checked: isAdmin,
handler: updateRole,
},
],
},
canRemoveFromChannel && {
danger: true,
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[]
}
/>
}
>
{children}
</Tippy>
</>
);
};
+39 -28
View File
@@ -1,4 +1,4 @@
import { FC, ReactElement } from "react";
import { FC, ReactElement, useState } from "react";
import { useTranslation } from "react-i18next";
import { shallowEqual, useDispatch } from "react-redux";
import { useLocation, useMatch, useNavigate } from "react-router-dom";
@@ -8,13 +8,15 @@ import { useReadMessageMutation } from "@/app/services/message";
import {
usePinChatMutation,
useUnpinChatMutation,
useUpdateMuteSettingMutation
useUpdateMuteSettingMutation,
} from "@/app/services/user";
import { removeUserSession } from "@/app/slices/message.user";
import { useAppSelector } from "@/app/store";
import { ChatContext } from "@/types/common";
import ContextMenu, { Item } from "@/components/ContextMenu";
import useUserOperation from "@/hooks/useUserOperation";
import Modal from "../../../components/Modal";
import NicknameModal from "../../../components/NicknameModal";
type Props = {
context: ChatContext;
@@ -36,12 +38,14 @@ const SessionContextMenu: FC<Props> = ({
hide,
deleteChannel,
setInviteChannelId,
children
children,
}) => {
const [remarkVisible, setRemarkVisible] = useState(false);
const { t: tChat } = useTranslation("chat");
const { t } = useTranslation();
const { canCopyEmail, copyEmail, canDeleteChannel, canInviteChannel } = useUserOperation({
uid: context == "dm" ? id : undefined,
cid: context == "channel" ? id : undefined
cid: context == "channel" ? id : undefined,
});
const [muteChannel] = useUpdateMuteSettingMutation();
const [pinChat] = usePinChatMutation();
@@ -97,67 +101,74 @@ const SessionContextMenu: FC<Props> = ({
? [
{
title: pinTxt,
handler: handlePinChat
handler: handlePinChat,
},
{
title: t("action.mark_read"),
handler: handleReadAll
handler: handleReadAll,
},
{
title: tChat("remark"),
handler: setRemarkVisible.bind(null, true),
},
{
title: t("setting"),
handler: handleDMSetting
handler: handleDMSetting,
},
canCopyEmail && {
title: t("action.copy_email"),
handler: copyEmail
handler: copyEmail,
},
{
title: t("action.hide_session"),
danger: true,
handler: handleRemoveSession
}
handler: handleRemoveSession,
},
]
: [
{
title: pinTxt,
handler: handlePinChat
handler: handlePinChat,
},
{
title: t("setting"),
underline: true,
handler: handleChannelSetting
handler: handleChannelSetting,
},
{
title: t("action.mark_read"),
// underline: true
handler: handleReadAll
handler: handleReadAll,
},
{
title: channelMuted ? t("action.unmute") : t("action.mute"),
handler: handleChannelMute
handler: handleChannelMute,
},
canInviteChannel && {
title: t("action.invite_people"),
handler: setInviteChannelId.bind(null, id)
handler: setInviteChannelId.bind(null, id),
},
canDeleteChannel && {
title: t("action.delete_channel"),
danger: true,
handler: deleteChannel.bind(null, id)
}
handler: deleteChannel.bind(null, id),
},
];
return (
<Tippy
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
followCursor={"initial"}
visible={visible}
onClickOutside={hide}
content={<ContextMenu hideMenu={hide} items={items.filter(Boolean) as Item[]} />}
>
{children}
</Tippy>
<>
<NicknameModal uid={id} visible={remarkVisible} updateVisible={setRemarkVisible} />
<Tippy
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
followCursor={"initial"}
visible={visible}
onClickOutside={hide}
content={<ContextMenu hideMenu={hide} items={items.filter(Boolean) as Item[]} />}
>
{children}
</Tippy>
</>
);
};
export default SessionContextMenu;