Files
backspace/packages/web/src/components/NotificationController.tsx
T
Jannis Braun ed70099963 fix(mobile): resolve all TypeScript errors from integration
- Fix voiceStore method names (toggleMic, leaveVoice, setCurrentVoiceChannel)
- Fix MemberWithUser nested user property access
- Pass required channelId props to MessageList, TypingIndicator, MessageInput
- Fix ConfirmDialog props (isOpen, description, variant, onClose)
- Add mobileStyle prop to Modal component (fullscreen + sheet modes)
- Fix fromId/isAdmin camelCase property names
- Guard possibly undefined touch events and array accesses
- Fix notification channelId resolution
2026-03-17 15:54:39 +01:00

102 lines
3.4 KiB
TypeScript

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, {
channelId: message.channelId,
});
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;
}