feat: Introduce comprehensive user settings, voice, chat, and screen sharing features with new components, contexts, icons, and Convex backend integrations.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s
This commit is contained in:
@@ -536,6 +536,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
||||
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
||||
|
||||
// Focused mode state (for jumping to old messages not in paginated view)
|
||||
const [focusedMode, setFocusedMode] = useState(false);
|
||||
const [focusedMessageId, setFocusedMessageId] = useState(null);
|
||||
const [focusedMessages, setFocusedMessages] = useState([]);
|
||||
const [focusedHasOlder, setFocusedHasOlder] = useState(false);
|
||||
const [focusedHasNewer, setFocusedHasNewer] = useState(false);
|
||||
const [focusedLoading, setFocusedLoading] = useState(false);
|
||||
const focusedModeRef = useRef(false);
|
||||
const focusedLoadingMoreRef = useRef(false);
|
||||
|
||||
const inputDivRef = useRef(null);
|
||||
const savedRangeRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
@@ -579,7 +589,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||
api.messages.list,
|
||||
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
|
||||
channelId && !focusedMode ? { channelId, userId: currentUserId || undefined } : "skip",
|
||||
{ initialNumItems: 50 }
|
||||
);
|
||||
|
||||
@@ -632,6 +642,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const TAG_LENGTH = 32;
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedModeRef.current) return;
|
||||
if (!rawMessages || rawMessages.length === 0) {
|
||||
setDecryptedMessages([]);
|
||||
return;
|
||||
@@ -880,6 +891,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setReactionPickerMsgId(null);
|
||||
setSlashQuery(null);
|
||||
setEphemeralMessages([]);
|
||||
// Reset focused mode
|
||||
setFocusedMode(false);
|
||||
focusedModeRef.current = false;
|
||||
setFocusedMessageId(null);
|
||||
setFocusedMessages([]);
|
||||
setFocusedHasOlder(false);
|
||||
setFocusedHasNewer(false);
|
||||
setFocusedLoading(false);
|
||||
focusedLoadingMoreRef.current = false;
|
||||
floodAbortRef.current = true;
|
||||
isLoadingMoreRef.current = false;
|
||||
loadMoreSettlingRef.current = false;
|
||||
@@ -900,7 +920,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
jumpToMessageIdRef.current = jumpToMessageId || null;
|
||||
}, [jumpToMessageId]);
|
||||
|
||||
// Jump to a specific message (from search results)
|
||||
// Jump to a specific message (from search results or pinned panel)
|
||||
useEffect(() => {
|
||||
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
||||
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
||||
@@ -916,16 +936,231 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
}
|
||||
}, 300);
|
||||
onClearJumpToMessage?.();
|
||||
} else if (idx === -1 && !focusedModeRef.current) {
|
||||
// Message not loaded in paginated view — enter focused mode
|
||||
scrollLog('[SCROLL:jumpToMessage] entering focused mode for', jumpToMessageId);
|
||||
setFocusedMessageId(jumpToMessageId);
|
||||
onClearJumpToMessage?.();
|
||||
}
|
||||
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
|
||||
|
||||
// Safety timeout: clear jumpToMessageId if message never found (too old / not loaded)
|
||||
// Safety timeout: clear jumpToMessageId only in normal mode
|
||||
useEffect(() => {
|
||||
if (!jumpToMessageId) return;
|
||||
if (!jumpToMessageId || focusedModeRef.current) return;
|
||||
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [jumpToMessageId, onClearJumpToMessage]);
|
||||
|
||||
// Focused mode: fetch messages around target
|
||||
useEffect(() => {
|
||||
if (!focusedMessageId || !channelId) return;
|
||||
let cancelled = false;
|
||||
setFocusedLoading(true);
|
||||
setFocusedMode(true);
|
||||
focusedModeRef.current = true;
|
||||
setDecryptedMessages([]);
|
||||
decryptionDoneRef.current = false;
|
||||
isInitialLoadRef.current = true;
|
||||
initialScrollScheduledRef.current = false;
|
||||
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||
prevMessageCountRef.current = 0;
|
||||
prevFirstMsgIdRef.current = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await convex.query(api.messages.listAround, {
|
||||
channelId,
|
||||
messageId: focusedMessageId,
|
||||
userId: currentUserId || undefined,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!result.targetFound) {
|
||||
scrollLog('[SCROLL:focusedMode] target not found');
|
||||
setFocusedLoading(false);
|
||||
// Auto-exit focused mode after brief delay
|
||||
setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setFocusedMode(false);
|
||||
focusedModeRef.current = false;
|
||||
setFocusedMessageId(null);
|
||||
setFocusedMessages([]);
|
||||
}
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
setFocusedMessages(result.messages);
|
||||
setFocusedHasOlder(result.hasOlder);
|
||||
setFocusedHasNewer(result.hasNewer);
|
||||
setFocusedLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load messages around target:', err);
|
||||
if (!cancelled) {
|
||||
setFocusedLoading(false);
|
||||
setFocusedMode(false);
|
||||
focusedModeRef.current = false;
|
||||
setFocusedMessageId(null);
|
||||
setFocusedMessages([]);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [focusedMessageId, channelId]);
|
||||
|
||||
// Focused mode: decrypt focusedMessages (parallel to normal decrypt effect)
|
||||
useEffect(() => {
|
||||
if (!focusedMode || focusedMessages.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const buildFromCache = () => {
|
||||
return focusedMessages.map(msg => {
|
||||
const cached = messageDecryptionCache.get(msg.id);
|
||||
return {
|
||||
...msg,
|
||||
content: cached?.content ?? '[Decrypting...]',
|
||||
isVerified: cached?.isVerified ?? null,
|
||||
decryptedReply: cached?.decryptedReply ?? null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const newMessages = buildFromCache();
|
||||
|
||||
// Adjust firstItemIndex for prepended messages
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const newCount = newMessages.length;
|
||||
if (newCount > prevCount && prevCount > 0) {
|
||||
if (prevFirstMsgIdRef.current && newMessages[0]?.id !== prevFirstMsgIdRef.current) {
|
||||
const prependedCount = newCount - prevCount;
|
||||
setFirstItemIndex(prev => prev - prependedCount);
|
||||
}
|
||||
}
|
||||
prevMessageCountRef.current = newCount;
|
||||
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
|
||||
|
||||
setDecryptedMessages(newMessages);
|
||||
|
||||
const processUncached = async () => {
|
||||
if (!channelKey) return;
|
||||
|
||||
const needsDecryption = focusedMessages.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) {
|
||||
if (!cancelled) {
|
||||
decryptionDoneRef.current = true;
|
||||
setDecryptedMessages(buildFromCache());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptItems = [];
|
||||
const decryptMsgMap = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const [decryptResults, replyResults, verifyResults] = await Promise.all([
|
||||
decryptItems.length > 0 ? crypto.decryptBatch(decryptItems) : [],
|
||||
replyDecryptItems.length > 0 ? crypto.decryptBatch(replyDecryptItems) : [],
|
||||
verifyItems.length > 0 ? crypto.verifyBatch(verifyItems) : [],
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
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];
|
||||
const verified = verifyResults[i].verified;
|
||||
verifyMap.set(msg.id, verified === null ? null : (verifyResults[i].success && verified));
|
||||
}
|
||||
|
||||
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.has(msg.id) ? verifyMap.get(msg.id) : null;
|
||||
const decryptedReply = replyMap.get(msg.id) ?? null;
|
||||
messageDecryptionCache.set(msg.id, { content, isVerified, decryptedReply });
|
||||
}
|
||||
|
||||
evictCacheIfNeeded();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
decryptionDoneRef.current = true;
|
||||
setDecryptedMessages(buildFromCache());
|
||||
};
|
||||
|
||||
processUncached();
|
||||
return () => { cancelled = true; };
|
||||
}, [focusedMode, focusedMessages, channelKey]);
|
||||
|
||||
// Focused mode: scroll to target message after decryption completes
|
||||
useEffect(() => {
|
||||
if (!focusedMode || !focusedMessageId || !decryptionDoneRef.current || !decryptedMessages.length) return;
|
||||
const idx = decryptedMessages.findIndex(m => m.id === focusedMessageId);
|
||||
if (idx !== -1 && virtuosoRef.current) {
|
||||
scrollLog('[SCROLL:focusedMode] scrolling to target', { focusedMessageId, idx });
|
||||
isInitialLoadRef.current = false;
|
||||
setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`msg-${focusedMessageId}`);
|
||||
if (el) {
|
||||
el.classList.add('message-highlight');
|
||||
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||
}
|
||||
}, 300);
|
||||
}, 100);
|
||||
}
|
||||
}, [focusedMode, focusedMessageId, decryptedMessages]);
|
||||
|
||||
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
||||
|
||||
const isMentionedInContent = useCallback((content) => {
|
||||
@@ -1080,6 +1315,33 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
// Virtuoso: startReached replaces IntersectionObserver
|
||||
const handleStartReached = useCallback(() => {
|
||||
if (focusedModeRef.current) {
|
||||
if (focusedLoadingMoreRef.current || !focusedHasOlder) return;
|
||||
const msgs = decryptedMessages;
|
||||
if (!msgs.length) return;
|
||||
const oldestTimestamp = new Date(msgs[0].created_at).getTime();
|
||||
focusedLoadingMoreRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await convex.query(api.messages.listBefore, {
|
||||
channelId,
|
||||
beforeTimestamp: oldestTimestamp,
|
||||
userId: currentUserId || undefined,
|
||||
});
|
||||
setFocusedMessages(prev => {
|
||||
const existingIds = new Set(prev.map(m => m.id));
|
||||
const newMsgs = result.messages.filter(m => !existingIds.has(m.id));
|
||||
return [...newMsgs, ...prev];
|
||||
});
|
||||
setFocusedHasOlder(result.hasMore);
|
||||
} catch (err) {
|
||||
console.error('Failed to load older messages:', err);
|
||||
} finally {
|
||||
focusedLoadingMoreRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (isLoadingMoreRef.current) return;
|
||||
if (statusRef.current === 'CanLoadMore') {
|
||||
isLoadingMoreRef.current = true;
|
||||
@@ -1090,11 +1352,39 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
}
|
||||
loadMoreRef.current(50);
|
||||
}
|
||||
}, []);
|
||||
}, [focusedHasOlder, decryptedMessages, channelId, currentUserId]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!focusedModeRef.current || !focusedHasNewer || focusedLoadingMoreRef.current) return;
|
||||
const msgs = decryptedMessages;
|
||||
if (!msgs.length) return;
|
||||
const newestTimestamp = new Date(msgs[msgs.length - 1].created_at).getTime();
|
||||
focusedLoadingMoreRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await convex.query(api.messages.listAfter, {
|
||||
channelId,
|
||||
afterTimestamp: newestTimestamp,
|
||||
userId: currentUserId || undefined,
|
||||
});
|
||||
setFocusedMessages(prev => {
|
||||
const existingIds = new Set(prev.map(m => m.id));
|
||||
const newMsgs = result.messages.filter(m => !existingIds.has(m.id));
|
||||
return [...prev, ...newMsgs];
|
||||
});
|
||||
setFocusedHasNewer(result.hasMore);
|
||||
} catch (err) {
|
||||
console.error('Failed to load newer messages:', err);
|
||||
} finally {
|
||||
focusedLoadingMoreRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [focusedHasNewer, decryptedMessages, channelId, currentUserId]);
|
||||
|
||||
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
||||
const followOutput = useCallback((isAtBottom) => {
|
||||
if (focusedModeRef.current) return false;
|
||||
|
||||
const metrics = {
|
||||
isAtBottom,
|
||||
userScrolledUp: userIsScrolledUpRef.current,
|
||||
@@ -1143,6 +1433,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
|
||||
const handleAtBottomStateChange = useCallback((atBottom) => {
|
||||
if (focusedModeRef.current) return;
|
||||
scrollLog('[SCROLL:atBottomStateChange]', { atBottom, settling: loadMoreSettlingRef.current, userScrolledUp: userIsScrolledUpRef.current });
|
||||
if (loadMoreSettlingRef.current && atBottom) {
|
||||
return;
|
||||
@@ -1593,6 +1884,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
}
|
||||
if (!messageContent && pendingFiles.length === 0) return;
|
||||
|
||||
// Exit focused mode when sending a message
|
||||
if (focusedModeRef.current) {
|
||||
setFocusedMode(false);
|
||||
focusedModeRef.current = false;
|
||||
setFocusedMessageId(null);
|
||||
setFocusedMessages([]);
|
||||
setFocusedHasOlder(false);
|
||||
setFocusedHasNewer(false);
|
||||
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||
prevMessageCountRef.current = 0;
|
||||
prevFirstMsgIdRef.current = null;
|
||||
}
|
||||
|
||||
// Intercept slash commands
|
||||
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
|
||||
const parts = messageContent.slice(1).split(/\s+/);
|
||||
@@ -1753,6 +2057,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
// Message not in current view — enter focused mode
|
||||
setFocusedMessageId(messageId);
|
||||
}
|
||||
}, [decryptedMessages]);
|
||||
|
||||
@@ -1761,6 +2068,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } });
|
||||
}, []);
|
||||
|
||||
const handleJumpToPresent = useCallback(() => {
|
||||
setFocusedMode(false);
|
||||
focusedModeRef.current = false;
|
||||
setFocusedMessageId(null);
|
||||
setFocusedMessages([]);
|
||||
setFocusedHasOlder(false);
|
||||
setFocusedHasNewer(false);
|
||||
setFocusedLoading(false);
|
||||
focusedLoadingMoreRef.current = false;
|
||||
setDecryptedMessages([]);
|
||||
decryptionDoneRef.current = false;
|
||||
isInitialLoadRef.current = true;
|
||||
initialScrollScheduledRef.current = false;
|
||||
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||
prevMessageCountRef.current = 0;
|
||||
prevFirstMsgIdRef.current = null;
|
||||
userIsScrolledUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
const isDM = channelType === 'dm';
|
||||
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
||||
|
||||
@@ -1790,12 +2116,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const renderListHeader = useCallback(() => {
|
||||
return (
|
||||
<>
|
||||
{status === 'LoadingMore' && (
|
||||
{(status === 'LoadingMore' || (focusedMode && focusedHasOlder)) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
||||
</div>
|
||||
)}
|
||||
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
||||
{!focusedMode && status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
||||
<div className="channel-beginning">
|
||||
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
|
||||
<h1 className="channel-beginning-title">
|
||||
@@ -1811,7 +2137,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
|
||||
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName, focusedMode, focusedHasOlder]);
|
||||
|
||||
// Stable Virtuoso components — avoids remounting Header/Footer every render
|
||||
const virtuosoComponents = useMemo(() => ({
|
||||
@@ -1953,7 +2279,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
/>
|
||||
|
||||
<div className="messages-list">
|
||||
{status === 'LoadingFirstPage' ? (
|
||||
{((!focusedMode && status === 'LoadingFirstPage') || focusedLoading) ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
@@ -1962,13 +2288,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
ref={virtuosoRef}
|
||||
scrollerRef={(el) => { scrollerElRef.current = el; }}
|
||||
firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||
alignToBottom={true}
|
||||
{...(!focusedMode ? { initialTopMostItemIndex: { index: 'LAST', align: 'end' } } : {})}
|
||||
alignToBottom={!focusedMode}
|
||||
atBottomThreshold={20}
|
||||
data={allDisplayMessages}
|
||||
startReached={handleStartReached}
|
||||
followOutput={followOutput}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
endReached={focusedMode ? handleEndReached : undefined}
|
||||
followOutput={focusedMode ? false : followOutput}
|
||||
atBottomStateChange={focusedMode ? undefined : handleAtBottomStateChange}
|
||||
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||
defaultItemHeight={60}
|
||||
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
||||
@@ -1976,6 +2303,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
||||
/>
|
||||
)}
|
||||
{focusedMode && !focusedLoading && (
|
||||
<button className="jump-to-present-btn" onClick={handleJumpToPresent}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ marginRight: '6px' }}>
|
||||
<path d="M8 12l-4.5-4.5 1.06-1.06L8 9.88l3.44-3.44 1.06 1.06z"/>
|
||||
</svg>
|
||||
Jump to Present
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} isAttachment={contextMenu.isAttachment} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
||||
{reactionPickerMsgId && (
|
||||
|
||||
Reference in New Issue
Block a user