UI: render DM inbox from dialogs

This commit is contained in:
AidarKC
2026-08-22 18:47:14 +04:00
parent 5e6d64e965
commit 7a5ac01d1c
2 changed files with 168 additions and 72 deletions
+144 -68
View File
@@ -1,21 +1,28 @@
import { directMessages } from '../mock-data.js';
import { import {
getChatMessages, authService,
isSessionInvalidError, isSessionInvalidError,
normalizeDmChatId, normalizeDmChatId,
setContacts, setContacts,
state, state,
terminateCurrentSession, terminateCurrentSession,
} from '../state.js'; } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map(); const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map(); const dmAvatarPendingByLogin = new Map();
const RELATION_ORDER = new Map([
['close_friend', 0],
['contact', 1],
['none', 2],
]);
async function loadDmAvatarSnapshot(login) { async function loadDmAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null; if (!cleanLogin) return null;
@@ -65,10 +72,72 @@ function createDmAvatar(login) {
return avatarEl; return avatarEl;
} }
function resolveLastMessagePreview(text = '') { function normalizeRelationFlag(value) {
const parsed = parseDmTechBlocks(String(text || '')); const clean = String(value || '').trim().toLowerCase();
const display = String(parsed.displayText || '').trim(); if (clean === 'close_friend' || clean === 'contact') return clean;
return display || ''; return 'none';
}
function relationOrder(flag) {
return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99;
}
function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) {
case 'close_friend':
return 'близкий друг';
case 'contact':
return 'контакт';
default:
return 'не в контактах';
}
}
function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, maxLen - 1)}`;
}
async function resolveDialogPreview(dialog) {
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
if (!blobB64) return 'Диалог пока пуст.';
const cacheKey = [
blobB64,
String(state.session.login || '').trim().toLowerCase(),
String(state.session.storagePwdInMemory || '').trim(),
].join('|');
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
const pending = (async () => {
try {
const parsed = authService.parseSignedMessageBlob(blobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
blobB64,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
const parsedText = parseDmTechBlocks(String(decrypted?.text || ''));
const display = clipPreviewText(String(parsedText.displayText || '').trim());
const result = display || 'Сообщение';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result;
} catch {
const fallback = 'Сообщение недоступно';
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback;
} finally {
DM_BLOB_PREVIEW_PENDING.delete(cacheKey);
}
})();
DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending);
return pending;
} }
function formatChatRowTime(ts) { function formatChatRowTime(ts) {
@@ -83,11 +152,11 @@ function formatChatRowTime(ts) {
} }
function compareChatRows(a, b) { function compareChatRows(a, b) {
const timeA = Number(a?.lastTimeMs || 0); const timeA = Number(a?.lastMessageTimeMs || 0);
const timeB = Number(b?.lastTimeMs || 0); const timeB = Number(b?.lastMessageTimeMs || 0);
if (timeA !== timeB) return timeB - timeA; if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.name || '').toLowerCase(); const nameA = String(a?.peerLogin || '').toLowerCase();
const nameB = String(b?.name || '').toLowerCase(); const nameB = String(b?.peerLogin || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru'); return nameA.localeCompare(nameB, 'ru');
} }
@@ -107,9 +176,9 @@ export function render({ navigate, chrome }) {
<span class="dm-head-name"></span> <span class="dm-head-name"></span>
</div> </div>
</div> </div>
<h1 class="dm-head-title">Контакты</h1> <h1 class="dm-head-title">Чаты</h1>
<div class="dm-head-menu-wrap"> <div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false"> <button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false">
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span> <span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button> </button>
<div class="dm-head-menu" role="menu" hidden> <div class="dm-head-menu" role="menu" hidden>
@@ -118,7 +187,7 @@ export function render({ navigate, chrome }) {
<circle cx="11" cy="11" r="6.5"></circle> <circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path> <path d="M16 16l4 4"></path>
</svg> </svg>
<span>Поиск контактов</span> <span>Поиск пользователей</span>
</button> </button>
</div> </div>
</div> </div>
@@ -164,7 +233,7 @@ export function render({ navigate, chrome }) {
<circle cx="11" cy="11" r="6.5"></circle> <circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path> <path d="M16 16l4 4"></path>
</svg> </svg>
<span>Поиск контактов</span> <span>Поиск пользователей</span>
</button> </button>
`; `;
@@ -206,16 +275,17 @@ export function render({ navigate, chrome }) {
window.addEventListener('resize', onMenuViewportChange, { passive: true }); window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true }); window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div');
divider.className = 'dm-divider';
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack dm-list'; list.className = 'stack dm-list';
function renderRow(item) { function renderRow(item) {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id); const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin);
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div'); const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.className = 'dm-av dm-av--default';
@@ -224,14 +294,14 @@ export function render({ navigate, chrome }) {
<div class="dm-row-main"> <div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap"> <div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong> <strong class="dm-row-title"></strong>
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''} <span class="dm-contact-note">${relationBadge}</span>
</div> </div>
<p class="dm-row-last-message"></p> <p class="dm-row-last-message"></p>
</div> </div>
<div class="dm-row-meta-col"> <div class="dm-row-meta-col">
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'} ${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<div class="dm-row-meta-line"> <div class="dm-row-meta-line">
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'} ${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span> <span class="dm-chevron">${SVG_CHEVRON}</span>
</div> </div>
</div> </div>
@@ -239,11 +309,15 @@ export function render({ navigate, chrome }) {
const titleEl = row.querySelector('.dm-row-title'); const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message'); const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time'); const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.name || ''); if (titleEl) titleEl.textContent = String(item.peerLogin || '');
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.'; if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = String(item.time || ''); if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap); row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`)); void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row; return row;
} }
@@ -257,62 +331,64 @@ export function render({ navigate, chrome }) {
} }
try { try {
const relations = await loadCurrentRelations(); const payload = await authService.listContacts();
const contacts = relations.outContacts || []; const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
setContacts(contacts); setContacts(contacts);
list.innerHTML = ''; list.innerHTML = '';
const contactRows = contacts.map((login) => { const byPeer = new Map();
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); dialogs.forEach((dialog) => {
const canonicalLogin = normalizeDmChatId(login); const peerLogin = String(dialog?.peerLogin || '').trim();
const chat = getChatMessages(canonicalLogin); if (!peerLogin) return;
const lastChat = chat[chat.length - 1]; const key = peerLogin.toLowerCase();
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
const lastTimeMs = Number(lastChat?.createdAtMs || 0); const next = {
return { id: peerLogin,
id: canonicalLogin, peerLogin,
name: preview?.name || login, relationFlag,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
time: formatChatRowTime(lastTimeMs), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
unread, unreadCount: Number(dialog?.unreadCount || 0),
notInContacts: false, hasDialog: Boolean(dialog?.hasDialog),
lastTimeMs,
}; };
const current = byPeer.get(key);
if (!current) {
byPeer.set(key, next);
return;
}
const currentRank = relationOrder(current.relationFlag);
const nextRank = relationOrder(relationFlag);
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
byPeer.set(key, next);
}
}); });
const allChatIds = Object.keys(state.chats || {}) const rows = Array.from(byPeer.values()).sort((a, b) => {
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase()) const orderA = relationOrder(a.relationFlag);
.filter((id) => (getChatMessages(id) || []).length > 0); const orderB = relationOrder(b.relationFlag);
if (orderA !== orderB) return orderA - orderB;
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase())); return compareChatRows(a, b);
const extraRows = allChatIds
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
.map((login) => {
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: login,
name: login,
lastMessage: lastChat?.text || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: true,
lastTimeMs,
};
}); });
const rows = [...contactRows, ...extraRows].sort(compareChatRows);
if (!rows.length) { if (!rows.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'card meta-muted';
empty.textContent = 'Пока нет ни контактов, ни сообщений'; empty.textContent = 'Пока нет диалогов';
list.append(empty); list.append(empty);
return; return;
} }
rows.forEach((item) => list.append(renderRow(item))); let dividerInserted = false;
rows.forEach((item) => {
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
const divider = document.createElement('div');
divider.className = 'dm-divider';
list.append(divider);
dividerInserted = true;
}
list.append(renderRow(item));
});
} catch (error) { } catch (error) {
if (isSessionInvalidError(error)) { if (isSessionInvalidError(error)) {
list.innerHTML = ''; list.innerHTML = '';
@@ -352,7 +428,7 @@ export function render({ navigate, chrome }) {
} }
chrome?.setTopbar(head); chrome?.setTopbar(head);
screen.append(divider, list); screen.append(list);
loadList(); loadList();
screen.cleanup = () => { screen.cleanup = () => {
+21 -1
View File
@@ -509,6 +509,22 @@ function parseSignedMessageBlockBytes(bytes) {
}; };
} }
function extractContactsFromDialogs(dialogs) {
const seen = new Set();
const out = [];
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
const login = String(dialog?.peerLogin || '').trim();
if (!login) return;
const key = login.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) { function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || '')); const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || '')); const valueBytes = utf8Bytes(String(value || ''));
@@ -2852,7 +2868,11 @@ export class AuthService {
async listContacts() { async listContacts() {
const response = await this.ws.request('ListContacts', {}); const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response); if (response.status !== 200) throw opError('ListContacts', response);
return response.payload || {}; const payload = response.payload || {};
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
payload.contacts = extractContactsFromDialogs(payload.dialogs);
}
return payload;
} }