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

@@ -1,10 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
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 {
GifIcon,
StickerIcon,
@@ -13,7 +9,6 @@ import {
EmojiesGreyscale,
EditIcon,
ReplyIcon,
MoreIcon,
DeleteIcon,
PinIcon,
TypingIcon,
@@ -22,22 +17,37 @@ import {
} from '../assets/icons';
import PingSound from '../assets/sounds/ping.mp3';
import CategorizedEmojis, { AllEmojis, getEmojiUrl } from '../assets/emojis';
const fireIcon = getEmojiUrl('nature', 'fire');
const heartIcon = getEmojiUrl('symbols', 'heart');
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
import GifPicker from './GifPicker';
import PinnedMessagesPanel from './PinnedMessagesPanel';
import Tooltip from './Tooltip';
import UserProfilePopup from './UserProfilePopup';
import Avatar from './Avatar';
import MentionMenu from './MentionMenu';
import MessageItem, { getUserColor } from './MessageItem';
const metadataCache = new Map();
const attachmentCache = new Map();
// Persistent global decryption cache (survives channel switches)
// Keyed by message _id, stores { content, isVerified, decryptedReply }
const messageDecryptionCache = new Map();
const MESSAGE_CACHE_MAX = 2000;
function evictCacheIfNeeded() {
if (messageDecryptionCache.size <= MESSAGE_CACHE_MAX) return;
const keysToDelete = [...messageDecryptionCache.keys()].slice(0, messageDecryptionCache.size - MESSAGE_CACHE_MAX);
for (const key of keysToDelete) {
messageDecryptionCache.delete(key);
}
}
// Exported for logout clearing
export function clearMessageDecryptionCache() {
messageDecryptionCache.clear();
}
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_DANGER = 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
const fromHexString = (hexString) =>
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
@@ -45,26 +55,6 @@ const fromHexString = (hexString) =>
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
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];
};
const getReactionIcon = (name) => {
switch (name) {
case 'thumbsup': return thumbsupIcon;
case 'heart': return heartIcon;
case 'fire': return fireIcon;
default: return heartIcon;
}
};
const extractUrls = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
};
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
@@ -99,19 +89,6 @@ const getYouTubeId = (link) => {
return (match && match[2].length === 11) ? match[2] : null;
};
const formatMentions = (text) => {
if (!text) return '';
return text.replace(/@(\w+)/g, '[@$1](mention://$1)');
};
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;
});
};
const filterMembersForMention = (members, query) => {
if (!members) return [];
const q = query.toLowerCase();
@@ -273,7 +250,6 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
if (attachmentCache.has(metadata.url)) {
setUrl(attachmentCache.get(metadata.url));
setLoading(false);
setTimeout(onLoad, 100);
return;
}
@@ -299,7 +275,6 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
attachmentCache.set(metadata.url, objectUrl);
setUrl(objectUrl);
setLoading(false);
setTimeout(onLoad, 100);
}
} catch (err) {
console.error('Attachment decrypt error:', err);
@@ -423,41 +398,6 @@ const EmojiButton = ({ onClick, active }) => {
);
};
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 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 MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
@@ -499,51 +439,6 @@ const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
</div>
);
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 parseAttachment = (content) => {
if (!content || !content.startsWith('{')) return null;
try {
const parsed = JSON.parse(content);
return parsed.type === 'attachment' ? parsed : null;
} catch (e) {
return null;
}
};
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 ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState('');
@@ -562,6 +457,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const [profilePopup, setProfilePopup] = useState(null);
const [mentionQuery, setMentionQuery] = useState(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
@@ -570,13 +466,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const decryptionCacheRef = useRef(new Map());
const isInitialLoadRef = useRef(true);
const prevScrollHeightRef = useRef(0);
const isLoadingMoreRef = useRef(false);
const userSentMessageRef = useRef(false);
const topSentinelRef = useRef(null);
const prevResultsLengthRef = useRef(0);
const convex = useConvex();
@@ -601,6 +495,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const removeReaction = useMutation(api.reactions.remove);
const startTypingMutation = useMutation(api.typing.startTyping);
const stopTypingMutation = useMutation(api.typing.stopTyping);
const markReadMutation = useMutation(api.readState.markRead);
const readState = useQuery(
api.readState.getReadState,
channelId && currentUserId ? { userId: currentUserId, channelId } : "skip"
);
const showGifPicker = pickerTab !== null;
@@ -610,39 +510,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return () => window.removeEventListener('click', handleClickOutside);
}, [showGifPicker]);
const decryptMessage = async (msg) => {
try {
const TAG_LENGTH = 32;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) return '[Invalid Encrypted Message]';
if (!channelKey) return '[Encrypted Message - Key Missing]';
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
return await window.cryptoAPI.decryptData(content, channelKey, msg.nonce, tag);
} catch (e) {
console.error('Decryption failed for msg:', msg.id, e);
return '[Decryption Error]';
}
};
const decryptReplyContent = async (ciphertext, nonce) => {
try {
if (!ciphertext || !nonce || !channelKey) return null;
const TAG_LENGTH = 32;
const tag = ciphertext.slice(-TAG_LENGTH);
const content = ciphertext.slice(0, -TAG_LENGTH);
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, nonce, tag);
if (decrypted.startsWith('{')) return '[Attachment]';
return decrypted.length > 100 ? decrypted.substring(0, 100) + '...' : decrypted;
} catch {
return '[Encrypted]';
}
};
const verifyMessage = async (msg) => {
if (!msg.signature || !msg.public_signing_key) return false;
try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); }
catch (e) { console.error('Verification error for msg:', msg.id, e); return false; }
};
const TAG_LENGTH = 32;
useEffect(() => {
if (!rawMessages || rawMessages.length === 0) {
@@ -650,35 +518,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return;
}
const cache = decryptionCacheRef.current;
let cancelled = false;
const processMessages = async () => {
const needsDecryption = rawMessages.filter(msg => {
const cached = cache.get(msg.id);
if (!cached) return true;
// Re-decrypt if reply nonce is now available but reply wasn't decrypted
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
return false;
});
if (needsDecryption.length > 0) {
await Promise.all(needsDecryption.map(async (msg) => {
const content = await decryptMessage(msg);
const isVerified = await verifyMessage(msg);
let decryptedReply = null;
if (msg.replyToContent && msg.replyToNonce) {
decryptedReply = await decryptReplyContent(msg.replyToContent, msg.replyToNonce);
}
if (!cancelled) {
cache.set(msg.id, { content, isVerified, decryptedReply });
}
}));
}
if (cancelled) return;
const processed = [...rawMessages].reverse().map(msg => {
const cached = cache.get(msg.id);
// Phase 1: Immediately render from cache (cached = content, uncached = "[Decrypting...]")
const buildFromCache = () => {
return [...rawMessages].reverse().map(msg => {
const cached = messageDecryptionCache.get(msg.id);
return {
...msg,
content: cached?.content ?? '[Decrypting...]',
@@ -686,35 +531,184 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
decryptedReply: cached?.decryptedReply ?? null,
};
});
setDecryptedMessages(processed);
};
processMessages();
setDecryptedMessages(buildFromCache());
// Phase 2: Batch-decrypt only uncached messages in background
const processUncached = async () => {
if (!channelKey) return;
// Optimistic: check if any uncached messages match ciphertext from our own sends
for (const msg of rawMessages) {
if (messageDecryptionCache.has(msg.id)) continue;
const plaintext = optimisticMapRef.current.get(msg.ciphertext);
if (plaintext) {
messageDecryptionCache.set(msg.id, { content: plaintext, isVerified: true, decryptedReply: null });
optimisticMapRef.current.delete(msg.ciphertext);
}
}
const needsDecryption = rawMessages.filter(msg => {
const cached = messageDecryptionCache.get(msg.id);
if (!cached) return true;
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
return false;
});
if (needsDecryption.length === 0) {
// Still re-render from cache in case optimistic matches were added
if (!cancelled) setDecryptedMessages(buildFromCache());
return;
}
// Build batch arrays for decrypt and verify
const decryptItems = [];
const decryptMsgMap = []; // parallel array to track which msg each item belongs to
const replyDecryptItems = [];
const replyMsgMap = [];
const verifyItems = [];
const verifyMsgMap = [];
for (const msg of needsDecryption) {
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
decryptMsgMap.push(msg);
}
if (msg.replyToContent && msg.replyToNonce) {
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
replyMsgMap.push(msg);
}
if (msg.signature && msg.public_signing_key) {
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
verifyMsgMap.push(msg);
}
}
// Execute batch IPC calls in parallel (2-3 calls instead of 100+)
const [decryptResults, replyResults, verifyResults] = await Promise.all([
decryptItems.length > 0
? window.cryptoAPI.decryptBatch(decryptItems)
: [],
replyDecryptItems.length > 0
? window.cryptoAPI.decryptBatch(replyDecryptItems)
: [],
verifyItems.length > 0
? window.cryptoAPI.verifyBatch(verifyItems)
: [],
]);
if (cancelled) return;
// Build lookup maps from batch results
const decryptedMap = new Map();
for (let i = 0; i < decryptResults.length; i++) {
const msg = decryptMsgMap[i];
const result = decryptResults[i];
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
}
const replyMap = new Map();
for (let i = 0; i < replyResults.length; i++) {
const msg = replyMsgMap[i];
const result = replyResults[i];
if (result.success) {
let text = result.data;
if (text.startsWith('{')) text = '[Attachment]';
else if (text.length > 100) text = text.substring(0, 100) + '...';
replyMap.set(msg.id, text);
} else {
replyMap.set(msg.id, '[Encrypted]');
}
}
const verifyMap = new Map();
for (let i = 0; i < verifyResults.length; i++) {
const msg = verifyMsgMap[i];
verifyMap.set(msg.id, verifyResults[i].success && verifyResults[i].verified);
}
// Populate cache
for (const msg of needsDecryption) {
const content = decryptedMap.get(msg.id) ??
(msg.ciphertext && msg.ciphertext.length < TAG_LENGTH ? '[Invalid Encrypted Message]' : '[Encrypted Message - Key Missing]');
const isVerified = verifyMap.get(msg.id) ?? false;
const decryptedReply = replyMap.get(msg.id) ?? null;
messageDecryptionCache.set(msg.id, { content, isVerified, decryptedReply });
}
evictCacheIfNeeded();
if (cancelled) return;
// Phase 3: Re-render with newly decrypted content
setDecryptedMessages(buildFromCache());
};
processUncached();
return () => { cancelled = true; };
}, [rawMessages, channelKey]);
useEffect(() => {
decryptionCacheRef.current.clear();
// Don't clear messageDecryptionCache — it persists across channel switches
setDecryptedMessages([]);
isInitialLoadRef.current = true;
prevResultsLengthRef.current = 0;
setReplyingTo(null);
setEditingMessage(null);
setMentionQuery(null);
setUnreadDividerTimestamp(null);
onTogglePinned();
}, [channelId, channelKey]);
// Capture the unread divider position when read state loads for a channel
const unreadDividerCapturedRef = useRef(null);
useEffect(() => {
if (!channelId) return;
// Reset when channel changes
unreadDividerCapturedRef.current = null;
setUnreadDividerTimestamp(null);
}, [channelId]);
useEffect(() => {
if (unreadDividerCapturedRef.current === channelId) return;
if (readState === undefined) return; // still loading
if (readState === null) {
// Never read this channel — no divider needed (first visit)
unreadDividerCapturedRef.current = channelId;
return;
}
unreadDividerCapturedRef.current = channelId;
setUnreadDividerTimestamp(readState.lastReadTimestamp);
}, [readState, channelId]);
// Mark channel as read when scrolled to bottom
const markChannelAsRead = useCallback(() => {
if (!currentUserId || !channelId || !decryptedMessages.length) return;
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
if (!lastMsg?.created_at) return;
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: lastMsg.created_at }).catch(() => {});
setUnreadDividerTimestamp(null);
}, [currentUserId, channelId, decryptedMessages, markReadMutation]);
const typingUsers = typingData.filter(t => t.username !== username);
const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : [];
const scrollToBottom = useCallback((force = false) => {
if (force) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); return; }
const container = messagesContainerRef.current;
if (container) {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
} else {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
if (!container) return;
if (force) {
container.scrollTop = container.scrollHeight;
return;
}
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
container.scrollTop = container.scrollHeight;
}
}, []);
@@ -722,39 +716,27 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const container = messagesContainerRef.current;
if (!container || decryptedMessages.length === 0) return;
if (isInitialLoadRef.current) {
container.scrollTop = container.scrollHeight;
isInitialLoadRef.current = false;
prevResultsLengthRef.current = rawMessages?.length || 0;
return;
}
if (isLoadingMoreRef.current) {
const newScrollHeight = container.scrollHeight;
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
container.scrollTop += heightDifference;
isLoadingMoreRef.current = false;
prevResultsLengthRef.current = rawMessages?.length || 0;
return;
}
if (userSentMessageRef.current) {
if (userSentMessageRef.current || isInitialLoadRef.current) {
container.scrollTop = container.scrollHeight;
userSentMessageRef.current = false;
prevResultsLengthRef.current = rawMessages?.length || 0;
isInitialLoadRef.current = false;
return;
}
const currentLen = rawMessages?.length || 0;
const prevLen = prevResultsLengthRef.current;
if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
// Always auto-scroll if near bottom — handles decryption content changes,
// new messages, and any height shifts
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
container.scrollTop = container.scrollHeight;
}
prevResultsLengthRef.current = currentLen;
}, [decryptedMessages, rawMessages?.length]);
useEffect(() => {
@@ -777,6 +759,32 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return () => observer.disconnect();
}, [status, loadMore]);
// Mark as read when scrolled to bottom
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 50) {
markChannelAsRead();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [markChannelAsRead]);
// Mark as read on initial load (already scrolled to bottom)
useEffect(() => {
if (!isInitialLoadRef.current && decryptedMessages.length > 0) {
const container = messagesContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 50) {
markChannelAsRead();
}
}
}, [decryptedMessages.length, markChannelAsRead]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
@@ -903,6 +911,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
await sendMessage(JSON.stringify(metadata));
};
// Store ciphertext→plaintext mapping for optimistic display
const optimisticMapRef = useRef(new Map());
const sendMessage = async (contentString, replyToId) => {
try {
if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; }
@@ -912,6 +923,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const signingKey = sessionStorage.getItem('signingKey');
if (!senderId || !signingKey) return;
// Optimistic: store ciphertext→plaintext so processMessages can skip decryption
optimisticMapRef.current.set(ciphertext, contentString);
const args = {
channelId,
senderId,
@@ -964,6 +978,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
setReplyingTo(null);
setMentionQuery(null);
markChannelAsRead();
} catch (err) {
console.error("Error sending message/files:", err);
alert("Failed to send message/files");
@@ -988,7 +1003,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
nonce: iv,
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
});
decryptionCacheRef.current.delete(editingMessage.id);
messageDecryptionCache.delete(editingMessage.id);
setEditingMessage(null);
setEditInput('');
} catch (err) {
@@ -1078,58 +1093,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
};
const renderMessageContent = (msg) => {
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={setZoomedImage} />;
}
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 = (msg) => {
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={() => handleReactionClick(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>
);
};
// Stable callbacks for MessageItem
const handleProfilePopup = useCallback((e, msg) => {
setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } });
}, []);
const isDM = channelType === 'dm';
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
@@ -1177,9 +1144,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const currentDate = new Date(msg.created_at);
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
const isMentioned = msg.content && msg.content.includes(`@${username}`);
const userColor = getUserColor(msg.username || 'Unknown');
const isOwner = msg.username === username;
const isEditing = editingMessage?.id === msg.id;
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
const isGrouped = prevMsg
@@ -1188,95 +1153,48 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
&& (currentDate - new Date(prevMsg.created_at)) < 60000
&& !msg.replyToId;
const showDateDivider = isNewDay(currentDate, previousDate);
const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '';
// Show unread divider before the first message after lastReadTimestamp
const showUnreadDivider = unreadDividerTimestamp != null
&& msg.created_at > unreadDividerTimestamp
&& (idx === 0 || decryptedMessages[idx - 1].created_at <= unreadDividerTimestamp);
return (
<React.Fragment key={msg.id || idx}>
{isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</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={() => setHoveredMessageId(msg.id)}
onMouseLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}
>
{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={() => scrollToMessage(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 grouped-timestamp-wrapper">
<span className="grouped-timestamp">
{currentDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
) : (
<div className="message-avatar-wrapper">
<Avatar
username={msg.username}
avatarUrl={msg.avatarUrl}
size={40}
className="message-avatar"
style={{ cursor: 'pointer' }}
onClick={(e) => setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } })}
/>
</div>
)}
<div className="message-body">
{!isGrouped && (
<div className="message-header">
<span
className="username"
style={{ color: userColor, cursor: 'pointer' }}
onClick={(e) => setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } })}
>
{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={(e) => setEditInput(e.target.value)}
onKeyDown={handleEditKeyDown}
autoFocus
/>
<div className="message-edit-hint">
escape to <span onClick={() => { setEditingMessage(null); setEditInput(''); }}>cancel</span> · enter to <span onClick={handleEditSave}>save</span>
</div>
</div>
) : (
<div className="message-content">
{renderMessageContent(msg)}
{msg.editedAt && <span className="edited-indicator">(edited)</span>}
{renderReactions(msg)}
</div>
)}
{hoveredMessageId === msg.id && !isEditing && (
<MessageToolbar isOwner={isOwner}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
/>
)}
</div>
</div>
</div>
</React.Fragment>
<MessageItem
key={msg.id || idx}
msg={msg}
isGrouped={isGrouped}
showDateDivider={showDateDivider}
showUnreadDivider={showUnreadDivider}
dateLabel={dateLabel}
isMentioned={isMentioned}
isOwner={isOwner}
isEditing={editingMessage?.id === msg.id}
isHovered={hoveredMessageId === msg.id}
editInput={editInput}
username={username}
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
onEditInputChange={(e) => setEditInput(e.target.value)}
onEditKeyDown={handleEditKeyDown}
onEditSave={handleEditSave}
onEditCancel={() => { setEditingMessage(null); setEditInput(''); }}
onReactionClick={handleReactionClick}
onScrollToMessage={scrollToMessage}
onProfilePopup={handleProfilePopup}
onImageClick={setZoomedImage}
scrollToBottom={scrollToBottom}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
/>
);
})}
<div ref={messagesEndRef} />

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;

View File

@@ -187,7 +187,7 @@ const UserControlPanel = ({ username, userId }) => {
)}
<div className="user-control-info" onClick={() => setShowStatusMenu(!showStatusMenu)}>
<div style={{ position: 'relative', marginRight: '8px' }}>
<Avatar username={username} size={32} />
<Avatar username={username} avatarUrl={myUser?.avatarUrl} size={32} />
<div style={{
position: 'absolute',
bottom: '-2px',
@@ -235,15 +235,6 @@ const UserControlPanel = ({ username, userId }) => {
/>
</button>
</Tooltip>
<Tooltip text="Log Out" position="top">
<button style={controlButtonStyle} onClick={handleLogout}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M16 17L21 12L16 7" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke={ICON_COLOR_DEFAULT} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</Tooltip>
</div>
{showUserSettings && (
<UserSettings
@@ -356,6 +347,32 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex();
// Unread tracking
const channelIds = React.useMemo(() => channels.map(c => c._id), [channels]);
const allReadStates = useQuery(
api.readState.getAllReadStates,
userId ? { userId } : "skip"
) || [];
const latestTimestamps = useQuery(
api.readState.getLatestMessageTimestamps,
channelIds.length > 0 ? { channelIds } : "skip"
) || [];
const unreadChannels = React.useMemo(() => {
const set = new Set();
const readMap = new Map();
for (const rs of allReadStates) {
readMap.set(rs.channelId, rs.lastReadTimestamp);
}
for (const lt of latestTimestamps) {
const lastRead = readMap.get(lt.channelId);
if (lastRead === undefined || lt.latestTimestamp > lastRead) {
set.add(lt.channelId);
}
}
return set;
}, [allReadStates, latestTimestamps]);
const onRenameChannel = () => {};
const onDeleteChannel = (id) => {
@@ -610,7 +627,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
+
</button>
</div>
{!collapsedCategories[category] && catChannels.map(channel => (
{!collapsedCategories[category] && catChannels.map(channel => {
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
return (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
@@ -623,6 +642,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
paddingRight: '8px'
}}
>
{isUnread && <div className="channel-unread-indicator" />}
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
@@ -635,7 +655,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
) : (
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
</div>
<button
@@ -661,7 +681,8 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
{renderVoiceUsers(channel)}
</React.Fragment>
))}
);
})}
</div>
))}
</div>

View File

@@ -249,9 +249,9 @@ body {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding: 0 0 20px 0;
padding: 20px 0 0 0;
display: flex;
flex-direction: column;
flex-direction: column-reverse;
}
.messages-list::-webkit-scrollbar {
@@ -533,8 +533,6 @@ body {
/* .messages-content-wrapper */
.messages-content-wrapper {
/* Use margin-top: auto to push content to bottom safely */
margin-top: auto;
display: flex;
flex-direction: column;
}
@@ -1331,6 +1329,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 8px;
}
@@ -2531,4 +2530,49 @@ body {
background: var(--header-primary);
cursor: pointer;
border: none;
}
/* ============================================
UNREAD MESSAGES DIVIDER
============================================ */
.unread-divider {
border-top: 1px solid var(--danger);
margin: 0 16px 8px;
position: relative;
height: 0;
}
.unread-pill {
position: absolute;
top: -7px;
right: 4px;
background: var(--danger);
color: #fff;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
padding: 1px 6px 1px 0;
border-radius: 0 4px 4px 0;
line-height: 13px;
display: flex;
align-items: center;
}
.unread-pill-cap {
color: var(--danger);
margin-left: -7px;
}
/* ============================================
SIDEBAR UNREAD INDICATOR
============================================ */
.channel-unread-indicator {
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 8px;
border-radius: 0 4px 4px 0;
background: var(--header-primary);
}