From e6a1a761169c3d52661934ea6d90a435c2e5463d Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:57:37 -0600 Subject: [PATCH] feat: Initialize monorepo structure for Electron, Web, and Android apps with shared UI components for voice and presence. --- apps/android/android/app/build.gradle | 2 +- apps/android/package.json | 2 +- apps/electron/package.json | 2 +- apps/web/package.json | 2 +- packages/shared/package.json | 2 +- packages/shared/src/components/Sidebar.jsx | 5 +++ packages/shared/src/contexts/VoiceContext.jsx | 32 +++++++++++++++-- packages/shared/src/hooks/usePresence.js | 34 ++++++++++++++++--- 8 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index 4133f4b..f1cb336 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.0.33" + versionName "1.0.34" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/android/package.json b/apps/android/package.json index ce7c905..fc1957a 100644 --- a/apps/android/package.json +++ b/apps/android/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/android", "private": true, - "version": "1.0.33", + "version": "1.0.34", "type": "module", "scripts": { "cap:sync": "npx cap sync", diff --git a/apps/electron/package.json b/apps/electron/package.json index cd5197c..34ca27a 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.33", + "version": "1.0.34", "description": "Discord Clone - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 5a32341..d073a5f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.33", + "version": "1.0.34", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shared/package.json b/packages/shared/package.json index 267da02..4c3d59e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.33", + "version": "1.0.34", "type": "module", "main": "src/App.jsx", "dependencies": { diff --git a/packages/shared/src/components/Sidebar.jsx b/packages/shared/src/components/Sidebar.jsx index b5d1167..9d3400b 100644 --- a/packages/shared/src/components/Sidebar.jsx +++ b/packages/shared/src/components/Sidebar.jsx @@ -181,8 +181,13 @@ const UserControlPanel = React.memo(({ username, userId }) => { }; // Auto-idle detection via platform idle API + // On Capacitor (Android), skip this entirely — presence disconnect handles + // offline when not in voice, and VoiceContext AFK polling handles idle + // after 5 min of not talking when in voice. useEffect(() => { if (!idle || !userId) return; + if (window.Capacitor?.isNativePlatform?.()) return; + const handleIdleChange = (data) => { if (manualStatusRef.current) return; if (data.isIdle) { diff --git a/packages/shared/src/contexts/VoiceContext.jsx b/packages/shared/src/contexts/VoiceContext.jsx index d6b8332..88cc779 100644 --- a/packages/shared/src/contexts/VoiceContext.jsx +++ b/packages/shared/src/contexts/VoiceContext.jsx @@ -72,6 +72,7 @@ export const VoiceProvider = ({ children }) => { ); const isMovingRef = useRef(false); const isDMCallRef = useRef(false); + const lastSpokeRef = useRef(Date.now()); const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [connectionQualities, setConnectionQualities] = useState({}); @@ -245,6 +246,7 @@ export const VoiceProvider = ({ children }) => { setActiveChannelId(channelId); setActiveChannelName(channelName); setConnectionState('connecting'); + window.__inVoiceCall = true; try { // Request microphone permission (triggers Android runtime prompt) @@ -381,6 +383,7 @@ export const VoiceProvider = ({ children }) => { playSound('leave'); setConnectionState('disconnected'); setActiveChannelId(null); + window.__inVoiceCall = false; setRoom(null); setToken(null); clearSpeakerTimers(); @@ -397,6 +400,12 @@ export const VoiceProvider = ({ children }) => { newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => { const currentSpeakerIds = new Set(speakers.map(p => p.identity)); + // Track when local user last spoke (for Android AFK detection) + const localIdentity = newRoom.localParticipant?.identity; + if (localIdentity && currentSpeakerIds.has(localIdentity)) { + lastSpokeRef.current = Date.now(); + } + // Cancel pending removal timers for anyone who is speaking again for (const id of currentSpeakerIds) { const timer = speakerRemovalTimers.current.get(id); @@ -456,6 +465,7 @@ export const VoiceProvider = ({ children }) => { console.error('Voice Connection Failed:', err); setConnectionState('error'); setActiveChannelId(null); + window.__inVoiceCall = false; } }; @@ -565,16 +575,33 @@ export const VoiceProvider = ({ children }) => { // AFK idle polling: move user to AFK channel when idle exceeds timeout useEffect(() => { if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return; - if (!idle?.getSystemIdleTime) return; if (isDMCallRef.current) return; // Skip AFK for DM calls + const isCapacitor = !!window.Capacitor?.isNativePlatform?.(); + + // On desktop/web, require system idle API; on Capacitor, use lastSpokeRef + if (!isCapacitor && !idle?.getSystemIdleTime) return; + const afkTimeout = serverSettings.afkTimeout || 300; const interval = setInterval(async () => { try { - const idleSeconds = await idle.getSystemIdleTime(); + let idleSeconds; + if (isCapacitor) { + // On Android, idle = time since user last spoke in voice + idleSeconds = Math.floor((Date.now() - lastSpokeRef.current) / 1000); + } else { + idleSeconds = await idle.getSystemIdleTime(); + } + if (idleSeconds >= afkTimeout) { const userId = localStorage.getItem('userId'); if (!userId) return; + + // On Capacitor, also set user status to idle + if (isCapacitor) { + await convex.mutation(api.auth.updateStatus, { userId, status: 'idle' }); + } + await convex.mutation(api.voiceState.afkMove, { userId, afkChannelId: serverSettings.afkChannelId, @@ -766,6 +793,7 @@ export const VoiceProvider = ({ children }) => { const disconnectVoice = () => { console.log('User manually disconnected voice'); isDMCallRef.current = false; + window.__inVoiceCall = false; voiceService?.stopService(); if (room) room.disconnect(); }; diff --git a/packages/shared/src/hooks/usePresence.js b/packages/shared/src/hooks/usePresence.js index 3a1209b..6cc707b 100644 --- a/packages/shared/src/hooks/usePresence.js +++ b/packages/shared/src/hooks/usePresence.js @@ -76,12 +76,36 @@ export default function usePresence(presence, roomId, userId, interval = 10000, window.addEventListener("beforeunload", handleUnload); // Handle visibility changes. - // FIX: Do NOT disconnect when hidden. Electron timers keep running - // when minimized, so heartbeats continue normally. Only send an - // immediate heartbeat when becoming visible again to recover quickly - // in case the browser had throttled the interval. + // Desktop/web: Do NOT disconnect when hidden. Electron timers keep + // running when minimized, so heartbeats continue normally. Only send + // an immediate heartbeat when becoming visible again to recover + // quickly in case the browser had throttled the interval. + // + // Capacitor (Android): When minimized and NOT in a voice call, + // disconnect presence so the user appears offline. When in a voice + // call, keep heartbeats running (user stays online). + const isCapacitor = !!window.Capacitor?.isNativePlatform?.(); + const handleVisibility = async () => { - if (!document.hidden) { + if (document.hidden) { + // Only disconnect on Capacitor when not in a voice call + if (isCapacitor && !window.__inVoiceCall) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (sessionTokenRef.current) { + const blob = new Blob([ + JSON.stringify({ + path: "presence:disconnect", + args: { sessionToken: sessionTokenRef.current }, + }), + ], { type: "application/json" }); + navigator.sendBeacon(`${baseUrl}/api/mutation`, blob); + } + } + } else { + // Becoming visible: send immediate heartbeat + restart interval void sendHeartbeat(); if (intervalRef.current) { clearInterval(intervalRef.current);