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:
+629
-71
@@ -6,6 +6,7 @@ import {
|
||||
addSignedMessageToChat,
|
||||
addSystemChatMessage,
|
||||
addOutgoingPendingMessage,
|
||||
deleteConversationMessagesBefore,
|
||||
getChatMessages,
|
||||
markChatRead,
|
||||
markOutgoingSent,
|
||||
@@ -23,10 +24,13 @@ import {
|
||||
isTelegramAnimatedEmoji,
|
||||
stopAllTelegramEmojiAnimations,
|
||||
} from '../components/emoji-picker.js?v=202607152130';
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -35,6 +39,12 @@ function truncatePreviewText(value, maxLen = 72) {
|
||||
return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function findMessageByBaseKey(messages, baseKey) {
|
||||
const normalized = String(baseKey || '').trim();
|
||||
if (!normalized) return null;
|
||||
return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null;
|
||||
}
|
||||
|
||||
function openDeleteMessageConfirmModal({ onConfirm }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
@@ -62,12 +72,83 @@ function openDeleteMessageConfirmModal({ onConfirm }) {
|
||||
});
|
||||
}
|
||||
|
||||
function openChatConfirmModal({
|
||||
title = 'Подтвердить действие',
|
||||
text = '',
|
||||
confirmLabel = 'Да',
|
||||
confirmKind = 'primary',
|
||||
onConfirm,
|
||||
}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn';
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="chat-confirm-modal">
|
||||
<div class="modal-card stack dm-dialog-card">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<p class="meta-muted">${text}</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="chat-confirm-no">Нет</button>
|
||||
<button class="${confirmClass}" type="button" id="chat-confirm-yes">${confirmLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#chat-confirm-no')?.addEventListener('click', close);
|
||||
root.querySelector('#chat-confirm-yes')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onConfirm === 'function') await onConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="chat-delete-chat-modal">
|
||||
<div class="modal-card stack dm-dialog-card">
|
||||
<h3 class="modal-title">Удалить чат?</h3>
|
||||
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
|
||||
<label class="dm-confirm-check">
|
||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||
<span>Также удалить всю историю переписки</span>
|
||||
</label>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
||||
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
||||
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
||||
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
||||
close();
|
||||
if (typeof onConfirm === 'function') {
|
||||
await onConfirm({ deleteHistory });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openMessageActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
messageText = '',
|
||||
canReply = false,
|
||||
showReadAloud = true,
|
||||
canEdit = false,
|
||||
canDelete = false,
|
||||
onReply,
|
||||
onReadAloud,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -79,8 +160,9 @@ function openMessageActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
||||
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
|
||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
||||
</div>
|
||||
@@ -137,6 +219,11 @@ function openMessageActionsMenu({
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-reply')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onReply === 'function') await onReply();
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onReadAloud === 'function') await onReadAloud();
|
||||
@@ -153,6 +240,78 @@ function openMessageActionsMenu({
|
||||
});
|
||||
}
|
||||
|
||||
function openChatActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
onCall,
|
||||
onClearHistory,
|
||||
onDeleteChat,
|
||||
}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
|
||||
const menuId = `chat-header-actions-menu-${Date.now()}`;
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = root.querySelector(`#${menuId}`);
|
||||
if (!menu) return;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (menu.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
const left = Math.min(
|
||||
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
|
||||
Math.max(12, viewportWidth - menuRect.width - 12)
|
||||
);
|
||||
const top = Math.min(
|
||||
Math.max(12, Number(anchorY || 0) + 10),
|
||||
Math.max(12, viewportHeight - menuRect.height - 12)
|
||||
);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
|
||||
menu.classList.add('is-visible');
|
||||
});
|
||||
|
||||
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onCall === 'function') await onCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||
});
|
||||
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onDeleteChat === 'function') await onDeleteChat();
|
||||
});
|
||||
}
|
||||
|
||||
function showTtsMissingConfigDialog(navigate) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
@@ -254,6 +413,31 @@ function formatMessageTime(valueMs) {
|
||||
}).format(new Date(timeMs));
|
||||
}
|
||||
|
||||
function formatCallDurationCompact(totalSeconds) {
|
||||
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildCallStatusText(callSummary) {
|
||||
if (!callSummary) return '';
|
||||
if (callSummary.status === 'completed') {
|
||||
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
|
||||
}
|
||||
const reason = String(callSummary.reason || '').trim().toLowerCase();
|
||||
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
|
||||
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
|
||||
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
|
||||
if (reason === 'busy') return 'Не дозвонился: абонент занят';
|
||||
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
|
||||
return 'Не дозвонился';
|
||||
}
|
||||
|
||||
function resolveDeliveryStatus(msg) {
|
||||
if (msg?.from !== 'out') return '';
|
||||
if (msg?.secondTick) return '✓✓';
|
||||
@@ -269,8 +453,13 @@ function resolveMessageEditedTimeMs(msg) {
|
||||
|
||||
function scrollToLatestMessage(list) {
|
||||
if (!list) return;
|
||||
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
|
||||
const lastBubble = list.lastElementChild;
|
||||
const apply = () => {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
if (lastBubble?.scrollIntoView) {
|
||||
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' });
|
||||
}
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
};
|
||||
apply();
|
||||
window.requestAnimationFrame(apply);
|
||||
@@ -294,7 +483,51 @@ function renderMessageText(textNode, messageText) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||
function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) {
|
||||
if (!list) return;
|
||||
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
|
||||
const lastBubble = list.lastElementChild;
|
||||
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
|
||||
const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop);
|
||||
const useSmooth = smoothIfNearBottom && distanceToBottom <= 180;
|
||||
|
||||
const apply = (behavior = useSmooth ? 'smooth' : 'auto') => {
|
||||
if (lastBubble?.scrollIntoView) {
|
||||
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior });
|
||||
}
|
||||
if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') {
|
||||
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
apply(useSmooth ? 'smooth' : 'auto');
|
||||
window.requestAnimationFrame(() => apply('auto'));
|
||||
window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90);
|
||||
}
|
||||
|
||||
function scrollToUnreadSeparator(list) {
|
||||
if (!list) return false;
|
||||
const separator = list.querySelector('.chat-unread-separator');
|
||||
if (!separator) return false;
|
||||
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
|
||||
const apply = () => {
|
||||
if (separator?.scrollIntoView) {
|
||||
separator.scrollIntoView({ block: 'start', inline: 'nearest' });
|
||||
}
|
||||
const bottomSlack = 72;
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack);
|
||||
};
|
||||
apply();
|
||||
window.requestAnimationFrame(apply);
|
||||
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
|
||||
window.setTimeout(apply, 60);
|
||||
window.setTimeout(apply, 160);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
@@ -310,13 +543,70 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||
}
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
const bubbleKind = String(msg?.kind || '').trim();
|
||||
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
|
||||
const autoKind = parsedText.callSummary ? 'call-tech' : '';
|
||||
const bubbleKind = String(msg?.kind || autoKind || '').trim();
|
||||
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
|
||||
|
||||
if (parsedText.callSummary) {
|
||||
const callCard = document.createElement('div');
|
||||
callCard.className = 'bubble-call-card';
|
||||
|
||||
const callHead = document.createElement('div');
|
||||
callHead.className = 'bubble-call-head';
|
||||
|
||||
const callIcon = document.createElement('span');
|
||||
callIcon.className = 'bubble-call-icon';
|
||||
callIcon.textContent = '☎';
|
||||
|
||||
const callTitle = document.createElement('span');
|
||||
callTitle.className = 'bubble-call-title';
|
||||
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
|
||||
|
||||
callHead.append(callIcon, callTitle);
|
||||
|
||||
const callMetaLine = document.createElement('div');
|
||||
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
|
||||
const callStatus = buildCallStatusText(parsedText.callSummary);
|
||||
callMetaLine.textContent = callStatus;
|
||||
|
||||
callCard.append(callHead, callMetaLine);
|
||||
bubble.append(callCard);
|
||||
}
|
||||
|
||||
const textNode = document.createElement('div');
|
||||
textNode.className = 'bubble-text';
|
||||
renderMessageText(textNode, msg.text);
|
||||
bubble.append(textNode);
|
||||
renderMessageText(textNode, parsedText.displayText || '');
|
||||
if (parsedText.replyRef) {
|
||||
const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey);
|
||||
if (replyTarget) {
|
||||
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
|
||||
const replyBox = document.createElement('button');
|
||||
replyBox.type = 'button';
|
||||
replyBox.className = 'bubble-reply-preview';
|
||||
|
||||
const replyAuthor = document.createElement('div');
|
||||
replyAuthor.className = 'bubble-reply-preview-author';
|
||||
replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin);
|
||||
|
||||
const replyText = document.createElement('div');
|
||||
replyText.className = 'bubble-reply-preview-text';
|
||||
replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96);
|
||||
|
||||
replyBox.append(replyAuthor, replyText);
|
||||
replyBox.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`);
|
||||
if (targetNode?.scrollIntoView) {
|
||||
targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
bubble.append(replyBox);
|
||||
}
|
||||
}
|
||||
if (!parsedText.callSummary) {
|
||||
bubble.append(textNode);
|
||||
}
|
||||
|
||||
const metaNode = document.createElement('div');
|
||||
metaNode.className = 'bubble-meta';
|
||||
@@ -347,10 +637,46 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||
bubble.addEventListener('click', (event) => {
|
||||
if (typeof onOpenActions === 'function') onOpenActions(msg, event);
|
||||
});
|
||||
bubble.dataset.baseKey = String(msg?.baseKey || '');
|
||||
list.append(bubble);
|
||||
});
|
||||
scrollToLatestMessage(list);
|
||||
markChatRead(chatId);
|
||||
if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) {
|
||||
scrollToLatestMessage(list);
|
||||
} else if (scrollMode === 'latest') {
|
||||
scrollToLatestMessage(list);
|
||||
}
|
||||
if (markAsRead) {
|
||||
markChatRead(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
function preserveComposerSelection(input, callback) {
|
||||
if (!input || typeof callback !== 'function') {
|
||||
if (typeof callback === 'function') callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const wasFocused = document.activeElement === input;
|
||||
const start = Number(input.selectionStart ?? input.value.length);
|
||||
const end = Number(input.selectionEnd ?? input.value.length);
|
||||
|
||||
callback();
|
||||
|
||||
if (!wasFocused) return;
|
||||
try {
|
||||
input.focus({ preventScroll: true });
|
||||
} catch {
|
||||
input.focus();
|
||||
}
|
||||
try {
|
||||
input.setSelectionRange(start, end);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function setChatKeyboardOpen(isOpen) {
|
||||
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
@@ -364,14 +690,58 @@ export function render({ navigate, route }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack dm-screen dm-chat-screen';
|
||||
const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings);
|
||||
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||
|
||||
const handleReadAloud = async (msg) => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
|
||||
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
try {
|
||||
await startOutgoingCall(chatId);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
}
|
||||
};
|
||||
|
||||
const clearConversationHistory = async () => {
|
||||
const result = await authService.deleteConversation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
deleteByRecipient: true,
|
||||
});
|
||||
const parsed = authService.parseSignedMessageBlob(String(result?.localBlobB64 || '').trim());
|
||||
deleteConversationMessagesBefore(chatId, Number(parsed?.timeMs || 0));
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey: String(parsed?.messageKey || ''),
|
||||
baseKey: String(parsed?.baseKey || ''),
|
||||
from: 'out',
|
||||
text: CONVERSATION_CLEAR_NOTICE_TEXT,
|
||||
messageType: Number(parsed?.messageType || 8),
|
||||
unread: false,
|
||||
rawBlobB64: String(result?.localBlobB64 || ''),
|
||||
});
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead: false,
|
||||
scrollMode: 'latest',
|
||||
});
|
||||
notifyUnreadStateUpdated();
|
||||
};
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
@@ -384,21 +754,70 @@ export function render({ navigate, route }) {
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [{
|
||||
label: 'Позвонить',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await startOutgoingCall(chatId);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
}
|
||||
rightActions: [
|
||||
{
|
||||
label: '☎',
|
||||
title: 'Позвонить',
|
||||
ariaLabel: 'Позвонить',
|
||||
className: 'chat-header-icon-btn chat-header-call-btn',
|
||||
onClick: handleStartCall,
|
||||
},
|
||||
}],
|
||||
{
|
||||
label: '⋯',
|
||||
title: 'Действия чата',
|
||||
ariaLabel: 'Открыть меню действий чата',
|
||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||
onClick: (event) => {
|
||||
openChatActionsMenu({
|
||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||
onCall: handleStartCall,
|
||||
onClearHistory: async () => {
|
||||
openChatConfirmModal({
|
||||
title: 'Очистить историю?',
|
||||
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
|
||||
confirmLabel: 'Очистить',
|
||||
confirmKind: 'danger',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await clearConversationHistory();
|
||||
showToast('История чата очищена', { timeoutMs: 1200 });
|
||||
} catch (error) {
|
||||
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
onDeleteChat: async () => {
|
||||
openDeleteChatConfirmModal({
|
||||
contactName: contact.name,
|
||||
onConfirm: async ({ deleteHistory }) => {
|
||||
try {
|
||||
if (deleteHistory) {
|
||||
await clearConversationHistory();
|
||||
}
|
||||
await authService.setUserRelation({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
kind: 'contact',
|
||||
enabled: false,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
const contactsPayload = await authService.listContacts();
|
||||
setContacts(contactsPayload?.contacts || []);
|
||||
notifyUnreadStateUpdated();
|
||||
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
|
||||
navigate('messages-list');
|
||||
} catch (error) {
|
||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -452,6 +871,7 @@ export function render({ navigate, route }) {
|
||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
||||
<div class="dm-actions-col">
|
||||
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи">☺</button>
|
||||
${isSpeechToTextReady ? '<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>' : ''}
|
||||
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -463,8 +883,20 @@ export function render({ navigate, route }) {
|
||||
const editBannerText = form.querySelector('#chat-edit-banner-text');
|
||||
const editCancelBtn = form.querySelector('#chat-edit-cancel');
|
||||
let activeEdit = null;
|
||||
let activeReply = null;
|
||||
let inputFocused = false;
|
||||
let emojiPickerOpen = false;
|
||||
let emojiSelection = null;
|
||||
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
|
||||
const syncKeyboardUi = () => {
|
||||
const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
|
||||
setChatKeyboardOpen(inputFocused && viewportShrunk);
|
||||
if (inputFocused) {
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
}
|
||||
};
|
||||
|
||||
const rememberEmojiSelection = () => {
|
||||
if (!input) return;
|
||||
@@ -505,13 +937,17 @@ export function render({ navigate, route }) {
|
||||
|
||||
const syncEditBanner = () => {
|
||||
if (!editBanner || !editBannerText) return;
|
||||
if (!activeEdit) {
|
||||
if (!activeEdit && !activeReply) {
|
||||
editBanner.hidden = true;
|
||||
editBannerText.textContent = '';
|
||||
return;
|
||||
}
|
||||
editBanner.hidden = false;
|
||||
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
|
||||
if (activeEdit) {
|
||||
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
|
||||
return;
|
||||
}
|
||||
editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`;
|
||||
};
|
||||
|
||||
const focusInputToEnd = () => {
|
||||
@@ -537,46 +973,91 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReplyMode = ({ restoreDraft = true } = {}) => {
|
||||
if (!activeReply) return;
|
||||
activeReply = null;
|
||||
syncEditBanner();
|
||||
if (restoreDraft && input) {
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const startEditMode = (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base || msg?.from !== 'out') return;
|
||||
const parsed = parseDmTechBlocks(String(msg?.text || ''));
|
||||
const currentDraft = String(input?.value || '');
|
||||
activeReply = null;
|
||||
activeEdit = {
|
||||
messageKey: String(msg?.messageKey || ''),
|
||||
originalText: String(msg?.text || ''),
|
||||
originalText: String(parsed?.displayText || ''),
|
||||
prefixText: String(parsed?.prefixText || ''),
|
||||
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
|
||||
timeMs: base.timeMs,
|
||||
nonce: base.nonce,
|
||||
};
|
||||
syncEditBanner();
|
||||
if (input) {
|
||||
input.value = String(msg?.text || '');
|
||||
input.value = String(parsed?.visibleText || '');
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
const startReplyMode = (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base) return;
|
||||
const parsed = parseDmTechBlocks(String(msg?.text || ''));
|
||||
activeEdit = null;
|
||||
activeReply = {
|
||||
baseKey: String(msg?.baseKey || ''),
|
||||
fromLogin: base.fromLogin,
|
||||
toLogin: base.toLogin,
|
||||
previewText: String(parsed?.displayText || parsed?.visibleText || ''),
|
||||
};
|
||||
syncEditBanner();
|
||||
focusInputToEnd();
|
||||
};
|
||||
|
||||
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
if (!localOutgoingBlobB64) return;
|
||||
try {
|
||||
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
|
||||
let text = '';
|
||||
if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) {
|
||||
const decrypted = await authService.decryptSignedMessageContent({
|
||||
parsed,
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
text = String(decrypted?.text || '');
|
||||
}
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey: fallbackMessageKey || parsed?.messageKey || '',
|
||||
baseKey: fallbackBaseKey || parsed?.baseKey || '',
|
||||
from: 'out',
|
||||
text: parsed?.text || '',
|
||||
text,
|
||||
messageType: Number(parsed?.messageType || 2),
|
||||
unread: false,
|
||||
rawBlobB64: localOutgoingBlobB64,
|
||||
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
|
||||
deleted: Boolean(parsed?.deleted),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// ignore local parse failure; server backlog/realtime will reconcile later
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const notifyUnreadStateUpdated = () => {
|
||||
window.dispatchEvent(new CustomEvent('shine-unread-state-updated', {
|
||||
detail: { chatId },
|
||||
}));
|
||||
};
|
||||
|
||||
const sendDeleteRevision = async (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base) return;
|
||||
@@ -587,11 +1068,13 @@ export function render({ navigate, route }) {
|
||||
timeMs: base.timeMs,
|
||||
nonce: base.nonce,
|
||||
revisionTimeMs: Date.now(),
|
||||
deleteByRecipient: msg?.from === 'in',
|
||||
});
|
||||
applyLocalRevision({
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey: String(msg?.messageKey || ''),
|
||||
baseKey: String(msg?.baseKey || ''),
|
||||
deleted: true,
|
||||
});
|
||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
@@ -600,11 +1083,17 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const text = String(rawText || '').trim();
|
||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||
const text = safeText.trim();
|
||||
if (!text) return;
|
||||
const editing = activeEdit;
|
||||
const replying = !editing ? activeReply : null;
|
||||
const finalText = editing
|
||||
? `${String(editing?.prefixText || '')}${text}`
|
||||
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
|
||||
try {
|
||||
let result;
|
||||
@@ -612,7 +1101,7 @@ export function render({ navigate, route }) {
|
||||
result = await authService.sendDirectMessageRevision({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
text: finalText,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
timeMs: editing.timeMs,
|
||||
nonce: editing.nonce,
|
||||
@@ -622,7 +1111,7 @@ export function render({ navigate, route }) {
|
||||
result = await authService.sendDirectMessage({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
text: finalText,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
markOutgoingSent(tempId, {
|
||||
@@ -631,7 +1120,7 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
}
|
||||
|
||||
applyLocalRevision({
|
||||
const localRevisionApplied = await applyLocalRevision({
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
@@ -639,13 +1128,24 @@ export function render({ navigate, route }) {
|
||||
|
||||
if (editing) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
} else if (replying) {
|
||||
cancelReplyMode({ restoreDraft: false });
|
||||
}
|
||||
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
scrollToLatestMessage(log);
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
window.setTimeout(() => scrollToLatestMessage(log), 90);
|
||||
window.setTimeout(() => scrollToLatestMessage(log), 220);
|
||||
if (localRevisionApplied) {
|
||||
notifyUnreadStateUpdated();
|
||||
}
|
||||
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
|
||||
window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
|
||||
window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220);
|
||||
if (input) {
|
||||
try {
|
||||
input.focus({ preventScroll: true });
|
||||
} catch {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'outgoing-dm',
|
||||
@@ -655,6 +1155,7 @@ export function render({ navigate, route }) {
|
||||
messageId: result?.outgoingKey || '',
|
||||
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
|
||||
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
|
||||
replyToBaseKey: replying?.baseKey || '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -678,12 +1179,21 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
const handleOpenActions = (msg, event) => {
|
||||
const parsed = parseDmTechBlocks(String(msg?.text || ''));
|
||||
openMessageActionsMenu({
|
||||
anchorX: Number(event?.clientX || 0),
|
||||
anchorY: Number(event?.clientY || 0),
|
||||
messageText: msg?.text || '',
|
||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||
messageText: parsed?.displayText || msg?.text || '',
|
||||
canReply: true,
|
||||
showReadAloud: isTextToSpeechReady,
|
||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
|
||||
canDelete: (
|
||||
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|
||||
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
|
||||
),
|
||||
onReply: async () => {
|
||||
startReplyMode(msg);
|
||||
},
|
||||
onReadAloud: async () => handleReadAloud(msg),
|
||||
onEdit: async () => {
|
||||
startEditMode(msg);
|
||||
@@ -703,7 +1213,11 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
editCancelBtn?.addEventListener('click', () => {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
if (activeEdit) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
return;
|
||||
}
|
||||
cancelReplyMode({ restoreDraft: true });
|
||||
});
|
||||
|
||||
autoResizeComposer(input);
|
||||
@@ -716,8 +1230,14 @@ export function render({ navigate, route }) {
|
||||
input?.addEventListener('click', rememberEmojiSelection);
|
||||
input?.addEventListener('focus', () => {
|
||||
rememberEmojiSelection();
|
||||
inputFocused = true;
|
||||
syncKeyboardUi();
|
||||
scrollToLatestMessage(log);
|
||||
});
|
||||
input?.addEventListener('blur', () => {
|
||||
inputFocused = false;
|
||||
setChatKeyboardOpen(false);
|
||||
});
|
||||
emojiToggle?.setAttribute('aria-expanded', 'false');
|
||||
emojiToggle?.addEventListener('pointerdown', (event) => {
|
||||
rememberEmojiSelection();
|
||||
@@ -734,28 +1254,31 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
input?.addEventListener('keydown', async (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
const start = Number(input.selectionStart ?? input.value.length);
|
||||
const end = Number(input.selectionEnd ?? input.value.length);
|
||||
const value = String(input.value || '');
|
||||
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
|
||||
const nextPos = start + 1;
|
||||
try {
|
||||
input.setSelectionRange(nextPos, nextPos);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
autoResizeComposer(input);
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => autoResizeComposer(input));
|
||||
});
|
||||
|
||||
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(input.value || '').trim();
|
||||
input.value = prev ? `${prev} ${text}` : text;
|
||||
autoResizeComposer(input);
|
||||
},
|
||||
onSendText: async (text) => sendTextMessage(text),
|
||||
onSendQueued: () => {
|
||||
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
const text = String(input.value || '').trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
autoResizeComposer(input);
|
||||
closeEmojiPicker();
|
||||
await sendTextMessage(text);
|
||||
try {
|
||||
input?.focus({ preventScroll: true });
|
||||
} catch {
|
||||
input?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
@@ -766,14 +1289,50 @@ export function render({ navigate, route }) {
|
||||
autoResizeComposer(input);
|
||||
closeEmojiPicker();
|
||||
await sendTextMessage(text);
|
||||
focusInputToEnd();
|
||||
});
|
||||
|
||||
const handleIncomingChatRefresh = async (event) => {
|
||||
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||
if (updatedChatId !== chatId) return;
|
||||
preserveComposerSelection(input, () => {
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
|
||||
});
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
};
|
||||
|
||||
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
|
||||
window.addEventListener('resize', syncKeyboardUi);
|
||||
|
||||
wrap.append(log, form);
|
||||
screen.append(wrap);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: handleOpenActions,
|
||||
markAsRead: false,
|
||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||
});
|
||||
if (hasUnreadIncoming) {
|
||||
window.requestAnimationFrame(() => scrollToUnreadSeparator(log));
|
||||
window.setTimeout(() => scrollToUnreadSeparator(log), 180);
|
||||
} else {
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (markChatRead(chatId) > 0) {
|
||||
notifyUnreadStateUpdated();
|
||||
}
|
||||
}, 220);
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
screen.cleanup = () => {
|
||||
setChatKeyboardOpen(false);
|
||||
stopAllTelegramEmojiAnimations();
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
|
||||
window.removeEventListener('resize', syncKeyboardUi);
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -793,7 +1352,6 @@ async function sendReadReceiptsForVisible(chatId) {
|
||||
refFromLogin: ref.fromLogin,
|
||||
refTimeMs: ref.timeMs,
|
||||
refNonce: ref.nonce,
|
||||
refType: 1,
|
||||
});
|
||||
if (row.baseKey) {
|
||||
markReadReceiptSentByBaseKey(row.baseKey);
|
||||
|
||||
Reference in New Issue
Block a user