SHA256
Improve direct message history and SQLite resilience
This commit is contained in:
+1
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user