feat: Initialize monorepo structure for Electron, Web, and Android apps with shared UI components for voice and presence.
All checks were successful
Build and Release / build-and-release (push) Successful in 14m34s
All checks were successful
Build and Release / build-and-release (push) Successful in 14m34s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/web",
|
||||
"private": true,
|
||||
"version": "1.0.33",
|
||||
"version": "1.0.34",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user