Звонки: WebPush incoming/stop, actions и TTL; обновлена логика

This commit is contained in:
AidarKC
2026-05-02 18:25:44 +03:00
parent 310863faec
commit c0c29b74ab
10 changed files with 711 additions and 208 deletions
+103
View File
@@ -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 }));
});
}
+108 -63
View File
@@ -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();
}