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 00000000..3bc818a4 Binary files /dev/null and b/packages/web/public/sounds/notification.ogg differ diff --git a/packages/web/src/components/NotificationController.tsx b/packages/web/src/components/NotificationController.tsx index 57b70535..2dedfab5 100644 --- a/packages/web/src/components/NotificationController.tsx +++ b/packages/web/src/components/NotificationController.tsx @@ -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 } } 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('