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

This commit is contained in:
Bryan1029384756
2026-02-21 15:57:37 -06:00
parent 7981b408db
commit e6a1a76116
8 changed files with 69 additions and 12 deletions

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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();
};

View File

@@ -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);