feat: Electron desktop app — hardening, IPC bridge, and dev launch fixes
- Dev/prod URL auto-detection (Vite 5173 in dev, server 3000 in prod) - Typed IPC bridge via preload (notifications, badge, window controls, updates, deep links) - Native OS notifications via NotificationController with window focus suppression - Auto-update via electron-updater with UpdateToast UI - Deep linking (backspace:// protocol) for macOS and Windows/Linux - Window state persistence (position, size, maximize across restarts) - Tray icon with graceful fallback when icon asset missing - Suppress PWA service worker polling/reloads inside Electron - Platform detection layer (isElectron, getElectronAPI) - Root workspace scripts (dev:desktop, build:desktop) - Document BACKSPACE_URL and BACKSPACE_UPDATE_URL env vars
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { isElectron } from '../platform/platform';
|
||||
import { sendNotification, updateBadgeCount } from '../platform/notifications';
|
||||
|
||||
/**
|
||||
* Headless component that bridges store events to native OS notifications and badge counts.
|
||||
* Renders nothing — lives alongside SoundController in AppLayout.
|
||||
*/
|
||||
export function NotificationController() {
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isInitialMount = useRef(true);
|
||||
const windowFocused = useRef(true);
|
||||
|
||||
// Track window focus state
|
||||
useEffect(() => {
|
||||
if (isElectron() && window.backspace) {
|
||||
window.backspace.onWindowFocusChange((focused) => {
|
||||
windowFocused.current = focused;
|
||||
});
|
||||
}
|
||||
|
||||
// Browser fallback focus tracking
|
||||
const onFocus = () => { windowFocused.current = true; };
|
||||
const onBlur = () => { windowFocused.current = false; };
|
||||
const onVisibility = () => {
|
||||
windowFocused.current = document.visibilityState === 'visible' && document.hasFocus();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
// Sync initial state
|
||||
windowFocused.current = document.hasFocus();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Message notifications
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
isInitialMount.current = false;
|
||||
}, 1000);
|
||||
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current) return;
|
||||
if (windowFocused.current) return;
|
||||
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
const displayName = message.user?.displayName || message.user?.username || 'Someone';
|
||||
const body = message.content
|
||||
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
|
||||
: 'Sent an attachment';
|
||||
sendNotification(displayName, body);
|
||||
break; // one notification per batch
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
unsubscribeChat();
|
||||
};
|
||||
}, [currentUser?.id]);
|
||||
|
||||
// Badge count (Electron only)
|
||||
useEffect(() => {
|
||||
const unsubscribe = useChatStore.subscribe((state) => {
|
||||
updateBadgeCount(state.unreadChannels.size);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// DM call notification
|
||||
useEffect(() => {
|
||||
let prevIncoming: { dmChannelId: string; callerId: string; callerName: string } | null = null;
|
||||
|
||||
const unsubscribe = useVoiceStore.subscribe((state) => {
|
||||
if (state.incomingCall && !prevIncoming && !windowFocused.current) {
|
||||
sendNotification('Incoming Call', `${state.incomingCall.callerName} is calling you`);
|
||||
}
|
||||
prevIncoming = state.incomingCall;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user