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:
Pixel
2026-07-16 11:41:41 +03:00
co-authored by Claude Opus 4.8
374 changed files with 18484 additions and 3887 deletions
+289 -20
View File
@@ -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();