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)) && (
)} - {status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && ( + {!focusedMode && status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
{isDM ? '@' : '#'}

@@ -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 />
- {status === 'LoadingFirstPage' ? ( + {((!focusedMode && status === 'LoadingFirstPage') || focusedLoading) ? (
@@ -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 && ( + + )}
{contextMenu && setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />} {reactionPickerMsgId && ( diff --git a/packages/shared/src/components/ScreenShareModal.jsx b/packages/shared/src/components/ScreenShareModal.jsx index 2e259fd..9a568d5 100644 --- a/packages/shared/src/components/ScreenShareModal.jsx +++ b/packages/shared/src/components/ScreenShareModal.jsx @@ -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) { diff --git a/packages/shared/src/components/UserSettings.jsx b/packages/shared/src/components/UserSettings.jsx index af75a12..01ce598 100644 --- a/packages/shared/src/components/UserSettings.jsx +++ b/packages/shared/src/components/UserSettings.jsx @@ -971,6 +971,29 @@ const VoiceVideoTab = () => {

+ + {/* Noise Suppression */} +
+ + +
); }; diff --git a/packages/shared/src/components/VoiceRoom.jsx b/packages/shared/src/components/VoiceRoom.jsx deleted file mode 100644 index 601b6b4..0000000 --- a/packages/shared/src/components/VoiceRoom.jsx +++ /dev/null @@ -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
Connecting to Voice...
; - - const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL; - - return ( -
-
-
- 🔊 - {channelName} - Connected -
- -
- -
- - - - -
-
- ); -}; - -export default VoiceRoom; diff --git a/packages/shared/src/components/VoiceStage.jsx b/packages/shared/src/components/VoiceStage.jsx index 6be2e8f..8eb4a3d 100644 --- a/packages/shared/src/components/VoiceStage.jsx +++ b/packages/shared/src/components/VoiceStage.jsx @@ -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 ( + + {[0, 1, 2, 3].map(i => ( + + ))} + + ); +}; + // --- 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 }) => { ) : isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'} {displayName} + ); @@ -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 }) => { )} + {/* Reconnection Banner */} + {isReconnecting && ( +
+
+ Reconnecting... +
+ )} + {/* Controls */}
{ 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 ( { globalOutputVolume, setGlobalOutputVolume, isReceivingScreenShareAudio, + isReconnecting, + connectionQualities, }}> {children} {room && ( diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index d1b5b87..68ed055 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -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 ============================================ */