359 lines
17 KiB
JavaScript
359 lines
17 KiB
JavaScript
import React, { useState } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
import {
|
|
EmojieIcon,
|
|
EditIcon,
|
|
ReplyIcon,
|
|
MoreIcon,
|
|
DeleteIcon,
|
|
PinIcon,
|
|
} from '../assets/icons';
|
|
import { getEmojiUrl, AllEmojis } from '../assets/emojis';
|
|
import Tooltip from './Tooltip';
|
|
import Avatar from './Avatar';
|
|
|
|
const fireIcon = getEmojiUrl('nature', 'fire');
|
|
const heartIcon = getEmojiUrl('symbols', 'heart');
|
|
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
|
|
|
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
|
|
|
export const getUserColor = (name) => {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); }
|
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
|
};
|
|
|
|
export const extractUrls = (text) => {
|
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
|
return text.match(urlRegex) || [];
|
|
};
|
|
|
|
export const formatMentions = (text) => {
|
|
if (!text) return '';
|
|
return text.replace(/@(\w+)/g, '[@$1](mention://$1)');
|
|
};
|
|
|
|
export const formatEmojis = (text) => {
|
|
if (!text) return '';
|
|
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
|
const emoji = AllEmojis.find(e => e.name === name);
|
|
return emoji ? `` : match;
|
|
});
|
|
};
|
|
|
|
export const parseAttachment = (content) => {
|
|
if (!content || !content.startsWith('{')) return null;
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
return parsed.type === 'attachment' ? parsed : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const parseSystemMessage = (content) => {
|
|
if (!content || !content.startsWith('{')) return null;
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
return parsed.type === 'system' ? parsed : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
|
|
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
|
|
|
|
const getReactionIcon = (name) => {
|
|
switch (name) {
|
|
case 'thumbsup': return thumbsupIcon;
|
|
case 'heart': return heartIcon;
|
|
case 'fire': return fireIcon;
|
|
default: return heartIcon;
|
|
}
|
|
};
|
|
|
|
const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
|
|
<div style={{ width: size, height: size, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
|
|
<img src={src} alt="" style={color ? { width: size, height: size, transform: 'translateX(-1000px)', filter: `drop-shadow(1000px 0 0 ${color})` } : { width: size, height: size, objectFit: 'contain' }} />
|
|
</div>
|
|
);
|
|
|
|
const isNewDay = (current, previous) => {
|
|
if (!previous) return true;
|
|
return current.getDate() !== previous.getDate()
|
|
|| current.getMonth() !== previous.getMonth()
|
|
|| current.getFullYear() !== previous.getFullYear();
|
|
};
|
|
|
|
const markdownComponents = {
|
|
a: ({ node, ...props }) => {
|
|
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
|
|
return <a {...props} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
|
|
},
|
|
code({ node, inline, className, children, ...props }) {
|
|
const match = /language-(\w+)/.exec(className || '');
|
|
return !inline && match ? <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}>{String(children).replace(/\n$/, '')}</SyntaxHighlighter> : <code className={className} {...props}>{children}</code>;
|
|
},
|
|
p: ({ node, ...props }) => <p style={{ margin: '0 0 6px 0' }} {...props} />,
|
|
h1: ({ node, ...props }) => <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '12px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
|
h2: ({ node, ...props }) => <h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
|
h3: ({ node, ...props }) => <h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: '8px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
|
ul: ({ node, ...props }) => <ul style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
|
ol: ({ node, ...props }) => <ol style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
|
li: ({ node, ...props }) => <li style={{ margin: '0' }} {...props} />,
|
|
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
|
|
img: ({ node, alt, src, ...props }) => {
|
|
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
|
|
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
|
|
}
|
|
return <img alt={alt} src={src} {...props} />;
|
|
},
|
|
};
|
|
|
|
const IconButton = ({ onClick, emoji }) => (
|
|
<div onClick={(e) => { e.stopPropagation(); onClick(e); }} className="icon-button" style={{ cursor: 'pointer', padding: '6px', fontSize: '16px', lineHeight: 1, color: 'var(--header-secondary)', transition: 'background-color 0.1s' }}>
|
|
{emoji}
|
|
</div>
|
|
);
|
|
|
|
const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) => (
|
|
<div className="message-toolbar">
|
|
<Tooltip text="Thumbs Up" position="top">
|
|
<IconButton onClick={() => onAddReaction('thumbsup')} emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} />
|
|
</Tooltip>
|
|
<Tooltip text="Heart" position="top">
|
|
<IconButton onClick={() => onAddReaction('heart')} emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
|
</Tooltip>
|
|
<Tooltip text="Fire" position="top">
|
|
<IconButton onClick={() => onAddReaction('fire')} emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
|
</Tooltip>
|
|
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
|
|
<Tooltip text="Add Reaction" position="top">
|
|
<IconButton onClick={() => onAddReaction(null)} emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
|
</Tooltip>
|
|
{isOwner && (
|
|
<Tooltip text="Edit" position="top">
|
|
<IconButton onClick={onEdit} emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip text="Reply" position="top">
|
|
<IconButton onClick={onReply} emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
|
</Tooltip>
|
|
<Tooltip text="More" position="top">
|
|
<IconButton onClick={onMore} emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
|
|
const MessageItem = React.memo(({
|
|
msg,
|
|
isGrouped,
|
|
showDateDivider,
|
|
showUnreadDivider,
|
|
dateLabel,
|
|
isMentioned,
|
|
isOwner,
|
|
isEditing,
|
|
isHovered,
|
|
editInput,
|
|
username,
|
|
onHover,
|
|
onLeave,
|
|
onContextMenu,
|
|
onAddReaction,
|
|
onEdit,
|
|
onReply,
|
|
onMore,
|
|
onEditInputChange,
|
|
onEditKeyDown,
|
|
onEditSave,
|
|
onEditCancel,
|
|
onReactionClick,
|
|
onScrollToMessage,
|
|
onProfilePopup,
|
|
onImageClick,
|
|
scrollToBottom,
|
|
Attachment,
|
|
LinkPreview,
|
|
DirectVideo,
|
|
}) => {
|
|
const currentDate = new Date(msg.created_at);
|
|
const userColor = getUserColor(msg.username || 'Unknown');
|
|
|
|
const renderMessageContent = () => {
|
|
const systemMsg = parseSystemMessage(msg.content);
|
|
if (systemMsg) {
|
|
return (
|
|
<div className="system-message">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="#3ba55c" style={{ marginRight: '8px', flexShrink: 0 }}>
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
|
</svg>
|
|
<span style={{ fontStyle: 'italic', color: 'var(--header-secondary)' }}>{systemMsg.text || 'System event'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const attachmentMetadata = parseAttachment(msg.content);
|
|
if (attachmentMetadata) {
|
|
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={onImageClick} />;
|
|
}
|
|
|
|
const urls = extractUrls(msg.content);
|
|
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
|
|
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
|
|
const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
|
|
|
|
return (
|
|
<>
|
|
{!isGif && !isDirectVideo && (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
|
{formatEmojis(formatMentions(msg.content))}
|
|
</ReactMarkdown>
|
|
)}
|
|
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
|
{urls.filter(u => !(isDirectVideo && u === urls[0])).map((url, i) => (
|
|
<LinkPreview key={i} url={url} />
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderReactions = () => {
|
|
if (!msg.reactions || Object.keys(msg.reactions).length === 0) return null;
|
|
return (
|
|
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
|
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
|
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'var(--embed-background)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
|
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : 'var(--header-secondary)'} />
|
|
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<React.Fragment>
|
|
{showUnreadDivider && (
|
|
<div className="unread-divider" role="separator">
|
|
<span className="unread-pill">
|
|
<svg className="unread-pill-cap" width="8" height="13" viewBox="0 0 8 13">
|
|
<path stroke="currentColor" fill="transparent" d="M8.16639 0.5H9C10.933 0.5 12.5 2.067 12.5 4V9C12.5 10.933 10.933 12.5 9 12.5H8.16639C7.23921 12.5 6.34992 12.1321 5.69373 11.4771L0.707739 6.5L5.69373 1.52292C6.34992 0.86789 7.23921 0.5 8.16639 0.5Z" />
|
|
</svg>
|
|
NEW
|
|
</span>
|
|
</div>
|
|
)}
|
|
{showDateDivider && <div className="date-divider"><span>{dateLabel}</span></div>}
|
|
<div
|
|
id={`msg-${msg.id}`}
|
|
className={`message-item${isGrouped ? ' message-grouped' : ''}`}
|
|
style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }}
|
|
onMouseEnter={onHover}
|
|
onMouseLeave={onLeave}
|
|
onContextMenu={onContextMenu}
|
|
>
|
|
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
|
|
|
|
{msg.replyToId && msg.replyToUsername && (
|
|
<div className="message-reply-context" onClick={() => onScrollToMessage(msg.replyToId)}>
|
|
<div className="reply-spine" />
|
|
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
|
|
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
|
|
@{msg.replyToUsername}
|
|
</span>
|
|
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
|
|
</div>
|
|
)}
|
|
|
|
{isGrouped ? (
|
|
<div className="message-avatar-wrapper">
|
|
</div>
|
|
) : (
|
|
<div className="message-avatar-wrapper">
|
|
<Avatar
|
|
username={msg.username}
|
|
avatarUrl={msg.avatarUrl}
|
|
size={40}
|
|
className="message-avatar"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={(e) => onProfilePopup(e, msg)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="message-body">
|
|
{!isGrouped && (
|
|
<div className="message-header">
|
|
<span
|
|
className="username"
|
|
style={{ color: userColor, cursor: 'pointer' }}
|
|
onClick={(e) => onProfilePopup(e, msg)}
|
|
>
|
|
{msg.username || 'Unknown'}
|
|
</span>
|
|
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
|
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
|
</div>
|
|
)}
|
|
<div style={{ position: 'relative' }}>
|
|
{isEditing ? (
|
|
<div className="message-editing">
|
|
<textarea
|
|
className="message-edit-textarea"
|
|
value={editInput}
|
|
onChange={onEditInputChange}
|
|
onKeyDown={onEditKeyDown}
|
|
autoFocus
|
|
/>
|
|
<div className="message-edit-hint">
|
|
escape to <span onClick={onEditCancel}>cancel</span> · enter to <span onClick={onEditSave}>save</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="message-content">
|
|
{renderMessageContent()}
|
|
{msg.editedAt && <span className="edited-indicator">(edited)</span>}
|
|
{renderReactions()}
|
|
</div>
|
|
)}
|
|
{isHovered && !isEditing && (
|
|
<MessageToolbar isOwner={isOwner}
|
|
onAddReaction={onAddReaction}
|
|
onEdit={onEdit}
|
|
onReply={onReply}
|
|
onMore={onMore}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
}, (prevProps, nextProps) => {
|
|
return (
|
|
prevProps.msg.id === nextProps.msg.id &&
|
|
prevProps.msg.content === nextProps.msg.content &&
|
|
prevProps.msg.editedAt === nextProps.msg.editedAt &&
|
|
prevProps.msg.reactions === nextProps.msg.reactions &&
|
|
prevProps.msg.decryptedReply === nextProps.msg.decryptedReply &&
|
|
prevProps.msg.isVerified === nextProps.msg.isVerified &&
|
|
prevProps.isHovered === nextProps.isHovered &&
|
|
prevProps.isEditing === nextProps.isEditing &&
|
|
prevProps.editInput === nextProps.editInput &&
|
|
prevProps.isGrouped === nextProps.isGrouped &&
|
|
prevProps.showDateDivider === nextProps.showDateDivider &&
|
|
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
|
|
prevProps.isMentioned === nextProps.isMentioned
|
|
);
|
|
});
|
|
|
|
MessageItem.displayName = 'MessageItem';
|
|
|
|
export default MessageItem;
|