SHA256
Merge origin/main (+56: DM v1 E2EE, промокоды, recovery key) + локальные фичи
Слито и разрешено вручную: - chat-view.js: база — новый DM-чат агента (ответы/реплаи, ревизии, клавиатурный UX, голосовой ввод); поверх встроен эмодзи-пикер (Telegram-набор): кнопка ☺, вставка в позицию курсора, анимированные эмодзи в пузырях (renderMessageText поверх displayText), остановка анимаций в cleanup. - register-view.js: база — версия агента (промокоды, глаз пароля, TEMP-заглушка коротких логинов); возвращена компактная ссылка «Вопросы о регистрации» (FAQ-экран у агента осиротел — снова достижим). - components.css: шапка «Связей» — вариант агента (sticky + grid minmax, заголовок сокращается, кнопки не обрезаются); стили эмодзи-пикера сохранены. - Заметки Pending_Features (шапка связей, поиск каналов, локальный вход) переложены в новую структуру docs/Pending_Features. - VERSION: client 1.2.314, server 1.2.292 (продолжение нумерации origin). Проверено локально: старт → «Открыть локальный тестовый режим» → Личные → чат: эмодзи-пикер открывается, эмодзи вставляется, PNG-ассеты грузятся; регистрация: промокод/12 слов/FAQ-ссылка работают; консоль чистая. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+289
-20
@@ -15,6 +15,7 @@ import {
|
||||
startDebugConnectionAsInitiator,
|
||||
startDebugConnectionAsResponder,
|
||||
} from './services/call-service.js';
|
||||
import { parseDmTechBlocks } from './services/dm-tech-blocks.js';
|
||||
import {
|
||||
authService,
|
||||
addAppLogEntry,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
addSignedMessageToChat,
|
||||
deleteConversationMessagesBefore,
|
||||
deleteSignedMessageByBaseKey,
|
||||
markIncomingReadByBaseKey,
|
||||
markOutgoingReadByBaseKey,
|
||||
normalizeDmChatId,
|
||||
@@ -47,14 +50,16 @@ import * as loginCameraView from './pages/login-camera-view.js';
|
||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
||||
import * as keyStorageView from './pages/key-storage-view.js';
|
||||
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202605300007';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as toolsSettingsView from './pages/tools-settings-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
@@ -79,6 +84,18 @@ import * as addPersonalPublicChatView from './pages/add-personal-public-chat-vie
|
||||
import * as networkView from './pages/network-view.js';
|
||||
import * as notificationsView from './pages/notifications-view.js';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
const DM_DECRYPT_FAILED_TEXT = 'Не удалось расшифровать сообщение';
|
||||
const DM_CORRUPTED_TEXT = 'Сообщение повреждено';
|
||||
|
||||
function dmDecryptFallbackText(error) {
|
||||
const kind = authService.classifyDmDecryptFailure(error);
|
||||
if (kind === 'unsupported_format') return DM_UNSUPPORTED_FORMAT_TEXT;
|
||||
if (kind === 'corrupted') return DM_CORRUPTED_TEXT;
|
||||
return DM_DECRYPT_FAILED_TEXT;
|
||||
}
|
||||
|
||||
const routes = {
|
||||
'start-view': startView,
|
||||
'entry-settings-view': entrySettingsView,
|
||||
@@ -94,6 +111,7 @@ const routes = {
|
||||
'login-other-device-view': loginOtherDeviceView,
|
||||
'login-password-view': loginPasswordView,
|
||||
'key-storage-view': keyStorageView,
|
||||
queue: publicSupportQueueView,
|
||||
'profile-view': profileView,
|
||||
'profile-edit-view': profileEditView,
|
||||
'wallet-view': walletView,
|
||||
@@ -101,6 +119,7 @@ const routes = {
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'tools-settings-view': toolsSettingsView,
|
||||
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
'device-pairing-view': clientPairingView,
|
||||
@@ -131,6 +150,8 @@ const toolbarEl = document.getElementById('toolbar-slot');
|
||||
const appShellEl = document.querySelector('.app-shell');
|
||||
|
||||
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
|
||||
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
|
||||
const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50;
|
||||
const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000;
|
||||
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
|
||||
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
|
||||
@@ -150,6 +171,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',
|
||||
@@ -160,6 +183,7 @@ const GUEST_ALLOWED_PAGES = new Set([
|
||||
'channel-thread-view',
|
||||
'user',
|
||||
'contact-search-view',
|
||||
'queue',
|
||||
]);
|
||||
|
||||
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
||||
@@ -172,6 +196,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');
|
||||
}
|
||||
@@ -555,6 +667,44 @@ function wsIsOpen() {
|
||||
return !!(ws && ws.readyState === WebSocket.OPEN);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSignedDmDecryptContext(timeoutMs = SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS) {
|
||||
const startedAtMs = Date.now();
|
||||
while ((Date.now() - startedAtMs) < timeoutMs) {
|
||||
const login = String(state.session?.login || '').trim();
|
||||
const storagePwd = String(state.session?.storagePwdInMemory || '').trim();
|
||||
if (login && storagePwd) {
|
||||
return { login, storagePwd };
|
||||
}
|
||||
await sleep(SIGNED_DM_DECRYPT_CONTEXT_POLL_MS);
|
||||
}
|
||||
return {
|
||||
login: String(state.session?.login || '').trim(),
|
||||
storagePwd: String(state.session?.storagePwdInMemory || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignedDmChatId({ parsed, login }) {
|
||||
const parsedBlock = parsed || {};
|
||||
const messageType = Number(parsedBlock.messageType || 0);
|
||||
const fromLogin = String(parsedBlock.fromLogin || '').trim();
|
||||
const toLogin = String(parsedBlock.toLogin || '').trim();
|
||||
const currentLogin = String(login || '').trim().toLowerCase();
|
||||
|
||||
if (messageType === 1) return normalizeDmChatId(fromLogin);
|
||||
if (messageType === 2) return normalizeDmChatId(toLogin);
|
||||
|
||||
if (currentLogin && currentLogin === fromLogin.toLowerCase()) {
|
||||
return normalizeDmChatId(toLogin);
|
||||
}
|
||||
return normalizeDmChatId(fromLogin);
|
||||
}
|
||||
|
||||
async function ensureSessionAfterWsReconnect() {
|
||||
if (!state.session.isAuthorized) return true;
|
||||
if (wsSessionRestoreInFlight) return wsSessionRestoreInFlight;
|
||||
@@ -754,6 +904,28 @@ function renderApp() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshToolbarOnly() {
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
function refreshUnreadUi() {
|
||||
const pageId = getRoute().pageId || '';
|
||||
if (pageId === 'messages-list') {
|
||||
renderApp();
|
||||
return;
|
||||
}
|
||||
refreshToolbarOnly();
|
||||
}
|
||||
|
||||
async function tryAutoLogin() {
|
||||
if (state.session.isLocalDemo) return;
|
||||
if (!state.session.login || !state.session.sessionId) return;
|
||||
@@ -928,10 +1100,48 @@ async function init() {
|
||||
const fromLogin = parsed.fromLogin || '';
|
||||
const toLogin = parsed.toLogin || '';
|
||||
const messageType = Number(parsed.messageType || 0);
|
||||
const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
|
||||
const text = (messageType === 1 || messageType === 2)
|
||||
? String(parsed.text || '')
|
||||
: '';
|
||||
let shouldAckSessionDelivery = true;
|
||||
let text = '';
|
||||
let sessionLoginForRouting = String(state.session?.login || '').trim();
|
||||
if (messageType === 1 || messageType === 2) {
|
||||
const decryptContext = await waitForSignedDmDecryptContext();
|
||||
if (!decryptContext.login || !decryptContext.storagePwd) {
|
||||
shouldAckSessionDelivery = false;
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'signed-dm',
|
||||
message: 'Отложена обработка DM: decrypt-контекст сессии ещё не готов',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sessionLoginForRouting = decryptContext.login;
|
||||
try {
|
||||
const decrypted = await authService.decryptSignedMessageContent({
|
||||
parsed,
|
||||
login: decryptContext.login,
|
||||
storagePwd: decryptContext.storagePwd,
|
||||
});
|
||||
text = String(decrypted?.text || '');
|
||||
} catch (error) {
|
||||
text = dmDecryptFallbackText(error);
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'signed-dm',
|
||||
message: 'Не удалось расшифровать DM',
|
||||
details: {
|
||||
messageKey,
|
||||
baseKey: parsed.baseKey,
|
||||
error: error?.message || 'unknown',
|
||||
fallbackText: text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const chatId = resolveSignedDmChatId({
|
||||
parsed,
|
||||
login: sessionLoginForRouting,
|
||||
});
|
||||
|
||||
let shouldRefreshToolbarUnread = false;
|
||||
|
||||
@@ -962,16 +1172,24 @@ 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) {
|
||||
const parsedText = parseDmTechBlocks(text);
|
||||
const displayText = String(parsedText.displayText || '');
|
||||
if (document.visibilityState === 'visible') {
|
||||
void playDmSignal({ extended: false });
|
||||
} else if (Notification.permission === 'granted') {
|
||||
try {
|
||||
void notifyHiddenIncomingMessage(fromLogin, displayText || '');
|
||||
} catch {}
|
||||
} else {
|
||||
void playDmSignal({ extended: true });
|
||||
}
|
||||
}
|
||||
} else if (messageType === 3 || messageType === 4) {
|
||||
let refBaseKey = String(payload.receiptRefBaseKey || '').trim();
|
||||
if (!refBaseKey) {
|
||||
try {
|
||||
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
|
||||
const ref = parsed.readReceiptPayload || authService.parseReadReceiptPayload(parsed.payloadBytes);
|
||||
refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
|
||||
} catch {}
|
||||
}
|
||||
@@ -979,7 +1197,9 @@ async function init() {
|
||||
if (messageType === 3) {
|
||||
markOutgoingReadByBaseKey(refBaseKey);
|
||||
} else {
|
||||
markIncomingReadByBaseKey(refBaseKey);
|
||||
if (markIncomingReadByBaseKey(refBaseKey)) {
|
||||
shouldRefreshToolbarUnread = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
addAppLogEntry({
|
||||
@@ -988,21 +1208,62 @@ async function init() {
|
||||
message: 'Получено подтверждение прочтения',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.ackSessionDelivery(messageKey);
|
||||
} catch (error) {
|
||||
} else if (messageType === 5 || messageType === 6) {
|
||||
const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey);
|
||||
if (changed) shouldRefreshToolbarUnread = true;
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
level: 'info',
|
||||
source: 'signed-dm',
|
||||
message: 'Не удалось отправить ACK доставки по сессии',
|
||||
details: { messageKey, error: error?.message || 'unknown' },
|
||||
message: 'Получено удаление сообщения',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType, changed },
|
||||
});
|
||||
} else if (messageType === 7 || messageType === 8) {
|
||||
const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0));
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey,
|
||||
baseKey: parsed.baseKey,
|
||||
from: messageType === 8 ? 'out' : 'in',
|
||||
text: CONVERSATION_CLEAR_NOTICE_TEXT,
|
||||
messageType,
|
||||
unread: false,
|
||||
rawBlobB64: blobB64,
|
||||
});
|
||||
if (removed > 0) shouldRefreshToolbarUnread = true;
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'signed-dm',
|
||||
message: 'Получено удаление истории переписки',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) },
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldAckSessionDelivery) {
|
||||
try {
|
||||
await authService.ackSessionDelivery(messageKey);
|
||||
} catch (error) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'signed-dm',
|
||||
message: 'Не удалось отправить ACK доставки по сессии',
|
||||
details: { messageKey, error: error?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pageId = getRoute().pageId || '';
|
||||
if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) {
|
||||
if (pageId === 'chat-view') {
|
||||
window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', {
|
||||
detail: {
|
||||
chatId,
|
||||
messageType,
|
||||
messageKey,
|
||||
},
|
||||
}));
|
||||
if (shouldRefreshToolbarUnread) {
|
||||
refreshToolbarOnly();
|
||||
}
|
||||
} else if (pageId === 'messages-list' || shouldRefreshToolbarUnread) {
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
@@ -1011,6 +1272,8 @@ async function init() {
|
||||
try { await handleIncomingCallInvite(evt); } catch {}
|
||||
});
|
||||
|
||||
window.addEventListener('shine-unread-state-updated', refreshUnreadUi);
|
||||
|
||||
authService.onEvent('IncomingCallSignal', async (evt) => {
|
||||
try { await handleIncomingCallSignal(evt); } catch {}
|
||||
});
|
||||
@@ -1099,6 +1362,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();
|
||||
|
||||
Reference in New Issue
Block a user