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

This commit is contained in:
Bryan1029384756
2026-02-18 14:48:57 -06:00
parent a9490f7bd4
commit bdc16b9d3f
22 changed files with 755 additions and 126 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"main": "src/App.jsx",
"dependencies": {

View File

@@ -0,0 +1 @@
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="transparent" class=""></circle><path fill="currentColor" fill-rule="evenodd" d="M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22Zm0-17a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd" class=""></path></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -0,0 +1 @@
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3h-7l-1.4-2.1A2 2 0 0 0 8.92 2H5Zm7 7a1 1 0 0 1 1 1v3h3a1 1 0 0 1 0 2h-3v3a1 1 0 0 1-2 0v-3H8a1 1 0 1 1 0-2h3v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd" class=""></path></svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -0,0 +1 @@
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M19 14a1 1 0 0 1 1 1v3h3a1 1 0 0 1 0 2h-3v3a1 1 0 0 1-2 0v-3h-3a1 1 0 1 1 0-2h3v-3a1 1 0 0 1 1-1Z" fill="currentColor" class=""></path><path fill-rule="evenodd" d="M22 13.67c0 .12-.33.17-.39.06A2.87 2.87 0 0 0 19 12a3 3 0 0 0-3 3v.5a.5.5 0 0 1-.5.5H15a3 3 0 0 0-3 3c0 1.2.7 2.1 1.73 2.61.11.06.06.39-.06.39H5a3 3 0 0 1-3-3v-9a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v3.67ZM5.5 12a.5.5 0 0 0-.5.5v3c0 .28.22.5.5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3Z" clip-rule="evenodd" fill="currentColor" class=""></path><path d="M7 1a1 1 0 0 1 1 1v.75c0 .14.11.25.25.25h7.5c.14 0 .25-.11.25-.25V2a1 1 0 1 1 2 0v.75c0 .14.11.25.25.25H19a3 3 0 0 1 3 3 1 1 0 0 1-1 1H3a1 1 0 0 1-1-1 3 3 0 0 1 3-3h.75c.14 0 .25-.11.25-.25V2a1 1 0 0 1 1-1Z" fill="currentColor" class=""></path></svg>

After

Width:  |  Height:  |  Size: 916 B

View File

@@ -0,0 +1 @@
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M11.47 6.55a.75.75 0 0 1 1.06 0l2.2 2.14c.14.14.23.34.23.55v5.52c0 .21-.09.4-.23.55l-2.2 2.14a.75.75 0 0 1-1.06 0l-2.2-2.14a.77.77 0 0 1-.23-.55V9.24c0-.21.09-.41.23-.55l2.2-2.14Z" class=""></path><path fill="currentColor" fill-rule="evenodd" d="M10.95 1.43a1.5 1.5 0 0 1 2.1 0l5.49 5.33c.3.29.46.68.46 1.1v8.44c-.04.35-.2.69-.46.94l-5.49 5.33-.11.1a1.5 1.5 0 0 1-2-.1l-5.48-5.33c-.3-.29-.46-.68-.46-1.1V7.86c0-.42.17-.81.46-1.1l5.49-5.33ZM7.29 7.76c-.2.2-.3.46-.3.73v7.02c0 .27.1.54.3.73l4 3.9a1 1 0 0 0 1.41 0l4-3.9c.2-.2.31-.46.31-.73V8.49c0-.27-.1-.54-.3-.73l-4-3.9a1 1 0 0 0-1.41 0l-4 3.9Z" clip-rule="evenodd" class=""></path></svg>

After

Width:  |  Height:  |  Size: 812 B

View File

@@ -0,0 +1 @@
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M9.7 2.89c.18-.07.32-.24.37-.43a2 2 0 0 1 3.86 0c.05.2.19.36.38.43A7 7 0 0 1 19 9.5v2.09c0 .12.05.24.13.33l1.1 1.22a3 3 0 0 1 .77 2.01v.28c0 .67-.34 1.29-.95 1.56-1.31.6-4 1.51-8.05 1.51-4.05 0-6.74-.91-8.05-1.5-.61-.28-.95-.9-.95-1.57v-.28a3 3 0 0 1 .77-2l1.1-1.23a.5.5 0 0 0 .13-.33V9.5a7 7 0 0 1 4.7-6.61ZM9.18 19.84A.16.16 0 0 0 9 20a3 3 0 1 0 6 0c0-.1-.09-.17-.18-.16a24.86 24.86 0 0 1-5.64 0Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1 @@
<svg x="0" y="0" class="icon__9293f" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M1.3 21.3a1 1 0 1 0 1.4 1.4l20-20a1 1 0 0 0-1.4-1.4l-20 20ZM3.13 16.13c.11.27.46.28.66.08L15.73 4.27a.47.47 0 0 0-.07-.74 6.97 6.97 0 0 0-1.35-.64.62.62 0 0 1-.38-.43 2 2 0 0 0-3.86 0 .62.62 0 0 1-.38.43A7 7 0 0 0 5 9.5v2.09a.5.5 0 0 1-.13.33l-1.1 1.22A3 3 0 0 0 3 15.15v.28c0 .24.04.48.13.7ZM18.64 9.36c.13-.13.36-.05.36.14v2.09c0 .12.05.24.13.33l1.1 1.22a3 3 0 0 1 .77 2.01v.28c0 .67-.34 1.29-.95 1.56-1.31.6-4 1.51-8.05 1.51-.46 0-.9-.01-1.33-.03a.48.48 0 0 1-.3-.83l8.27-8.28ZM9.18 19.84A.16.16 0 0 0 9 20a3 3 0 1 0 6 0c0-.1-.09-.17-.18-.16a24.84 24.84 0 0 1-5.64 0Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 780 B

View File

@@ -0,0 +1 @@
<svg class="icon__0c4c4 visible__0c4c4" aria-label="Search" aria-hidden="false" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M15.62 17.03a9 9 0 1 1 1.41-1.41l4.68 4.67a1 1 0 0 1-1.42 1.42l-4.67-4.68ZM17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" clip-rule="evenodd" class=""></path></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1 @@
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M17.05 8.76a4.7 4.7 0 0 1-1.29-.88c-.36-.33-.7-.73-.88-1.13-.33-.73-.16-.77-1.08-.77h-3.64c-.91 0-.74.04-1.08.77a4.94 4.94 0 0 1-2.17 2.01c-.74.33-.85.14-.86.82 0 .88-.22 3.34 1.78 5.19a13.68 13.68 0 0 0 3.98 2.47c.09.03.08.04.17.04.09 0 .08 0 .17-.04.76-.25 2.67-1.25 3.98-2.47 2-1.85 1.78-4.3 1.78-5.19-.01-.68-.12-.49-.86-.82Z" class=""></path><path fill="currentColor" d="M19.91 5.41c-.84 0-1.52-.65-1.52-1.46v-.3c0-.9-.77-1.65-1.71-1.65H7.31c-.94 0-1.71.74-1.71 1.65v.3c0 .81-.68 1.46-1.52 1.46H3.7c-.94 0-1.7.73-1.7 1.64v3.52l.01.49c.05 3.11.94 4.69 2.92 6.63C6.72 19.46 11.58 22 11.99 22c.41 0 5.27-2.54 7.06-4.31 1.98-1.95 2.92-3.53 2.92-6.63L22 7.05c0-.9-.76-1.64-1.7-1.64h-.39Zm-2.18 10.74a21.76 21.76 0 0 1-5.58 3.38c-.1.03-.2.03-.31 0a21.76 21.76 0 0 1-5.58-3.38c-1.15-1.08-2.12-2.37-2.12-5.29v-.33l.03-2.57c0-.31.26-.57.59-.57a2.88 2.88 0 0 0 2.89-2.8v-.03c0-.3.26-.55.59-.55h7.56c.32 0 .59.25.59.55v.03c0 1.53 1.3 2.77 2.89 2.8.32 0 .59.26.59.57l.03 2.57v.34c0 2.92-.98 4.2-2.14 5.28h-.03Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg x="0" y="0" class="icon__9293f" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M12 2.81a1 1 0 0 1 0-1.41l.36-.36a1 1 0 0 1 1.41 0l9.2 9.2a1 1 0 0 1 0 1.4l-.7.7a1 1 0 0 1-1.3.13l-9.54-6.72a1 1 0 0 1-.08-1.58l1-1L12 2.8ZM12 21.2a1 1 0 0 1 0 1.41l-.35.35a1 1 0 0 1-1.41 0l-9.2-9.19a1 1 0 0 1 0-1.41l.7-.7a1 1 0 0 1 1.3-.12l9.54 6.72a1 1 0 0 1 .07 1.58l-1 1 .35.36ZM15.66 16.8a1 1 0 0 1-1.38.28l-8.49-5.66A1 1 0 1 1 6.9 9.76l8.49 5.65a1 1 0 0 1 .27 1.39ZM17.1 14.25a1 1 0 1 0 1.11-1.66L9.73 6.93a1 1 0 0 0-1.11 1.66l8.49 5.66Z" fill="currentColor" class=""></path></svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -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 && (

View File

@@ -12,12 +12,21 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
loadSources();
}, []);
const [isWebFallback, setIsWebFallback] = useState(false);
const loadSources = async () => {
setLoading(true);
try {
// Get screen/window sources from Electron
const desktopSources = await screenCapture.getScreenSources();
// If no desktop sources (web platform), use getDisplayMedia fallback
if (!desktopSources || desktopSources.length === 0) {
setIsWebFallback(true);
setLoading(false);
return;
}
// Get video input devices (webcams)
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(d => d.kind === 'videoinput');
@@ -25,7 +34,7 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
// Categorize
const apps = desktopSources.filter(s => s.id.startsWith('window'));
const screens = desktopSources.filter(s => s.id.startsWith('screen'));
const formattedDevices = videoDevices.map(d => ({
id: d.deviceId,
name: d.label || `Camera ${d.deviceId.substring(0, 4)}...`,
@@ -40,11 +49,35 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
});
} catch (err) {
console.error("Failed to load sources:", err);
setIsWebFallback(true);
} finally {
setLoading(false);
}
};
const handleWebFallback = async () => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 60, max: 60 } },
audio: shareAudio,
});
onSelectSource({ type: 'web_stream', stream, shareAudio });
onClose();
} catch (err) {
if (err.name !== 'NotAllowedError') {
console.error('getDisplayMedia failed:', err);
}
onClose();
}
};
// Auto-trigger the browser picker on web
useEffect(() => {
if (isWebFallback) {
handleWebFallback();
}
}, [isWebFallback]);
const handleSelect = (source) => {
// If device, pass constraints differently (webcams don't have loopback audio)
if (source.isDevice) {

View File

@@ -971,6 +971,29 @@ const VoiceVideoTab = () => {
</div>
</div>
</div>
{/* Noise Suppression */}
<div style={{ marginBottom: '24px' }}>
<label style={labelStyle}>Voice Processing</label>
<label style={{
display: 'flex', alignItems: 'center', gap: '10px',
cursor: 'pointer', color: 'var(--text-normal)', fontSize: '14px',
userSelect: 'none',
}}>
<input
type="checkbox"
checked={localStorage.getItem('voiceNoiseSuppression') !== 'false'}
onChange={(e) => {
localStorage.setItem('voiceNoiseSuppression', String(e.target.checked));
}}
style={{ accentColor: 'var(--brand-experiment)', width: '18px', height: '18px', cursor: 'pointer' }}
/>
Noise Suppression
<span style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
Reduces background noise like keyboard clicks and fans. Takes effect on next voice connect.
</span>
</label>
</div>
</div>
);
};

View File

@@ -1,95 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
LiveKitRoom,
VideoConference,
RoomAudioRenderer,
} from '@livekit/components-react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import '@livekit/components-styles';
import { Track } from 'livekit-client';
const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
const [token, setToken] = useState('');
const convex = useConvex();
useEffect(() => {
const fetchToken = async () => {
try {
const { token: lkToken } = await convex.action(api.voice.getToken, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown'
});
if (lkToken) {
setToken(lkToken);
} else {
console.error('Failed to get token');
onDisconnect();
}
} catch (err) {
console.error(err);
onDisconnect();
}
};
if (channelId && userId) {
fetchToken();
}
}, [channelId, userId]);
if (!token) return <div style={{ color: 'white', padding: 20 }}>Connecting to Voice...</div>;
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
<div style={{
padding: '10px 20px',
background: '#1a1b1e',
color: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #2f3136'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🔊</span>
<span style={{ fontWeight: 'bold' }}>{channelName}</span>
<span style={{ fontSize: 12, color: '#43b581', marginLeft: 8 }}>Connected</span>
</div>
<button
onClick={onDisconnect}
style={{
background: '#ed4245',
border: 'none',
color: 'white',
padding: '6px 12px',
borderRadius: 4,
cursor: 'pointer',
fontWeight: 'bold'
}}
>
Disconnect
</button>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<LiveKitRoom
video={false}
audio={true}
token={token}
serverUrl={liveKitUrl}
data-lk-theme="default"
style={{ height: '100%' }}
onDisconnected={onDisconnect}
>
<VideoConference />
<RoomAudioRenderer />
</LiveKitRoom>
</div>
</div>
);
};
export default VoiceRoom;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Track, RoomEvent } from 'livekit-client';
import { Track, RoomEvent, ConnectionQuality } from 'livekit-client';
import { useVoice } from '../contexts/VoiceContext';
import ScreenShareModal from './ScreenShareModal';
import Avatar from './Avatar';
@@ -44,11 +44,49 @@ const WATCH_STREAM_BUTTON_STYLE = {
const THUMBNAIL_SIZE = { width: 120, height: 68 };
const BOTTOM_BAR_HEIGHT = 140;
const ConnectionQualityIcon = ({ quality }) => {
const getColor = () => {
switch (quality) {
case ConnectionQuality.Excellent: return '#3ba55d';
case ConnectionQuality.Good: return '#3ba55d';
case ConnectionQuality.Poor: return '#faa61a';
case ConnectionQuality.Lost: return '#ed4245';
default: return '#72767d';
}
};
const getBars = () => {
switch (quality) {
case ConnectionQuality.Excellent: return 4;
case ConnectionQuality.Good: return 3;
case ConnectionQuality.Poor: return 2;
case ConnectionQuality.Lost: return 1;
default: return 0;
}
};
const color = getColor();
const bars = getBars();
return (
<svg width="16" height="14" viewBox="0 0 16 14" style={{ flexShrink: 0 }}>
{[0, 1, 2, 3].map(i => (
<rect
key={i}
x={i * 4}
y={10 - i * 3}
width="3"
height={4 + i * 3}
rx="0.5"
fill={i < bars ? color : 'rgba(255,255,255,0.2)'}
/>
))}
</svg>
);
};
// --- Components ---
const ParticipantTile = ({ participant, username, avatarUrl }) => {
const cameraTrack = useParticipantTrack(participant, 'camera');
const { isPersonallyMuted, voiceStates } = useVoice();
const { isPersonallyMuted, voiceStates, connectionQualities } = useVoice();
const isMicEnabled = participant.isMicrophoneEnabled;
const isPersonalMuted = isPersonallyMuted(participant.identity);
const displayName = username || participant.identity;
@@ -111,6 +149,7 @@ const ParticipantTile = ({ participant, username, avatarUrl }) => {
<ColoredIcon src={personalMuteIcon} color="white" size="16px" />
) : isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
{displayName}
<ConnectionQualityIcon quality={connectionQualities[participant.identity]} />
</div>
</div>
);
@@ -581,7 +620,7 @@ const FocusedStreamView = ({
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const [participants, setParticipants] = useState([]);
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio } = useVoice();
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio, isReconnecting } = useVoice();
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
const screenShareAudioTrackRef = useRef(null);
@@ -670,7 +709,10 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
await room.localParticipant.setScreenShareEnabled(false);
}
let stream;
if (selection.type === 'device') {
if (selection.type === 'web_stream') {
// Web fallback: stream already obtained via getDisplayMedia
stream = selection.stream;
} else if (selection.type === 'device') {
stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: selection.deviceId } },
audio: false
@@ -694,7 +736,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
maxWidth: 1920,
minHeight: 720,
maxHeight: 1080,
maxFrameRate: 30
maxFrameRate: 60
}
}
});
@@ -712,7 +754,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
maxWidth: 1920,
minHeight: 720,
maxHeight: 1080,
maxFrameRate: 30
maxFrameRate: 60
}
}
});
@@ -934,6 +976,29 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
</div>
)}
{/* Reconnection Banner */}
{isReconnecting && (
<div style={{
backgroundColor: '#faa61a',
color: '#000',
textAlign: 'center',
padding: '6px 12px',
fontSize: '13px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}>
<div style={{
width: '14px', height: '14px', borderRadius: '50%',
border: '2px solid #000', borderTopColor: 'transparent',
animation: 'spin 0.8s linear infinite',
}} />
Reconnecting...
</div>
)}
{/* Controls */}
<div style={{
height: '80px',

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import { Room, RoomEvent } from 'livekit-client';
import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client';
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
@@ -66,6 +66,8 @@ export const VoiceProvider = ({ children }) => {
const isMovingRef = useRef(false);
const isDMCallRef = useRef(false);
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [connectionQualities, setConnectionQualities] = useState({});
const convex = useConvex();
@@ -119,7 +121,7 @@ export const VoiceProvider = ({ children }) => {
// Apply volume to LiveKit participant (factoring in global output volume)
const participant = room?.remoteParticipants?.get(userId);
const globalVol = globalOutputVolume / 100;
if (participant) participant.setVolume(Math.min(1, (volume / 100) * globalVol));
if (participant) participant.setVolume((volume / 100) * globalVol);
// Sync personal mute state
if (volume === 0) {
setPersonallyMutedUsers(prev => {
@@ -153,7 +155,7 @@ export const VoiceProvider = ({ children }) => {
const vol = userVolumes[userId] ?? 100;
const restoreVol = vol === 0 ? 100 : vol;
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(Math.min(1, (restoreVol / 100) * globalVol));
if (participant) participant.setVolume((restoreVol / 100) * globalVol);
// Update stored volume if it was 0
if (vol === 0) {
setUserVolumes(p => {
@@ -251,6 +253,8 @@ export const VoiceProvider = ({ children }) => {
const storedInputDevice = localStorage.getItem('voiceInputDevice');
const storedOutputDevice = localStorage.getItem('voiceOutputDevice');
const noiseSuppression = localStorage.getItem('voiceNoiseSuppression') !== 'false'; // default true
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
@@ -258,20 +262,27 @@ export const VoiceProvider = ({ children }) => {
audioCaptureDefaults: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: false,
noiseSuppression,
channelCount: 1,
sampleRate: 48000,
...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}),
},
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution,
},
publishDefaults: {
audioPreset: { maxBitrate: 96_000 },
dtx: false,
red: true,
videoEncoding: VideoPresets.h720.encoding,
videoCodec: 'vp9',
screenShareEncoding: {
maxBitrate: 10_000_000,
maxFramerate: 60,
},
screenShareSimulcastLayers: [],
screenShareSimulcastLayers: [
{ maxBitrate: 2_000_000, maxFramerate: 15, width: 1280, height: 720 },
],
},
});
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
@@ -286,7 +297,6 @@ export const VoiceProvider = ({ children }) => {
setRoom(newRoom);
setConnectionState('connected');
window.voiceRoom = newRoom;
// Play custom join sound if set, otherwise default
if (myJoinSoundUrl) {
playSoundUrl(myJoinSoundUrl);
@@ -316,14 +326,32 @@ export const VoiceProvider = ({ children }) => {
setRoom(null);
setToken(null);
setActiveSpeakers(new Set());
setConnectionQualities({});
return;
}
// Auto-reconnect on token expiry
if (reason === DisconnectReason.TOKEN_EXPIRED) {
console.log('Token expired, auto-reconnecting...');
setRoom(null);
setToken(null);
setActiveSpeakers(new Set());
setConnectionQualities({});
try {
await connectToVoice(channelId, channelName, userId, isDMCallRef.current);
} catch (e) {
console.error('Auto-reconnect failed:', e);
}
return;
}
playSound('leave');
setConnectionState('disconnected');
setActiveChannelId(null);
setRoom(null);
setToken(null);
setActiveSpeakers(new Set());
setConnectionQualities({});
try {
await convex.mutation(api.voiceState.leave, { userId });
@@ -336,6 +364,25 @@ export const VoiceProvider = ({ children }) => {
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
});
newRoom.on(RoomEvent.Reconnecting, () => {
console.warn('Voice room reconnecting...');
setIsReconnecting(true);
setConnectionState('reconnecting');
});
newRoom.on(RoomEvent.Reconnected, () => {
console.log('Voice room reconnected');
setIsReconnecting(false);
setConnectionState('connected');
});
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
setConnectionQualities(prev => ({
...prev,
[participant.identity]: quality,
}));
});
} catch (err) {
console.error('Voice Connection Failed:', err);
setConnectionState('error');
@@ -343,6 +390,24 @@ export const VoiceProvider = ({ children }) => {
}
};
// Heartbeat: send periodic heartbeat to prevent ghost voice states
useEffect(() => {
if (!activeChannelId) return;
const userId = localStorage.getItem('userId');
if (!userId) return;
const sendHeartbeat = () => {
convex.mutation(api.voiceState.heartbeat, { userId }).catch(e =>
console.warn('Heartbeat failed:', e)
);
};
// Send immediately, then every 30 seconds
sendHeartbeat();
const interval = setInterval(sendHeartbeat, 30_000);
return () => clearInterval(interval);
}, [activeChannelId, convex]);
// Detect when another user moves us to a different voice channel
useEffect(() => {
const myUserId = localStorage.getItem('userId');
@@ -395,7 +460,7 @@ export const VoiceProvider = ({ children }) => {
participant.setVolume(0);
} else {
const userVol = (userVolumes[identity] ?? 100) / 100;
participant.setVolume(Math.min(1, userVol * globalVol));
participant.setVolume(userVol * globalVol);
}
}
};
@@ -648,6 +713,7 @@ export const VoiceProvider = ({ children }) => {
}
}, [room]);
return (
<VoiceContext.Provider value={{
activeChannelId,
@@ -682,6 +748,8 @@ export const VoiceProvider = ({ children }) => {
globalOutputVolume,
setGlobalOutputVolume,
isReceivingScreenShareAudio,
isReconnecting,
connectionQualities,
}}>
{children}
{room && (

View File

@@ -1938,6 +1938,34 @@ body {
animation: messageHighlight 2s ease;
}
/* ============================================
JUMP TO PRESENT BUTTON
============================================ */
.jump-to-present-btn {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
padding: 6px 16px;
border-radius: 24px;
background-color: var(--brand-experiment, #5865f2);
color: #ffffff;
font-size: 14px;
font-weight: 500;
border: none;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: background-color 0.15s ease;
font-family: inherit;
}
.jump-to-present-btn:hover {
background-color: #4752c4;
}
/* ============================================
SYSTEM MESSAGES
============================================ */