Files
backspace/packages/web/src/components/NotificationController.tsx
T
devsyncwrldandClaude Opus 5 1fb61377b9
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
feat(notify): in-app notifications with the app's own sound
The system balloon carries the OS notification sound, which does not belong to
this app, and it only fired while the window was out of focus — with the app
focused nothing appeared at all.

Notifications now surface inside the window, carry the same synthesised timbre
as the rest of the app's sounds, and clicking one opens the channel. The native
balloon is kept for when the window is not visible, since an in-app card
nobody can see is no notification, but it is now silent: the app plays its own
effect instead.

A focused window is notified only about other channels — announcing the
conversation someone is already reading is noise.

Also fixes the Gitea publish cleanup, which silently deleted nothing: it
interpolated an Actions expression inside a bash , and when the pattern
did not match, the loop passed over every asset. The release ended with two
latest.yml files and the updater served the older one, reporting 1.1.0 as
current — an update that exists but is never offered, with no error anywhere.
The filter is plain bash now, logs what it found, and the job fails if more
than one latest.yml survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:57:02 -03:00

126 lines
4.7 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useInAppNotificationStore } from '../stores/inAppNotificationStore';
import { AudioManager } from '../audio/AudioManager';
import { getSfxVolume } from '../utils/sfx';
import { translate, useLocaleStore } from '../i18n';
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;
// Antes daqui só havia aviso com a janela fora de foco. Agora a janela em
// foco também avisa, mas dentro do app e apenas para outro canal — avisar
// sobre a conversa que a pessoa está lendo seria ruído.
const focused = windowFocused.current;
const currentChannel = state.currentChannelId;
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) {
if (focused && message.channelId === currentChannel) break;
const displayName = message.user?.displayName || message.user?.username || 'Someone';
const body = message.content
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
: translate(useLocaleStore.getState().locale, 'notify.attachment');
useInAppNotificationStore.getState().push({
title: displayName,
body,
avatar: message.user?.avatar ?? null,
userId: message.user?.id,
channelId: message.channelId,
});
// Efeito próprio, no lugar do som do sistema. O balão nativo agora
// é silencioso, então este é o único som que toca.
void AudioManager.getInstance().playSound('notification', { volume: getSfxVolume() });
// O balão do sistema só faz sentido quando a janela não está à
// vista: com ela em foco, o aviso dentro do app já cumpre o papel.
if (!focused) {
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 | null; 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;
}