You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailchat/web/src/components/ChatBox/ChatMessageList/Item.tsx

277 lines
7.7 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useMemo, useState } from 'react';
import {
ChatMessage,
formatShortTime,
getMessageTimeDiff,
shouldShowMessageTime,
SYSTEM_USERID,
t,
useCachedUserInfo,
MessageHelper,
useAsync,
getCachedUserInfo,
} from 'tailchat-shared';
import { Avatar } from '@/components/Avatar';
import { useRenderPluginMessageInterpreter } from './useRenderPluginMessageInterpreter';
import { getMessageRender } from '@/plugin/common';
import { Icon } from '@iconify/react';
import { Divider, Dropdown } from 'antd';
import { UserName } from '@/components/UserName';
import './item.less';
import clsx from 'clsx';
import { useChatMessageItemAction } from './useChatMessageItemAction';
import { useChatMessageReaction } from './useChatMessageReaction';
import { DevContainer } from '@/components/DevContainer';
import { TcPopover } from '@/components/TcPopover';
import { useMessageReactions } from './useMessageReactions';
/**
* 消息引用
*/
const MessageQuote: React.FC<{ payload: ChatMessage }> = React.memo(
({ payload }) => {
const quote = useMemo(
() => new MessageHelper(payload).hasReply(),
[payload]
);
if (quote === false) {
return null;
}
return (
<div className="chat-message-item_quote border-l-4 border-black border-opacity-20 pl-2 opacity-80">
{t('回复')} <UserName userId={String(quote.author)} />:{' '}
<span>{getMessageRender(quote.content)}</span>
</div>
);
}
);
MessageQuote.displayName = 'MessageQuote';
const MessageActionIcon: React.FC<{ icon: string }> = (props) => (
<div className="px-0.5 w-6 h-6 flex justify-center items-center opacity-60 hover:opacity-100">
<Icon icon={props.icon} />
</div>
);
/**
* 普通消息
*/
const NormalMessage: React.FC<ChatMessageItemProps> = React.memo((props) => {
const { showAvatar, payload } = props;
const userInfo = useCachedUserInfo(payload.author ?? '');
const [isActionBtnActive, setIsActionBtnActive] = useState(false);
const reactions = useMessageReactions(payload);
const emojiAction = useChatMessageReaction(payload);
const moreActions = useChatMessageItemAction(payload);
return (
<div
className={clsx('chat-message-item flex px-2 group relative', {
'bg-black bg-opacity-10': isActionBtnActive,
'hover:bg-black hover:bg-opacity-10': !isActionBtnActive,
})}
>
{/* 头像 */}
<div className="w-18 flex items-start justify-center pt-0.5">
{showAvatar ? (
<Avatar size={40} src={userInfo.avatar} name={userInfo.nickname} />
) : (
<div className="hidden group-hover:block opacity-40">
{formatShortTime(payload.createdAt)}
</div>
)}
</div>
{/* 主体 */}
<div className="flex flex-col flex-1 overflow-auto group">
{showAvatar && (
<div className="flex items-center">
<div className="font-bold">{userInfo.nickname}</div>
<div className="hidden group-hover:block opacity-40 ml-1 text-sm">
{formatShortTime(payload.createdAt)}
</div>
</div>
)}
<div className="leading-6 break-words">
<MessageQuote payload={payload} />
<span>{getMessageRender(payload.content)}</span>
{/* 解释器按钮 */}
{useRenderPluginMessageInterpreter(payload.content)}
</div>
{reactions}
</div>
{/* 操作 */}
<div
className={clsx(
'bg-white dark:bg-black rounded absolute right-2 cursor-pointer -top-3 shadow-sm flex',
{
'opacity-0 group-hover:opacity-100 bg-opacity-80 hover:bg-opacity-100':
!isActionBtnActive,
'opacity-100 bg-opacity-100': isActionBtnActive,
}
)}
>
<DevContainer>
<TcPopover
overlayClassName="chat-message-item_action-popover"
content={emojiAction}
placement="bottomLeft"
trigger={['click']}
onVisibleChange={setIsActionBtnActive}
>
<div>
<MessageActionIcon icon="mdi:emoticon-happy-outline" />
</div>
</TcPopover>
</DevContainer>
<Dropdown
overlay={moreActions}
placement="bottomLeft"
trigger={['click']}
onVisibleChange={setIsActionBtnActive}
>
<div>
<MessageActionIcon icon="mdi:dots-horizontal" />
</div>
</Dropdown>
</div>
</div>
);
});
NormalMessage.displayName = 'NormalMessage';
/**
* 系统消息
*/
const SystemMessage: React.FC<ChatMessageItemProps> = React.memo(
({ payload }) => {
return (
<div className="text-center">
<div className="bg-black bg-opacity-20 rounded inline-block py-0.5 px-2 my-1 text-sm">
{payload.content}
</div>
</div>
);
}
);
SystemMessage.displayName = 'SystemMessage';
/**
* 带userId => nickname异步解析的SystemMessage 组件
*/
const SystemMessageWithNickname: React.FC<
ChatMessageItemProps & {
userIds: string[];
overwritePayload: (nicknameList: string[]) => ChatMessage;
}
> = React.memo((props) => {
const { value: nicknameList = [] } = useAsync(() => {
return Promise.all(
props.userIds.map((userId) =>
getCachedUserInfo(userId).then((u) => u.nickname)
)
);
}, [props.userIds.join(',')]);
return (
<SystemMessage {...props} payload={props.overwritePayload(nicknameList)} />
);
});
SystemMessageWithNickname.displayName = 'SystemMessageWithNickname';
interface ChatMessageItemProps {
showAvatar: boolean;
payload: ChatMessage;
}
const ChatMessageItem: React.FC<ChatMessageItemProps> = React.memo((props) => {
const payload = props.payload;
if (payload.author === SYSTEM_USERID) {
// 系统消息
return <SystemMessage {...props} />;
} else if (payload.hasRecall === true) {
// 撤回消息
return (
<SystemMessageWithNickname
{...props}
userIds={[payload.author ?? SYSTEM_USERID]}
overwritePayload={(nickname) => ({
...payload,
content: t('{{nickname}} 撤回了一条消息', {
nickname: nickname[0] ?? '',
}),
})}
/>
);
}
// 普通消息
return <NormalMessage {...props} />;
});
ChatMessageItem.displayName = 'ChatMessageItem';
function findMessageIndexWithId(
messages: ChatMessage[],
messageId: string
): number {
return messages.findIndex((m) => m._id === messageId);
}
/**
* 构造聊天项
*/
export function buildMessageItemRow(
messages: ChatMessage[],
messageId: string
) {
const index = findMessageIndexWithId(messages, messageId); // TODO: 这里是因为mattermost的动态列表传的id因此只能这边再用id找回可以看看是否可以优化
if (index === -1) {
return <div />;
}
const message = messages[index];
let showDate = true;
let showAvatar = true;
const messageCreatedAt = new Date(message.createdAt ?? '');
if (index > 0) {
// 当不是第一条数据时
// 进行时间合并
const prevMessage = messages[index - 1];
if (
!shouldShowMessageTime(
new Date(prevMessage.createdAt ?? ''),
messageCreatedAt
)
) {
showDate = false;
}
// 进行头像合并(在同一时间块下 且发送者为同一人)
if (showDate === false) {
showAvatar = prevMessage.author !== message.author;
}
}
return (
<div key={message._id}>
{showDate && (
<Divider className="text-sm opacity-40 px-6 font-normal select-none">
{getMessageTimeDiff(messageCreatedAt)}
</Divider>
)}
<ChatMessageItem showAvatar={showAvatar} payload={message} />
</div>
);
}