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
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.33"
|
versionName "1.0.34"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.33",
|
"version": "1.0.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.33",
|
"version": "1.0.34",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.33",
|
"version": "1.0.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.33",
|
"version": "1.0.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -181,8 +181,13 @@ const UserControlPanel = React.memo(({ username, userId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-idle detection via platform idle API
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!idle || !userId) return;
|
if (!idle || !userId) return;
|
||||||
|
if (window.Capacitor?.isNativePlatform?.()) return;
|
||||||
|
|
||||||
const handleIdleChange = (data) => {
|
const handleIdleChange = (data) => {
|
||||||
if (manualStatusRef.current) return;
|
if (manualStatusRef.current) return;
|
||||||
if (data.isIdle) {
|
if (data.isIdle) {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
const isMovingRef = useRef(false);
|
const isMovingRef = useRef(false);
|
||||||
const isDMCallRef = useRef(false);
|
const isDMCallRef = useRef(false);
|
||||||
|
const lastSpokeRef = useRef(Date.now());
|
||||||
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
|
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
|
||||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
const [connectionQualities, setConnectionQualities] = useState({});
|
const [connectionQualities, setConnectionQualities] = useState({});
|
||||||
@@ -245,6 +246,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setActiveChannelId(channelId);
|
setActiveChannelId(channelId);
|
||||||
setActiveChannelName(channelName);
|
setActiveChannelName(channelName);
|
||||||
setConnectionState('connecting');
|
setConnectionState('connecting');
|
||||||
|
window.__inVoiceCall = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request microphone permission (triggers Android runtime prompt)
|
// Request microphone permission (triggers Android runtime prompt)
|
||||||
@@ -381,6 +383,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
playSound('leave');
|
playSound('leave');
|
||||||
setConnectionState('disconnected');
|
setConnectionState('disconnected');
|
||||||
setActiveChannelId(null);
|
setActiveChannelId(null);
|
||||||
|
window.__inVoiceCall = false;
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
clearSpeakerTimers();
|
clearSpeakerTimers();
|
||||||
@@ -397,6 +400,12 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
const currentSpeakerIds = new Set(speakers.map(p => p.identity));
|
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
|
// Cancel pending removal timers for anyone who is speaking again
|
||||||
for (const id of currentSpeakerIds) {
|
for (const id of currentSpeakerIds) {
|
||||||
const timer = speakerRemovalTimers.current.get(id);
|
const timer = speakerRemovalTimers.current.get(id);
|
||||||
@@ -456,6 +465,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
console.error('Voice Connection Failed:', err);
|
console.error('Voice Connection Failed:', err);
|
||||||
setConnectionState('error');
|
setConnectionState('error');
|
||||||
setActiveChannelId(null);
|
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
|
// AFK idle polling: move user to AFK channel when idle exceeds timeout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return;
|
if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return;
|
||||||
if (!idle?.getSystemIdleTime) return;
|
|
||||||
if (isDMCallRef.current) return; // Skip AFK for DM calls
|
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 afkTimeout = serverSettings.afkTimeout || 300;
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
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) {
|
if (idleSeconds >= afkTimeout) {
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = localStorage.getItem('userId');
|
||||||
if (!userId) return;
|
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, {
|
await convex.mutation(api.voiceState.afkMove, {
|
||||||
userId,
|
userId,
|
||||||
afkChannelId: serverSettings.afkChannelId,
|
afkChannelId: serverSettings.afkChannelId,
|
||||||
@@ -766,6 +793,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const disconnectVoice = () => {
|
const disconnectVoice = () => {
|
||||||
console.log('User manually disconnected voice');
|
console.log('User manually disconnected voice');
|
||||||
isDMCallRef.current = false;
|
isDMCallRef.current = false;
|
||||||
|
window.__inVoiceCall = false;
|
||||||
voiceService?.stopService();
|
voiceService?.stopService();
|
||||||
if (room) room.disconnect();
|
if (room) room.disconnect();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,12 +76,36 @@ export default function usePresence(presence, roomId, userId, interval = 10000,
|
|||||||
window.addEventListener("beforeunload", handleUnload);
|
window.addEventListener("beforeunload", handleUnload);
|
||||||
|
|
||||||
// Handle visibility changes.
|
// Handle visibility changes.
|
||||||
// FIX: Do NOT disconnect when hidden. Electron timers keep running
|
// Desktop/web: Do NOT disconnect when hidden. Electron timers keep
|
||||||
// when minimized, so heartbeats continue normally. Only send an
|
// running when minimized, so heartbeats continue normally. Only send
|
||||||
// immediate heartbeat when becoming visible again to recover quickly
|
// an immediate heartbeat when becoming visible again to recover
|
||||||
// in case the browser had throttled the interval.
|
// 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 () => {
|
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();
|
void sendHeartbeat();
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
|
|||||||
Reference in New Issue
Block a user