diff --git a/Dev_Docs/Pending_Features/2026-06-25_0735_hidden_dm_notifications.md b/Dev_Docs/Pending_Features/2026-06-25_0735_hidden_dm_notifications.md new file mode 100644 index 0000000..e245e0e --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-25_0735_hidden_dm_notifications.md @@ -0,0 +1,31 @@ +## Краткое описание + +Доработаны входящие уведомления для личных сообщений в сценарии, когда UI открыт, но страница скрыта на телефоне: + +- для входящего DM при `document.visibilityState !== visible` UI пытается показать системное уведомление через `service worker`; +- добавлен `best effort` сигнал через `navigator.vibrate()`; +- добавлен короткий локальный звуковой сигнал через Web Audio, если аудио-контекст был ранее разблокирован пользовательским действием. +- для видимой активной страницы этот же сигнал теперь проигрывается на каждое новое входящее DM; +- для скрытой страницы звуковой сигнал сделан длиннее и заметнее. + +## Что проверять + +- открыть SHiNE в Chrome/Android и один раз взаимодействовать со страницей; +- свернуть браузер или увести вкладку в фон, не закрывая её полностью; +- отправить DM с другого аккаунта; +- при открытой видимой странице тоже отправить DM и убедиться, что короткий сигнал воспроизводится без системного уведомления в шторке; +- проверить, что: + - сообщение пришло в шторку как системное уведомление; + - при поддержке устройства есть вибрация; + - на части устройств/браузеров может прозвучать локальный сигнал; +- отдельно проверить, что при открытой видимой странице не появилось лишних дублей системного уведомления. + +## Ожидаемый результат + +- скрытая, но живая страница стала заметнее реагировать на входящий DM; +- уведомление в фоне не зависит только от `new Notification(...)` из страницы; +- если браузер разрешает локальный аудио-сигнал, пользователь слышит короткое оповещение. + +## Статус + +`pending` diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md index 817e6e9..113c206 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/Dev_Docs/Personal_Messages/README.md @@ -184,6 +184,15 @@ Read-receipt пока остаются в legacy-формате: - `AckSessionDelivery` +WebPush и локальные уведомления сейчас работают так: + +- для активной онлайн-сессии приоритет у доставки по WebSocket через `SignedMessageArrived`; +- если целевая сессия не онлайн по WebSocket, сервер может отправить WebPush с `kind=new_message`; +- если вкладка/приложение живы, но страница скрыта (`document.visibilityState !== visible`), UI дополнительно пытается показать системное уведомление через `service worker`; +- для активной видимой страницы UI проигрывает короткий локальный сигнал на каждое новое входящее DM, если браузер ранее разрешил аудио-контекст после пользовательского жеста; +- для скрытой, но живой страницы UI также делает `best effort` сигнал через `vibrate()` и более длинный локальный звук; +- эти локальные сигналы не гарантируются браузером: на мобильных устройствах они зависят от политики Chrome/Android/iOS. + ## Правила UI UI сейчас работает так: @@ -194,6 +203,8 @@ UI сейчас работает так: - позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`; - при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены; - после редактирования показывает под временем отдельную строку `изменено: <дата время>`; +- на видимом экране чата/приложения проигрывает короткий локальный звук на новое входящее DM; +- при входящем DM для скрытой, но ещё живой страницы пытается поднять системное уведомление через `service worker`; - не показывает и не принимает вложения. ## Что обязательно помнить diff --git a/VERSION.properties b/VERSION.properties index 88a2994..6667bcc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.266 +client.version=1.2.268 server.version=1.2.248 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index d641ae6..289529e 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -150,6 +150,8 @@ let uiUpdateReloadScheduled = false; let pwaUpdateCheckAttempted = false; let uiVersionCheckInFlight = false; let uiVersionPeriodicIntervalId = null; +let hiddenDmAudioContext = null; +let hiddenDmAudioUnlocked = false; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const GUEST_ALLOWED_PAGES = new Set([ 'start-view', @@ -172,6 +174,94 @@ initPwaInstallPromptHandling(); initCallUiOverlay(); setCallDebugReporter((payload) => authService.reportClientDebug(payload)); +async function unlockHiddenDmAudio() { + try { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return false; + if (!hiddenDmAudioContext) { + hiddenDmAudioContext = new Ctx(); + } + if (hiddenDmAudioContext.state === 'suspended') { + await hiddenDmAudioContext.resume(); + } + hiddenDmAudioUnlocked = hiddenDmAudioContext.state === 'running'; + return hiddenDmAudioUnlocked; + } catch { + return false; + } +} + +async function playDmSignal({ extended = false } = {}) { + try { + if (!hiddenDmAudioUnlocked || !hiddenDmAudioContext) return false; + if (hiddenDmAudioContext.state === 'suspended') { + await hiddenDmAudioContext.resume(); + } + if (hiddenDmAudioContext.state !== 'running') return false; + + const now = hiddenDmAudioContext.currentTime; + const gain = hiddenDmAudioContext.createGain(); + gain.connect(hiddenDmAudioContext.destination); + gain.gain.setValueAtTime(0.0001, now); + + const pulse = (offsetSec, freqHz, durationSec, peakGain) => { + const osc = hiddenDmAudioContext.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freqHz, now + offsetSec); + osc.connect(gain); + gain.gain.exponentialRampToValueAtTime(peakGain, now + offsetSec + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + offsetSec + durationSec); + osc.start(now + offsetSec); + osc.stop(now + offsetSec + durationSec + 0.02); + }; + + if (extended) { + pulse(0, 880, 0.18, 0.032); + pulse(0.24, 1174, 0.2, 0.026); + pulse(0.52, 1567, 0.24, 0.02); + } else { + pulse(0, 1046, 0.12, 0.028); + pulse(0.17, 1318, 0.14, 0.022); + } + return true; + } catch { + return false; + } +} + +async function notifyHiddenIncomingMessage(fromLogin, text) { + const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`; + const title = `Сообщение от ${fromLogin}`; + try { + const registration = await navigator.serviceWorker?.getRegistration?.(); + if (registration?.showNotification) { + await registration.showNotification(title, { + body, + tag: `shine-hidden-dm-${String(fromLogin || '').trim().toLowerCase() || 'unknown'}`, + renotify: true, + data: { + kind: 'new_message', + fromLogin, + }, + }); + } else { + new Notification(title, { body }); + } + } catch { + // ignore notification errors + } + + try { + if (typeof navigator.vibrate === 'function') { + navigator.vibrate([140, 80, 220, 90, 180]); + } + } catch { + // ignore vibration errors + } + + void playDmSignal({ extended: true }); +} + function ensureConnectionIndicatorEl() { return document.getElementById('toolbar-connection-indicator'); } @@ -960,10 +1050,16 @@ async function init() { if (added && isIncomingForCurrent) { shouldRefreshToolbarUnread = true; } - if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) { - try { - new Notification(`Сообщение от ${fromLogin}`, { body: text || '' }); - } catch {} + if (added && isIncomingForCurrent && !payload.backlog) { + if (document.visibilityState === 'visible') { + void playDmSignal({ extended: false }); + } else if (Notification.permission === 'granted') { + try { + void notifyHiddenIncomingMessage(fromLogin, text || ''); + } catch {} + } else { + void playDmSignal({ extended: true }); + } } } else if (messageType === 3 || messageType === 4) { let refBaseKey = String(payload.receiptRefBaseKey || '').trim(); @@ -1106,6 +1202,12 @@ async function init() { })(); window.addEventListener('popstate', renderApp); + document.addEventListener('pointerdown', () => { + void unlockHiddenDmAudio(); + }, { passive: true }); + document.addEventListener('keydown', () => { + void unlockHiddenDmAudio(); + }, { passive: true }); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; void checkConnectionHealth();