SHA256
Звонки: WebPush incoming/stop, actions и TTL; обновлена логика
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
self.__shineStoppedCalls = self.__shineStoppedCalls || new Map();
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event?.data || {};
|
||||
if (data.type === 'SKIP_WAITING') {
|
||||
@@ -17,6 +19,61 @@ async function broadcastToClients(payload) {
|
||||
});
|
||||
}
|
||||
|
||||
async function broadcastCallActionToClients(action, payload) {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach((client) => {
|
||||
client.postMessage({
|
||||
type: 'SHINE_CALL_PUSH_ACTION',
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rememberStoppedCall(callId, sentAtMs = 0) {
|
||||
if (!callId) return;
|
||||
const now = Date.now();
|
||||
const markAtMs = Number.isFinite(Number(sentAtMs)) ? Number(sentAtMs) : now;
|
||||
self.__shineStoppedCalls.set(callId, Math.max(now, markAtMs));
|
||||
const cutoff = now - 10 * 60 * 1000;
|
||||
for (const [id, ts] of self.__shineStoppedCalls.entries()) {
|
||||
if (Number(ts || 0) < cutoff) self.__shineStoppedCalls.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function isCallStopped(callId, sentAtMs = 0) {
|
||||
if (!callId) return false;
|
||||
const stoppedAt = Number(self.__shineStoppedCalls.get(callId) || 0);
|
||||
if (!stoppedAt) return false;
|
||||
const incomingAt = Number.isFinite(Number(sentAtMs)) ? Number(sentAtMs) : 0;
|
||||
return incomingAt <= 0 || incomingAt <= stoppedAt;
|
||||
}
|
||||
|
||||
async function closeCallNotification(callId) {
|
||||
if (!callId) return;
|
||||
const list = await self.registration.getNotifications({ tag: callId });
|
||||
list.forEach((n) => {
|
||||
try { n.close(); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
function decodePushJson(rawText) {
|
||||
try {
|
||||
if (!rawText) return {};
|
||||
return JSON.parse(rawText);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCallPushPayloadForUrl(payload) {
|
||||
try {
|
||||
return encodeURIComponent(JSON.stringify(payload || {}));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let body = '';
|
||||
let rawText = '';
|
||||
@@ -27,13 +84,12 @@ self.addEventListener('push', (event) => {
|
||||
if (event.data) {
|
||||
const text = event.data.text();
|
||||
rawText = text || '';
|
||||
try {
|
||||
const json = JSON.parse(rawText || '{}');
|
||||
kind = String(json.kind || '');
|
||||
title = String(json.title || '');
|
||||
body = String(json.text || '');
|
||||
fromLogin = String(json.fromLogin || '');
|
||||
} catch {
|
||||
const json = decodePushJson(rawText);
|
||||
kind = String(json.kind || '');
|
||||
title = String(json.title || '');
|
||||
body = String(json.text || '');
|
||||
fromLogin = String(json.fromLogin || '');
|
||||
if (!kind && rawText) {
|
||||
body = rawText || '';
|
||||
}
|
||||
}
|
||||
@@ -41,34 +97,106 @@ self.addEventListener('push', (event) => {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const shouldNotify = kind === 'new_message' || kind === 'test_push' || (!kind && body);
|
||||
const json = decodePushJson(rawText);
|
||||
const callId = String(json.callId || '').trim();
|
||||
const fromSessionId = String(json.fromSessionId || '').trim();
|
||||
const toLogin = String(json.toLogin || '').trim();
|
||||
const reason = String(json.reason || '').trim();
|
||||
const sentAtMs = Number(json.sentAtMs || 0);
|
||||
const expiresAtMs = Number(json.expiresAtMs || 0);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (kind === 'stop_call' && callId) {
|
||||
rememberStoppedCall(callId, sentAtMs || nowMs);
|
||||
}
|
||||
|
||||
const isExpiredIncomingCall = kind === 'incoming_call'
|
||||
&& Number.isFinite(expiresAtMs)
|
||||
&& expiresAtMs > 0
|
||||
&& nowMs > expiresAtMs;
|
||||
const isIncomingCallAlreadyStopped = kind === 'incoming_call' && callId && isCallStopped(callId, sentAtMs || nowMs);
|
||||
|
||||
const shouldNotify = (
|
||||
kind === 'new_message'
|
||||
|| kind === 'test_push'
|
||||
|| (kind === 'incoming_call' && !isExpiredIncomingCall && !isIncomingCallAlreadyStopped)
|
||||
|| (!kind && body)
|
||||
);
|
||||
const notificationTitle = kind === 'test_push'
|
||||
? (title || 'SHiNE: тестовый push')
|
||||
: 'SHiNE: входящее сообщение';
|
||||
: (kind === 'incoming_call'
|
||||
? 'SHiNE: входящий звонок'
|
||||
: 'SHiNE: входящее сообщение');
|
||||
|
||||
const notifyPromise = shouldNotify
|
||||
? self.registration.showNotification(notificationTitle, {
|
||||
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
|
||||
tag: kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message',
|
||||
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'),
|
||||
renotify: true,
|
||||
requireInteraction: kind === 'incoming_call',
|
||||
data: {
|
||||
kind,
|
||||
callId,
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
toLogin,
|
||||
sentAtMs,
|
||||
expiresAtMs,
|
||||
reason,
|
||||
},
|
||||
actions: kind === 'incoming_call'
|
||||
? [
|
||||
{ action: 'accept', title: 'Ответить' },
|
||||
{ action: 'decline', title: 'Сбросить' },
|
||||
]
|
||||
: [],
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
const closeOnStopPromise = kind === 'stop_call' && callId
|
||||
? closeCallNotification(callId)
|
||||
: Promise.resolve();
|
||||
|
||||
event.waitUntil(Promise.all([
|
||||
notifyPromise,
|
||||
closeOnStopPromise,
|
||||
broadcastToClients({
|
||||
kind,
|
||||
body,
|
||||
fromLogin,
|
||||
fromSessionId,
|
||||
toLogin,
|
||||
callId,
|
||||
sentAtMs,
|
||||
expiresAtMs,
|
||||
reason,
|
||||
stale: isExpiredIncomingCall || isIncomingCallAlreadyStopped,
|
||||
rawText,
|
||||
receivedAt: Date.now(),
|
||||
receivedAt: nowMs,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification?.close();
|
||||
const action = String(event?.action || '').trim().toLowerCase();
|
||||
const data = event?.notification?.data || {};
|
||||
const payload = {
|
||||
kind: String(data.kind || '').trim(),
|
||||
callId: String(data.callId || '').trim(),
|
||||
fromLogin: String(data.fromLogin || '').trim(),
|
||||
fromSessionId: String(data.fromSessionId || '').trim(),
|
||||
toLogin: String(data.toLogin || '').trim(),
|
||||
sentAtMs: Number(data.sentAtMs || 0),
|
||||
expiresAtMs: Number(data.expiresAtMs || 0),
|
||||
reason: String(data.reason || '').trim(),
|
||||
};
|
||||
|
||||
event.waitUntil((async () => {
|
||||
if ((action === 'accept' || action === 'decline') && payload.callId) {
|
||||
await broadcastCallActionToClients(action, payload);
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const existing = allClients.find((client) => {
|
||||
try {
|
||||
@@ -78,11 +206,26 @@ self.addEventListener('notificationclick', (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
const openUrlBase = './index.html';
|
||||
const encodedPayload = encodeCallPushPayloadForUrl(payload);
|
||||
const openUrl = (action === 'accept' || action === 'decline')
|
||||
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
|
||||
: openUrlBase;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
existing.postMessage({
|
||||
type: 'SHINE_CALL_PUSH_ACTION',
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
await existing.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
await self.clients.openWindow('./index.html');
|
||||
await self.clients.openWindow(openUrl);
|
||||
})());
|
||||
});
|
||||
|
||||
@@ -5,8 +5,11 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||
import {
|
||||
handleCallPushAction,
|
||||
handleIncomingCallInvite,
|
||||
handleIncomingCallPush,
|
||||
handleIncomingCallSignal,
|
||||
handleStopCallPush,
|
||||
setCallDebugReporter,
|
||||
startDebugConnectionAsInitiator,
|
||||
startDebugConnectionAsResponder,
|
||||
@@ -127,6 +130,7 @@ let uiUpdateReloadScheduled = false;
|
||||
let pwaUpdateCheckAttempted = false;
|
||||
let uiVersionCheckInFlight = false;
|
||||
let uiVersionPeriodicIntervalId = null;
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
|
||||
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||
initPwaInstallPromptHandling();
|
||||
@@ -220,6 +224,85 @@ function startConnectionCountdown() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function savePendingCallPushAction(action, payload = {}) {
|
||||
try {
|
||||
const item = {
|
||||
action: String(action || '').trim().toLowerCase(),
|
||||
payload: payload || {},
|
||||
savedAtMs: Date.now(),
|
||||
};
|
||||
localStorage.setItem(CALL_PUSH_PENDING_ACTION_KEY, JSON.stringify(item));
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
function loadPendingCallPushAction() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CALL_PUSH_PENDING_ACTION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
const action = String(parsed?.action || '').trim().toLowerCase();
|
||||
if (action !== 'accept' && action !== 'decline') return null;
|
||||
return {
|
||||
action,
|
||||
payload: parsed?.payload || {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingCallPushAction() {
|
||||
try {
|
||||
localStorage.removeItem(CALL_PUSH_PENDING_ACTION_KEY);
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
function consumeCallPushActionFromUrlIfAny() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const action = String(params.get('callPushAction') || '').trim().toLowerCase();
|
||||
const rawPayload = String(params.get('callPushPayload') || '');
|
||||
if (action !== 'accept' && action !== 'decline') return;
|
||||
let payload = {};
|
||||
if (rawPayload) {
|
||||
try {
|
||||
payload = JSON.parse(decodeURIComponent(rawPayload));
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
savePendingCallPushAction(action, payload);
|
||||
params.delete('callPushAction');
|
||||
params.delete('callPushPayload');
|
||||
const nextQuery = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
} catch {
|
||||
// ignore URL parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
async function processPendingCallPushActionIfPossible() {
|
||||
if (!state.session.isAuthorized) return;
|
||||
const pending = loadPendingCallPushAction();
|
||||
if (!pending) return;
|
||||
clearPendingCallPushAction();
|
||||
try {
|
||||
await handleCallPushAction(pending.action, pending.payload || {});
|
||||
} catch (error) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'web-push',
|
||||
message: 'Не удалось выполнить действие звонка из push',
|
||||
details: { action: pending.action, error: error?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setConnectionStatus(nextState, text = '') {
|
||||
const state = String(nextState || '').trim();
|
||||
if (!state) return;
|
||||
@@ -677,9 +760,13 @@ async function ensureSessionRuntimeStarted() {
|
||||
});
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
await processPendingCallPushActionIfPossible();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'app',
|
||||
@@ -698,12 +785,23 @@ async function init() {
|
||||
|
||||
setSessionAuthorizedHandler(() => {
|
||||
void ensureSessionRuntimeStarted();
|
||||
void processPendingCallPushActionIfPossible();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
const data = event?.data || {};
|
||||
if (data.type === 'SHINE_CALL_PUSH_ACTION') {
|
||||
const action = String(data.action || '').trim().toLowerCase();
|
||||
const payload = data.payload || {};
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
savePendingCallPushAction(action, payload);
|
||||
void processPendingCallPushActionIfPossible();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
|
||||
|
||||
const payload = data.payload || {};
|
||||
const kind = String(payload.kind || '').trim();
|
||||
const now = Date.now();
|
||||
@@ -723,6 +821,11 @@ async function init() {
|
||||
message: 'Получено push-событие в service worker',
|
||||
details: payload,
|
||||
});
|
||||
if (kind === 'incoming_call' && !payload.stale && state.session.isAuthorized) {
|
||||
void handleIncomingCallPush(payload);
|
||||
} else if (kind === 'stop_call' && state.session.isAuthorized) {
|
||||
void handleStopCallPush(payload);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1064,6 +1064,84 @@ function ensureIncomingNotification(peerLogin) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isIncomingCallPushFresh(payload) {
|
||||
const expiresAtMs = Number(payload?.expiresAtMs || 0);
|
||||
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0 && Date.now() > expiresAtMs) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
||||
const callId = String(payload?.callId || '').trim();
|
||||
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
||||
if (!callId || !fromLogin || !fromSessionId) return null;
|
||||
|
||||
if (activeCallId && activeCallId !== callId) {
|
||||
try {
|
||||
await authService.callSignalToSession({
|
||||
toLogin: fromLogin,
|
||||
targetSessionId: fromSessionId,
|
||||
callId,
|
||||
type: TYPES.DECLINE_BUSY,
|
||||
data: 'busy',
|
||||
});
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
let call = getCall(callId);
|
||||
if (!call) {
|
||||
call = {
|
||||
callId,
|
||||
peerLogin: fromLogin,
|
||||
direction: 'in',
|
||||
phase: 'incoming',
|
||||
statusText: `Вам звонит ${fromLogin}`,
|
||||
remoteSessionId: fromSessionId,
|
||||
timers: {},
|
||||
startedAtMs: nowMs(),
|
||||
connectedAtMs: 0,
|
||||
pc: null,
|
||||
localStream: null,
|
||||
audioSenders: [],
|
||||
muted: false,
|
||||
connectionRouteLabel: '',
|
||||
reconnectInProgress: false,
|
||||
reconnectAttempts: 0,
|
||||
debugMode: false,
|
||||
debugRunId: '',
|
||||
debugRole: '',
|
||||
pendingRemoteIceCandidates: [],
|
||||
initialOfferInProgress: false,
|
||||
initialOfferSent: false,
|
||||
};
|
||||
calls.set(callId, call);
|
||||
} else if (!call.remoteSessionId && fromSessionId) {
|
||||
call.remoteSessionId = fromSessionId;
|
||||
}
|
||||
|
||||
activeCallId = callId;
|
||||
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
||||
ensureIncomingNotification(fromLogin);
|
||||
|
||||
try {
|
||||
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
|
||||
} catch {}
|
||||
|
||||
if (!call.timers.incoming20s) {
|
||||
call.timers.incoming20s = setTimeout(async () => {
|
||||
if (!calls.has(callId)) return;
|
||||
try {
|
||||
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
||||
} catch {}
|
||||
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
|
||||
}, 20000);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
export function setCallDebugReporter(fn) {
|
||||
debugReporter = typeof fn === 'function' ? fn : null;
|
||||
}
|
||||
@@ -1272,69 +1350,7 @@ export async function startOutgoingCall(peerLogin) {
|
||||
}
|
||||
|
||||
export async function handleIncomingCallInvite(evt) {
|
||||
const payload = evt?.payload || {};
|
||||
const callId = String(payload.callId || '').trim();
|
||||
const fromLogin = String(payload.fromLogin || '').trim();
|
||||
const fromSessionId = String(payload.fromSessionId || '').trim();
|
||||
if (!callId || !fromLogin || !fromSessionId) return;
|
||||
|
||||
if (activeCallId && activeCallId !== callId) {
|
||||
try {
|
||||
await authService.callSignalToSession({
|
||||
toLogin: fromLogin,
|
||||
targetSessionId: fromSessionId,
|
||||
callId,
|
||||
type: TYPES.DECLINE_BUSY,
|
||||
data: 'busy',
|
||||
});
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
let call = getCall(callId);
|
||||
if (!call) {
|
||||
call = {
|
||||
callId,
|
||||
peerLogin: fromLogin,
|
||||
direction: 'in',
|
||||
phase: 'incoming',
|
||||
statusText: `Вам звонит ${fromLogin}`,
|
||||
remoteSessionId: fromSessionId,
|
||||
timers: {},
|
||||
startedAtMs: nowMs(),
|
||||
connectedAtMs: 0,
|
||||
pc: null,
|
||||
localStream: null,
|
||||
audioSenders: [],
|
||||
muted: false,
|
||||
connectionRouteLabel: '',
|
||||
reconnectInProgress: false,
|
||||
reconnectAttempts: 0,
|
||||
debugMode: false,
|
||||
debugRunId: '',
|
||||
debugRole: '',
|
||||
pendingRemoteIceCandidates: [],
|
||||
initialOfferInProgress: false,
|
||||
initialOfferSent: false,
|
||||
};
|
||||
calls.set(callId, call);
|
||||
}
|
||||
|
||||
activeCallId = callId;
|
||||
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
||||
ensureIncomingNotification(fromLogin);
|
||||
|
||||
try {
|
||||
await sendSignal(call, TYPES.RINGING, 'ringing');
|
||||
} catch {}
|
||||
|
||||
call.timers.incoming20s = setTimeout(async () => {
|
||||
if (!calls.has(callId)) return;
|
||||
try {
|
||||
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
||||
} catch {}
|
||||
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
|
||||
}, 20000);
|
||||
await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' });
|
||||
}
|
||||
|
||||
export async function acceptIncomingCall() {
|
||||
@@ -1534,3 +1550,32 @@ export async function hangupActiveCall() {
|
||||
notifyRemoteHangup: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleIncomingCallPush(payload = {}) {
|
||||
if (!isIncomingCallPushFresh(payload)) return;
|
||||
await handleIncomingInvitePayload(payload, { source: 'push' });
|
||||
}
|
||||
|
||||
export async function handleStopCallPush(payload = {}) {
|
||||
const callId = String(payload?.callId || '').trim();
|
||||
if (!callId) return;
|
||||
const call = getCall(callId);
|
||||
if (!call) return;
|
||||
const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push';
|
||||
await finalizeCall(call, {
|
||||
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
|
||||
debugReason: `stop_call_push:${reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCallPushAction(action, payload = {}) {
|
||||
const normalized = String(action || '').trim().toLowerCase();
|
||||
if (normalized !== 'accept' && normalized !== 'decline') return;
|
||||
if (!isIncomingCallPushFresh(payload)) return;
|
||||
await handleIncomingCallPush(payload);
|
||||
if (normalized === 'accept') {
|
||||
await acceptIncomingCall();
|
||||
return;
|
||||
}
|
||||
await declineIncomingCall();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user