From 1fb61377b9a0cd3535f19e29e6291d282f2d2d09 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Tue, 1 Sep 2026 18:57:02 -0300 Subject: [PATCH] feat(notify): in-app notifications with the app's own sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/publish-gitea.yml | 44 +++++++++++-- packages/desktop/src/main.ts | 4 +- packages/web/public/sounds/notification.ogg | Bin 0 -> 6553 bytes .../src/components/NotificationController.tsx | 30 ++++++++- .../web/src/components/layout/AppLayout.tsx | 2 + .../src/components/ui/InAppNotifications.tsx | 61 ++++++++++++++++++ packages/web/src/i18n/locales/en.ts | 4 ++ packages/web/src/i18n/locales/pt-BR.ts | 4 ++ .../web/src/stores/inAppNotificationStore.ts | 30 +++++++++ tools/sfx/notif.py | 35 ++++++++++ 10 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 packages/web/public/sounds/notification.ogg create mode 100644 packages/web/src/components/ui/InAppNotifications.tsx create mode 100644 packages/web/src/stores/inAppNotificationStore.ts create mode 100644 tools/sfx/notif.py diff --git a/.github/workflows/publish-gitea.yml b/.github/workflows/publish-gitea.yml index 4cd9f086..bbb280f7 100644 --- a/.github/workflows/publish-gitea.yml +++ b/.github/workflows/publish-gitea.yml @@ -153,14 +153,31 @@ jobs: # Remove só os anexos que esta plataforma vai repor, para os dois jobs # não apagarem o trabalho um do outro. + # + # Padrão montado em bash puro: a versão anterior interpolava uma + # expressão do Actions dentro de um `case`, e quando ela não casou o + # laço passou em silêncio — a release ficou com dois latest.yml e o + # updater serviu o antigo, dizendo que a versão nova não existia. + if [ "${RUNNER_OS:-}" = "Windows" ]; then + MINE='\.exe$|^latest\.yml$' + else + MINE='\.AppImage$|\.deb$|^latest-linux\.yml$' + fi + R=$(api GET "/releases/$ID/assets") - if [ "$(code "$R")" = "200" ]; then - body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do - case "$aname" in - ${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }}) - echo "removendo anexo antigo: $aname" - api DELETE "/releases/$ID/assets/$aid" > /dev/null ;; - esac + if [ "$(code "$R")" != "200" ]; then + echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")" + exit 1 + fi + + OLD=$(body "$R" | jq -r '.[] | "\(.id) \(.name)"' | grep -E " .*($MINE)" || true) + echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)" + if [ -n "$OLD" ]; then + printf '%s\n' "$OLD" | while read -r aid aname; do + [ -n "$aid" ] || continue + echo "removendo anexo antigo: $aname" + D=$(api DELETE "/releases/$ID/assets/$aid") + [ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")" done fi @@ -181,3 +198,16 @@ jobs: done [ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; } echo "$sent arquivo(s) publicados" + + # O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome + # fazem o Gitea servir a mais antiga, e a atualização deixa de ser + # oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar + # uma release que parece boa e não atualiza. + if [ "${RUNNER_OS:-}" = "Windows" ]; then + R=$(api GET "/releases/$ID/assets") + DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length') + if [ "$DUP" != "1" ]; then + echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada" + exit 1 + fi + echo "latest.yml: 1 cópia, como esperado" diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 0a0dbbb5..fee93871 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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(); diff --git a/packages/web/public/sounds/notification.ogg b/packages/web/public/sounds/notification.ogg new file mode 100644 index 0000000000000000000000000000000000000000..3bc818a461c7f36e6e8262da2e1b5dae2979adac GIT binary patch literal 6553 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 { 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 } } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index ff82fa6e..01314d7b 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -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). */} + diff --git a/packages/web/src/components/ui/InAppNotifications.tsx b/packages/web/src/components/ui/InAppNotifications.tsx new file mode 100644 index 00000000..5db0ccf7 --- /dev/null +++ b/packages/web/src/components/ui/InAppNotifications.tsx @@ -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 ( +
+ +
+ ); +} + +/** + * 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 ( +
+ {items.map((item) => ( +
+ +
+ ))} +
+ ); +} diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts index c8e8d7a3..78133b05 100644 --- a/packages/web/src/i18n/locales/en.ts +++ b/packages/web/src/i18n/locales/en.ts @@ -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.', diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts index c1fa8ca2..b595f2d1 100644 --- a/packages/web/src/i18n/locales/pt-BR.ts +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -55,6 +55,10 @@ export const ptBR: Partial = { '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.', diff --git a/packages/web/src/stores/inAppNotificationStore.ts b/packages/web/src/stores/inAppNotificationStore.ts new file mode 100644 index 00000000..3a5ee0d6 --- /dev/null +++ b/packages/web/src/stores/inAppNotificationStore.ts @@ -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) => 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((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) })), +})); diff --git a/tools/sfx/notif.py b/tools/sfx/notif.py new file mode 100644 index 00000000..8eef4484 --- /dev/null +++ b/tools/sfx/notif.py @@ -0,0 +1,35 @@ +import math, wave, struct +SR=48000 +HARM=(1.00, 0.85, 0.10, 0.02) # timbre medido nas referencias aprovadas + +def note(f, dur, tau, amp=1.0): + n=int(SR*dur); o=[] + for i in range(n): + t=i/SR + s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM)) + o.append(s*amp*min(1.0,t/0.004)*math.exp(-t/tau)) + return o + +def mix(layers,total): + b=[0.0]*total + for off,s in layers: + for i,v in enumerate(s): + if off+i0.97: g*=0.97/pk + with wave.open(name,'w') as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR) + w.writeframes(b''.join(struct.pack('