feat(notify): in-app notifications with the app's own sound
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
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
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>
This commit is contained in:
@@ -588,7 +588,9 @@ function createTray(): void {
|
||||
|
||||
function showNotification(title: string, body: string, onClick?: () => void): void {
|
||||
if (!Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body, silent: false });
|
||||
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
|
||||
// o app toca o seu próprio efeito — que combina com os demais sons dele.
|
||||
const notification = new Notification({ title, body, silent: true });
|
||||
notification.on('click', onClick ?? (() => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,8 @@
|
||||
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';
|
||||
@@ -51,19 +55,39 @@ export function NotificationController() {
|
||||
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current) return;
|
||||
if (windowFocused.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)
|
||||
: 'Sent an attachment';
|
||||
sendNotification(displayName, body, {
|
||||
: 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { InAppNotifications } from '../ui/InAppNotifications';
|
||||
import { UpdateBanner } from '../ui/UpdateBanner';
|
||||
import { useExpressionStore } from '../../stores/expressionStore';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -430,6 +431,7 @@ export function AppLayout() {
|
||||
the root). */}
|
||||
<SoundController />
|
||||
<UpdateBanner />
|
||||
<InAppNotifications />
|
||||
<GlobalAudioRenderer />
|
||||
<NotificationController />
|
||||
<UpdateToast />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useInAppNotificationStore, type InAppNotification } from '../../stores/inAppNotificationStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
/** Tempo na tela antes de sumir sozinha. */
|
||||
const AUTO_DISMISS_MS = 6000;
|
||||
|
||||
function NotificationCard({ item }: { item: InAppNotification }) {
|
||||
const dismiss = useInAppNotificationStore((s) => s.dismiss);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => dismiss(item.id), AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [item.id, dismiss]);
|
||||
|
||||
const open = () => {
|
||||
if (item.channelId) {
|
||||
setCurrentChannel(item.channelId);
|
||||
navigate(`/channels/${item.spaceId || '@me'}/${item.channelId}`);
|
||||
}
|
||||
dismiss(item.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl shadow-xl w-[300px] overflow-hidden animate-slide-up">
|
||||
<button onClick={open} className="w-full text-left flex gap-2.5 p-3 hover:bg-interactive-hover transition-colors">
|
||||
<Avatar src={item.avatar} name={item.title} size={32} userId={item.userId} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-semibold text-txt-primary truncate">{item.title}</div>
|
||||
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">{item.body}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Avisos dentro da própria janela, no lugar do balão do sistema.
|
||||
*
|
||||
* O balão do Windows traz o som do sistema junto e não combina com o resto do
|
||||
* app. Aqui o efeito sonoro é o mesmo dos outros sons do Backspace, e clicar
|
||||
* leva direto ao canal.
|
||||
*/
|
||||
export function InAppNotifications() {
|
||||
const items = useInAppNotificationStore((s) => s.items);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[400] flex flex-col gap-2 pointer-events-none">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="pointer-events-auto">
|
||||
<NotificationCard item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,10 @@ export const en = {
|
||||
'accountMenu.copyId': 'Copy User ID',
|
||||
'accountMenu.copied': 'Copied',
|
||||
|
||||
// In-app notifications
|
||||
'notify.attachment': 'Sent an attachment',
|
||||
'notify.jump': 'Open',
|
||||
|
||||
// Desktop update
|
||||
'update.ready': 'Update ready',
|
||||
'update.readyVersion': 'Version {version} is ready to install.',
|
||||
|
||||
@@ -55,6 +55,10 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'accountMenu.copyId': 'Copiar ID do usuário',
|
||||
'accountMenu.copied': 'Copiado',
|
||||
|
||||
// Notificações no app
|
||||
'notify.attachment': 'Enviou um anexo',
|
||||
'notify.jump': 'Abrir',
|
||||
|
||||
// Atualização do app
|
||||
'update.ready': 'Atualização pronta',
|
||||
'update.readyVersion': 'A versão {version} está pronta para instalar.',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface InAppNotification {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
avatar: string | null;
|
||||
userId?: string;
|
||||
channelId?: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
items: InAppNotification[];
|
||||
push: (n: Omit<InAppNotification, 'id'>) => void;
|
||||
dismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Poucas de cada vez: uma pilha longa cobre a conversa em vez de avisar. */
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
export const useInAppNotificationStore = create<State>((set) => ({
|
||||
items: [],
|
||||
push: (n) =>
|
||||
set((s) => ({
|
||||
items: [...s.items, { ...n, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }]
|
||||
.slice(-MAX_VISIBLE),
|
||||
})),
|
||||
dismiss: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
|
||||
}));
|
||||
Reference in New Issue
Block a user