199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
import React, { useRef, useState, useEffect, FC } from "react";
|
|
import dayjs from "dayjs";
|
|
import Tippy from "@tippyjs/react";
|
|
import clsx from "clsx";
|
|
|
|
import useInView from "./useInView";
|
|
import Reaction from "./Reaction";
|
|
import Reply from "./Reply";
|
|
import Profile from "../Profile";
|
|
import Avatar from "../Avatar";
|
|
import Commands from "./Commands";
|
|
import EditMessage from "./EditMessage";
|
|
import renderContent from "./renderContent";
|
|
import Tooltip from "../Tooltip";
|
|
import ContextMenu from "./ContextMenu";
|
|
import useContextMenu from "../../hook/useContextMenu";
|
|
import usePinMessage from "../../hook/usePinMessage";
|
|
import { useAppSelector } from "../../../app/store";
|
|
import ExpireTimer from "./ExpireTimer";
|
|
|
|
interface IProps {
|
|
readOnly?: boolean;
|
|
contextId: number;
|
|
context?: "user" | "channel";
|
|
read?: boolean;
|
|
mid: number;
|
|
updateReadIndex?: (param: any) => void;
|
|
}
|
|
const Message: FC<IProps> = ({
|
|
readOnly = false,
|
|
contextId,
|
|
mid,
|
|
context = "user",
|
|
updateReadIndex,
|
|
read = true
|
|
}) => {
|
|
const { visible: contextMenuVisible, handleContextMenuEvent, hideContextMenu } = useContextMenu();
|
|
const inViewRef = useInView<HTMLDivElement>();
|
|
const [edit, setEdit] = useState(false);
|
|
const avatarRef = useRef(null);
|
|
const { getPinInfo } = usePinMessage(context == "channel" ? contextId : 0);
|
|
const { message, reactionMessageData, usersData } = useAppSelector((store) => {
|
|
return {
|
|
reactionMessageData: store.reactionMessage,
|
|
message: store.message[mid],
|
|
usersData: store.users.byId
|
|
};
|
|
});
|
|
|
|
const toggleEditMessage = () => {
|
|
setEdit((prev) => !prev);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!read) {
|
|
// 标记已读
|
|
const data =
|
|
context == "user"
|
|
? { users: [{ uid: +contextId, mid }] }
|
|
: { groups: [{ gid: +contextId, mid }] };
|
|
if (updateReadIndex) {
|
|
updateReadIndex(data);
|
|
}
|
|
}
|
|
}, [mid, read]);
|
|
|
|
const {
|
|
reply_mid,
|
|
from_uid: fromUid,
|
|
created_at: time,
|
|
sending = false,
|
|
content,
|
|
thumbnail,
|
|
download,
|
|
content_type = "text/plain",
|
|
edited,
|
|
properties,
|
|
expires_in = 0
|
|
} = message;
|
|
|
|
|
|
const reactions = reactionMessageData[mid];
|
|
const currUser = usersData[fromUid || 0];
|
|
// if (!message) return null;
|
|
let timePrefix = null;
|
|
const dayjsTime = dayjs(time);
|
|
timePrefix = dayjsTime.isToday() ? "Today" : dayjsTime.isYesterday() ? "Yesterday" : null;
|
|
|
|
const pinInfo = getPinInfo(mid);
|
|
// return null;
|
|
const _key = properties?.local_id || mid;
|
|
const showExpire = (expires_in ?? 0) > 0;
|
|
return (
|
|
<div
|
|
key={_key}
|
|
onContextMenu={readOnly ? undefined : handleContextMenuEvent}
|
|
data-msg-mid={mid}
|
|
ref={inViewRef}
|
|
className={clsx(`group w-full relative flex items-start gap-2 md:gap-4 px-1 md:px-2 py-1 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"
|
|
)}
|
|
>
|
|
<Tippy
|
|
key={_key}
|
|
popperOptions={{ strategy: "fixed" }}
|
|
disabled={readOnly}
|
|
interactive
|
|
placement="right"
|
|
trigger="click"
|
|
content={<Profile uid={fromUid || 0} type="card" cid={context == "user" ? 0 : contextId} />}
|
|
>
|
|
<div className="cursor-pointer w-10 h-10 shrink-0" data-uid={fromUid} ref={avatarRef}>
|
|
<Avatar className="w-10 h-10 rounded-full object-cover" width={40} height={40} src={currUser?.avatar} name={currUser?.name} />
|
|
</div>
|
|
</Tippy>
|
|
<ContextMenu
|
|
editMessage={toggleEditMessage}
|
|
context={context}
|
|
contextId={contextId}
|
|
mid={mid}
|
|
visible={contextMenuVisible}
|
|
hide={hideContextMenu}
|
|
>
|
|
<div
|
|
className={clsx("w-full flex flex-col gap-2", pinInfo && "relative")}
|
|
data-pin-tip={`pinned by ${pinInfo?.created_by ? usersData[pinInfo.created_by]?.name : ""
|
|
}`}
|
|
>
|
|
{pinInfo && <span className="absolute left-0 -top-1 -translate-y-full text-xs text-gray-400">
|
|
{`pinned by ${pinInfo.created_by ? usersData[pinInfo.created_by]?.name : ""}`}
|
|
</span>}
|
|
<div className="flex items-center gap-2 font-semibold">
|
|
<span className="text-primary-500 text-sm">{currUser?.name || "Deleted User"}</span>
|
|
<Tooltip
|
|
delay={200}
|
|
disabled={!timePrefix || readOnly}
|
|
placement="top"
|
|
tip={dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
|
|
>
|
|
<time className="text-gray-400 text-xs">
|
|
{timePrefix
|
|
? `${timePrefix} ${dayjsTime.format("h:mm A")}`
|
|
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
|
|
</time>
|
|
</Tooltip>
|
|
</div>
|
|
{/* 文本类的消息限高 */}
|
|
<div className={clsx(`select-text text-gray-800 text-sm break-all whitespace-pre-wrap dark:!text-white`,
|
|
sending && "opacity-90",
|
|
content_type.startsWith("text") && "max-h-96 overflow-y-scroll ",
|
|
)}>
|
|
{reply_mid && <Reply key={reply_mid} mid={reply_mid} />}
|
|
{edit ? (
|
|
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
|
|
) : (
|
|
renderContent({
|
|
context,
|
|
to: contextId,
|
|
from_uid: fromUid,
|
|
created_at: time,
|
|
content_type,
|
|
properties,
|
|
content,
|
|
thumbnail,
|
|
download,
|
|
edited
|
|
})
|
|
)}
|
|
</div>
|
|
{reactions && <Reaction mid={mid} reactions={reactions} readOnly={readOnly} />}
|
|
</div>
|
|
</ContextMenu>
|
|
|
|
{showExpire && (
|
|
<ExpireTimer
|
|
mid={message.mid}
|
|
context={context}
|
|
contextId={contextId}
|
|
expiresIn={expires_in ?? 0}
|
|
createAt={time ?? 0}
|
|
/>
|
|
)}
|
|
{!edit && !readOnly && (
|
|
<Commands
|
|
context={context}
|
|
contextId={contextId}
|
|
mid={mid}
|
|
toggleEditMessage={toggleEditMessage}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
export default React.memo(Message, (prevs, nexts) => {
|
|
return prevs.mid == nexts.mid;
|
|
});
|