Improve direct message history and SQLite resilience

This commit is contained in:
AidarKC
2026-07-22 13:09:42 +04:00
parent 2f3b1571e5
commit 93673e5786
18 changed files with 908 additions and 233 deletions
+1 -1
View File
@@ -1203,7 +1203,7 @@ async function init() {
}
if (refBaseKey) {
if (messageType === 3) {
markOutgoingReadByBaseKey(refBaseKey);
markOutgoingReadByBaseKey(refBaseKey, Number(parsed.timeMs || 0));
} else {
if (markIncomingReadByBaseKey(refBaseKey)) {
shouldRefreshToolbarUnread = true;
+131 -3
View File
@@ -144,6 +144,7 @@ function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
messageText = '',
infoText = '',
canReply = false,
showReadAloud = true,
canEdit = false,
@@ -165,6 +166,7 @@ function openMessageActionsMenu({
${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>' : ''}
${String(infoText || '').trim() ? `<div class="meta-muted">${String(infoText || '').trim()}</div>` : ''}
</div>
</div>
`;
@@ -438,9 +440,27 @@ function buildCallStatusText(callSummary) {
return 'Не дозвонился';
}
function resolveDeliveryStatus(msg) {
function resolveEffectiveReadState(messages, msg) {
if (msg?.from !== 'out') return { isRead: false, readAtMs: 0 };
const explicitReadAtMs = Number(msg?.readAtMs || 0);
if (Number.isFinite(explicitReadAtMs) && explicitReadAtMs > 0) {
return { isRead: true, readAtMs: explicitReadAtMs };
}
if (msg?.secondTick) {
return { isRead: true, readAtMs: 0 };
}
const messageTimeMs = resolveMessageTimeMs(msg);
const hasNewerRead = (messages || []).some((row) => {
if (row?.from !== 'out') return false;
if (resolveMessageTimeMs(row) <= messageTimeMs) return false;
return Boolean(Number(row?.readAtMs || 0) > 0 || row?.secondTick);
});
return { isRead: hasNewerRead, readAtMs: 0 };
}
function resolveDeliveryStatus(messages, msg) {
if (msg?.from !== 'out') return '';
if (msg?.secondTick) return '✓✓';
if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓';
if (msg?.firstTick) return '✓';
return '…';
}
@@ -625,7 +645,7 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode);
const status = resolveDeliveryStatus(msg);
const status = resolveDeliveryStatus(messages, msg);
if (status) {
const statusNode = document.createElement('span');
statusNode.className = 'bubble-status';
@@ -684,6 +704,49 @@ function preserveComposerSelection(input, callback) {
}
}
async function mergeDirectMessagesPage(chatId, payloadMessages) {
const items = Array.isArray(payloadMessages) ? [...payloadMessages] : [];
items.sort((a, b) => Number(a?.timeMs || 0) - Number(b?.timeMs || 0));
for (const item of items) {
const blobB64 = String(item?.blobB64 || '').trim();
const messageKey = String(item?.messageKey || '').trim();
if (!blobB64 || !messageKey) continue;
try {
const parsed = authService.parseSignedMessageBlob(blobB64);
let text = '';
try {
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
text = String(decrypted?.text || '');
} catch (error) {
text = `Нерасшифрованное сообщение (${error?.message || 'decrypt failed'})`;
}
addSignedMessageToChat({
chatId,
messageKey,
baseKey: String(item?.baseKey || parsed?.baseKey || ''),
from: Number(parsed?.messageType || 0) === 2 ? 'out' : 'in',
text,
messageType: Number(parsed?.messageType || item?.messageType || 0),
unread: false,
readAtMs: Number(item?.readAtMs || 0),
rawBlobB64: blobB64,
revisionTimeMs: Number(item?.revisionTimeMs || parsed?.revisionTimeMs || 0),
});
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'dm-history',
message: 'Не удалось обработать сообщение из истории DM',
details: { chatId, messageKey, error: error?.message || 'unknown' },
});
}
}
}
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
}
@@ -703,6 +766,12 @@ export function render({ navigate, route }) {
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);
let historyHasMore = true;
let historyLoading = false;
let historyNextBeforeTimeMs = 0;
let historyNextBeforeMessageKey = '';
let historyBootstrapped = false;
let boundScrollContainer = null;
const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
@@ -1195,10 +1264,14 @@ export function render({ navigate, route }) {
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const readState = resolveEffectiveReadState(getChatMessages(chatId), msg);
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: parsed?.displayText || msg?.text || '',
infoText: (msg?.from === 'out' && readState.readAtMs > 0)
? `Прочитано: ${formatMessageTime(readState.readAtMs)}`
: '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
@@ -1227,6 +1300,55 @@ export function render({ navigate, route }) {
});
};
const loadHistoryPage = async ({ preserveScroll = false } = {}) => {
if (historyLoading) return;
if (historyBootstrapped && !historyHasMore) return;
historyLoading = true;
const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
const prevScrollTop = Number(scrollContainer?.scrollTop || 0);
const prevScrollHeight = Number(scrollContainer?.scrollHeight || 0);
try {
const payload = await authService.getDirectMessages({
peerLogin: chatId,
limit: 50,
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
});
await mergeDirectMessagesPage(chatId, payload?.messages || []);
historyHasMore = Boolean(payload?.hasMore);
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
historyBootstrapped = true;
renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
if (preserveScroll) {
window.requestAnimationFrame(() => {
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
scrollContainer.scrollTop = Math.max(0, prevScrollTop + (nextHeight - prevScrollHeight));
});
} else {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
void sendReadReceiptsForVisible(chatId);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'dm-history',
message: 'Не удалось загрузить историю DM',
details: { chatId, error: error?.message || 'unknown' },
});
} finally {
historyLoading = false;
}
};
const handleHistoryScroll = () => {
const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
if (!scrollContainer) return;
if (Number(scrollContainer.scrollTop || 0) > 120) return;
if (!historyBootstrapped || !historyHasMore || historyLoading) return;
void loadHistoryPage({ preserveScroll: true });
};
editCancelBtn?.addEventListener('click', () => {
if (activeEdit) {
cancelEditMode({ restoreDraft: true });
@@ -1367,12 +1489,18 @@ export function render({ navigate, route }) {
}
}, 220);
void sendReadReceiptsForVisible(chatId);
window.requestAnimationFrame(() => {
boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
void loadHistoryPage({ preserveScroll: false });
});
screen.cleanup = () => {
setChatKeyboardOpen(false);
stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
};
return screen;
}
+17
View File
@@ -2579,6 +2579,23 @@ export class AuthService {
};
}
async getDirectMessages({ peerLogin, limit = 50, beforeTimeMs = 0, beforeMessageKey = '' } = {}) {
const payload = {
peerLogin: String(peerLogin || '').trim(),
limit: Number(limit || 0),
};
if (!payload.peerLogin) throw new Error('Не передан peerLogin');
if (Number.isFinite(Number(beforeTimeMs)) && Number(beforeTimeMs) > 0) {
payload.beforeTimeMs = Math.trunc(Number(beforeTimeMs));
}
if (String(beforeMessageKey || '').trim()) {
payload.beforeMessageKey = String(beforeMessageKey || '').trim();
}
const response = await this.ws.request('GetDirectMessages', payload);
if (response.status !== 200) throw opError('GetDirectMessages', response);
return response.payload || {};
}
async ackSessionDelivery(messageKey) {
const response = await this.ws.request('AckSessionDelivery', { messageKey });
if (response.status !== 200) throw opError('AckSessionDelivery', response);
+23 -2
View File
@@ -420,6 +420,7 @@ function persistMessageRecord(chatId, row) {
unread: Boolean(row.unread),
firstTick: Boolean(row.firstTick),
secondTick: Boolean(row.secondTick),
readAtMs: Number(row.readAtMs || 0),
readReceiptSent: Boolean(row.readReceiptSent),
refBaseKey: String(row.refBaseKey || ''),
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
@@ -455,6 +456,7 @@ export async function hydrateMessagesFromStore() {
unread: Boolean(row.unread),
firstTick: Boolean(row.firstTick),
secondTick: Boolean(row.secondTick),
readAtMs: Number(row.readAtMs || 0),
readReceiptSent: Boolean(row.readReceiptSent),
refBaseKey: String(row.refBaseKey || ''),
createdAtMs: Number(row.ts || 0),
@@ -556,8 +558,9 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
});
}
export function markOutgoingReadByBaseKey(baseKey) {
export function markOutgoingReadByBaseKey(baseKey, readAtMs = 0) {
if (!baseKey) return;
const normalizedReadAtMs = Number(readAtMs || 0);
const keys = Object.keys(state.chats || {});
let matched = false;
keys.forEach((chatId) => {
@@ -567,6 +570,9 @@ export function markOutgoingReadByBaseKey(baseKey) {
if (row.baseKey === baseKey) {
matched = true;
row.secondTick = true;
if (Number.isFinite(normalizedReadAtMs) && normalizedReadAtMs > 0) {
row.readAtMs = normalizedReadAtMs;
}
persistMessageRecord(chatId, row);
}
});
@@ -574,7 +580,9 @@ export function markOutgoingReadByBaseKey(baseKey) {
if (matched) {
delete state.pendingOutgoingReadByBaseKey[baseKey];
} else {
state.pendingOutgoingReadByBaseKey[baseKey] = true;
state.pendingOutgoingReadByBaseKey[baseKey] = (Number.isFinite(normalizedReadAtMs) && normalizedReadAtMs > 0)
? normalizedReadAtMs
: true;
}
return matched;
}
@@ -626,6 +634,7 @@ export function addSignedMessageToChat({
text = '',
messageType = 1,
unread = false,
readAtMs = 0,
rawBlobB64 = '',
refBaseKey = '',
revisionTimeMs = 0,
@@ -667,9 +676,14 @@ export function addSignedMessageToChat({
row.refBaseKey = String(refBaseKey || '');
row.firstTick = row.from === 'out';
row.secondTick = Boolean(existing?.secondTick);
row.readAtMs = Number(existing?.readAtMs || 0);
row.readReceiptSent = Boolean(existing?.readReceiptSent);
if (row.baseKey && row.from === 'out' && state.pendingOutgoingReadByBaseKey[row.baseKey]) {
row.secondTick = true;
const pendingReadAtMs = Number(state.pendingOutgoingReadByBaseKey[row.baseKey] || 0);
if (Number.isFinite(pendingReadAtMs) && pendingReadAtMs > 0) {
row.readAtMs = pendingReadAtMs;
}
delete state.pendingOutgoingReadByBaseKey[row.baseKey];
}
if (row.baseKey && row.from === 'in' && state.pendingIncomingReadByBaseKey[row.baseKey]) {
@@ -681,6 +695,13 @@ export function addSignedMessageToChat({
if (existingIndex < 0) {
list.push(row);
}
const nextReadAtMs = Number(readAtMs || 0);
if (Number.isFinite(nextReadAtMs) && nextReadAtMs > 0) {
row.readAtMs = nextReadAtMs;
if (row.from === 'out') {
row.secondTick = true;
}
}
sortChatMessagesInPlace(normalizedChatId);
persistMessageRecord(normalizedChatId, row);
return true;