feat: Add initial frontend components and their corresponding build assets, along with generated API types and configuration.
Some checks failed
Build and Release / build-and-release (push) Failing after 7m50s

This commit is contained in:
Bryan1029384756
2026-02-11 06:24:33 -06:00
parent cb4361da1a
commit c472f0ee2d
369 changed files with 1423 additions and 395 deletions

View File

@@ -0,0 +1,358 @@
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}](${emoji.src})` : 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;