From bdc16b9d3f8650bf33848de94310ed932d1d7189 Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:48:57 -0600 Subject: [PATCH] feat: Introduce comprehensive user settings, voice, chat, and screen sharing features with new components, contexts, icons, and Convex backend integrations. --- apps/electron/package.json | 2 +- convex/messages.ts | 103 +++++ convex/schema.ts | 1 + convex/voice.ts | 2 + convex/voiceState.ts | 59 ++- packages/shared/package.json | 2 +- packages/shared/src/assets/icons/create.svg | 1 + .../src/assets/icons/create_category.svg | 1 + .../shared/src/assets/icons/create_event.svg | 1 + packages/shared/src/assets/icons/gem.svg | 1 + .../shared/src/assets/icons/notifications.svg | 1 + .../src/assets/icons/notifications_off.svg | 1 + packages/shared/src/assets/icons/search.svg | 1 + packages/shared/src/assets/icons/shield.svg | 1 + packages/shared/src/assets/icons/threads.svg | 1 + packages/shared/src/components/ChatArea.jsx | 361 +++++++++++++++++- .../src/components/ScreenShareModal.jsx | 37 +- .../shared/src/components/UserSettings.jsx | 23 ++ packages/shared/src/components/VoiceRoom.jsx | 95 ----- packages/shared/src/components/VoiceStage.jsx | 77 +++- packages/shared/src/contexts/VoiceContext.jsx | 82 +++- packages/shared/src/index.css | 28 ++ 22 files changed, 755 insertions(+), 126 deletions(-) create mode 100644 packages/shared/src/assets/icons/create.svg create mode 100644 packages/shared/src/assets/icons/create_category.svg create mode 100644 packages/shared/src/assets/icons/create_event.svg create mode 100644 packages/shared/src/assets/icons/gem.svg create mode 100644 packages/shared/src/assets/icons/notifications.svg create mode 100644 packages/shared/src/assets/icons/notifications_off.svg create mode 100644 packages/shared/src/assets/icons/search.svg create mode 100644 packages/shared/src/assets/icons/shield.svg create mode 100644 packages/shared/src/assets/icons/threads.svg delete mode 100644 packages/shared/src/components/VoiceRoom.jsx diff --git a/apps/electron/package.json b/apps/electron/package.json index aac820f..d4e2110 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.23", + "version": "1.0.24", "description": "Discord Clone - Electron app", "author": "Moyettes", "type": "module", diff --git a/convex/messages.ts b/convex/messages.ts index 934e2aa..edb4267 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -224,6 +224,109 @@ export const fetchBulkPage = query({ }, }); +export const listAround = query({ + args: { + channelId: v.id("channels"), + messageId: v.id("messages"), + userId: v.optional(v.id("userProfiles")), + }, + returns: v.any(), + handler: async (ctx, args) => { + const target = await ctx.db.get(args.messageId); + if (!target || target.channelId !== args.channelId) { + return { messages: [], hasOlder: false, hasNewer: false, targetFound: false }; + } + + const targetTime = target._creationTime; + + const before = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => + q.eq("channelId", args.channelId).lt("_creationTime", targetTime) + ) + .order("desc") + .take(26); + + const after = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => + q.eq("channelId", args.channelId).gt("_creationTime", targetTime) + ) + .order("asc") + .take(26); + + const hasOlder = before.length > 25; + const hasNewer = after.length > 25; + const olderMessages = before.slice(0, 25).reverse(); + const newerMessages = after.slice(0, 25); + + const allRaw = [...olderMessages, target, ...newerMessages]; + const messages = await Promise.all( + allRaw.map((msg) => enrichMessage(ctx, msg, args.userId)) + ); + + return { messages, hasOlder, hasNewer, targetFound: true }; + }, +}); + +export const listBefore = query({ + args: { + channelId: v.id("channels"), + beforeTimestamp: v.number(), + userId: v.optional(v.id("userProfiles")), + limit: v.optional(v.number()), + }, + returns: v.any(), + handler: async (ctx, args) => { + const limit = args.limit ?? 50; + const rows = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => + q.eq("channelId", args.channelId).lt("_creationTime", args.beforeTimestamp) + ) + .order("desc") + .take(limit + 1); + + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + + const messages = await Promise.all( + page.reverse().map((msg) => enrichMessage(ctx, msg, args.userId)) + ); + + return { messages, hasMore }; + }, +}); + +export const listAfter = query({ + args: { + channelId: v.id("channels"), + afterTimestamp: v.number(), + userId: v.optional(v.id("userProfiles")), + limit: v.optional(v.number()), + }, + returns: v.any(), + handler: async (ctx, args) => { + const limit = args.limit ?? 50; + const rows = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => + q.eq("channelId", args.channelId).gt("_creationTime", args.afterTimestamp) + ) + .order("asc") + .take(limit + 1); + + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + + const messages = await Promise.all( + page.map((msg) => enrichMessage(ctx, msg, args.userId)) + ); + + return { messages, hasMore }; + }, +}); + export const remove = mutation({ args: { id: v.id("messages"), userId: v.id("userProfiles") }, returns: v.null(), diff --git a/convex/schema.ts b/convex/schema.ts index ad0d5c4..8f86ccc 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -113,6 +113,7 @@ export default defineSchema({ isScreenSharing: v.boolean(), isServerMuted: v.boolean(), watchingStream: v.optional(v.id("userProfiles")), + lastHeartbeat: v.optional(v.number()), }) .index("by_channel", ["channelId"]) .index("by_user", ["userId"]), diff --git a/convex/voice.ts b/convex/voice.ts index 3123e59..2feef65 100644 --- a/convex/voice.ts +++ b/convex/voice.ts @@ -19,6 +19,7 @@ export const getToken = action({ const at = new AccessToken(apiKey, apiSecret, { identity: args.userId, name: args.username, + ttl: "24h", }); at.addGrant({ @@ -26,6 +27,7 @@ export const getToken = action({ room: args.channelId, canPublish: true, canSubscribe: true, + canPublishData: true, }); const token = await at.toJwt(); diff --git a/convex/voiceState.ts b/convex/voiceState.ts index a3ac2fa..a89188e 100644 --- a/convex/voiceState.ts +++ b/convex/voiceState.ts @@ -1,5 +1,6 @@ -import { query, mutation } from "./_generated/server"; +import { query, mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; +import { internal } from "./_generated/api"; import { getPublicStorageUrl } from "./storageUrl"; import { getRolesForUser } from "./roles"; @@ -33,8 +34,12 @@ export const join = mutation({ isDeafened: args.isDeafened, isScreenSharing: false, isServerMuted: false, + lastHeartbeat: Date.now(), }); + // Schedule stale cleanup to run in 90 seconds + await ctx.scheduler.runAfter(90000, internal.voiceState.cleanStaleStates, {}); + return null; }, }); @@ -217,6 +222,7 @@ export const afkMove = mutation({ isDeafened: currentState.isDeafened, isScreenSharing: false, isServerMuted: currentState.isServerMuted, + lastHeartbeat: Date.now(), }); // Clear viewers watching the moved user's stream (screen sharing stops on AFK move) @@ -260,6 +266,56 @@ export const disconnectUser = mutation({ }, }); +export const heartbeat = mutation({ + args: { + userId: v.id("userProfiles"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("voiceStates") + .withIndex("by_user", (q: any) => q.eq("userId", args.userId)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { lastHeartbeat: Date.now() }); + } + + return null; + }, +}); + +export const cleanStaleStates = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + const states = await ctx.db.query("voiceStates").collect(); + const staleThreshold = Date.now() - 90_000; // 90 seconds + let hasActiveStates = false; + + for (const s of states) { + if (s.lastHeartbeat && s.lastHeartbeat < staleThreshold) { + // Clear viewers watching this user's stream + for (const other of states) { + if (other.watchingStream === s.userId && other._id !== s._id) { + await ctx.db.patch(other._id, { watchingStream: undefined }); + } + } + await ctx.db.delete(s._id); + } else { + hasActiveStates = true; + } + } + + // Re-schedule if there are still active voice states + if (hasActiveStates) { + await ctx.scheduler.runAfter(90000, internal.voiceState.cleanStaleStates, {}); + } + + return null; + }, +}); + export const moveUser = mutation({ args: { actorUserId: v.id("userProfiles"), @@ -303,6 +359,7 @@ export const moveUser = mutation({ isDeafened: currentState.isDeafened, isScreenSharing: currentState.isScreenSharing, isServerMuted: currentState.isServerMuted, + lastHeartbeat: Date.now(), }); return null; diff --git a/packages/shared/package.json b/packages/shared/package.json index 440cdf2..60501e0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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": { diff --git a/packages/shared/src/assets/icons/create.svg b/packages/shared/src/assets/icons/create.svg new file mode 100644 index 0000000..b097064 --- /dev/null +++ b/packages/shared/src/assets/icons/create.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/create_category.svg b/packages/shared/src/assets/icons/create_category.svg new file mode 100644 index 0000000..2a0246c --- /dev/null +++ b/packages/shared/src/assets/icons/create_category.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/create_event.svg b/packages/shared/src/assets/icons/create_event.svg new file mode 100644 index 0000000..fdb5a16 --- /dev/null +++ b/packages/shared/src/assets/icons/create_event.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/gem.svg b/packages/shared/src/assets/icons/gem.svg new file mode 100644 index 0000000..9ea23cd --- /dev/null +++ b/packages/shared/src/assets/icons/gem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/notifications.svg b/packages/shared/src/assets/icons/notifications.svg new file mode 100644 index 0000000..c401d4d --- /dev/null +++ b/packages/shared/src/assets/icons/notifications.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/notifications_off.svg b/packages/shared/src/assets/icons/notifications_off.svg new file mode 100644 index 0000000..10e4e2b --- /dev/null +++ b/packages/shared/src/assets/icons/notifications_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/search.svg b/packages/shared/src/assets/icons/search.svg new file mode 100644 index 0000000..6f578f6 --- /dev/null +++ b/packages/shared/src/assets/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/shield.svg b/packages/shared/src/assets/icons/shield.svg new file mode 100644 index 0000000..39dbbe0 --- /dev/null +++ b/packages/shared/src/assets/icons/shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/assets/icons/threads.svg b/packages/shared/src/assets/icons/threads.svg new file mode 100644 index 0000000..c8bcf29 --- /dev/null +++ b/packages/shared/src/assets/icons/threads.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index e566c59..2593d0a 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -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)) && (