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
Some checks failed
Build and Release / build-and-release (push) Failing after 7m50s
This commit is contained in:
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
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} />
|
||||
|
||||
358
Frontend/Electron/src/components/MessageItem.jsx
Normal file
358
Frontend/Electron/src/components/MessageItem.jsx
Normal 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;
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user