From 7a5ac01d1c827a4fcd613f1192c7713abb8368bdc18d7f446d3bfe9e454ac23d Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 22 Aug 2026 18:47:14 +0400 Subject: [PATCH] UI: render DM inbox from dialogs --- shine-UI/js/pages/messages-list.js | 218 ++++++++++++++++++--------- shine-UI/js/services/auth-service.js | 22 ++- 2 files changed, 168 insertions(+), 72 deletions(-) diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index aaadb086..9bb8b217 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -1,21 +1,28 @@ -import { directMessages } from '../mock-data.js'; import { - getChatMessages, + authService, isSessionInvalidError, normalizeDmChatId, setContacts, state, terminateCurrentSession, } from '../state.js'; -import { loadCurrentRelations } from '../services/user-connections.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; 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 dmAvatarPendingByLogin = new Map(); +const RELATION_ORDER = new Map([ + ['close_friend', 0], + ['contact', 1], + ['none', 2], +]); + async function loadDmAvatarSnapshot(login) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) return null; @@ -65,10 +72,72 @@ function createDmAvatar(login) { return avatarEl; } -function resolveLastMessagePreview(text = '') { - const parsed = parseDmTechBlocks(String(text || '')); - const display = String(parsed.displayText || '').trim(); - return display || ''; +function normalizeRelationFlag(value) { + const clean = String(value || '').trim().toLowerCase(); + if (clean === 'close_friend' || clean === 'contact') return clean; + 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) { @@ -83,11 +152,11 @@ function formatChatRowTime(ts) { } function compareChatRows(a, b) { - const timeA = Number(a?.lastTimeMs || 0); - const timeB = Number(b?.lastTimeMs || 0); + const timeA = Number(a?.lastMessageTimeMs || 0); + const timeB = Number(b?.lastMessageTimeMs || 0); if (timeA !== timeB) return timeB - timeA; - const nameA = String(a?.name || '').toLowerCase(); - const nameB = String(b?.name || '').toLowerCase(); + const nameA = String(a?.peerLogin || '').toLowerCase(); + const nameB = String(b?.peerLogin || '').toLowerCase(); return nameA.localeCompare(nameB, 'ru'); } @@ -107,9 +176,9 @@ export function render({ navigate, chrome }) { -

Контакты

+

Чаты

-
@@ -159,12 +228,12 @@ export function render({ navigate, chrome }) { portal.className = 'dm-head-menu dm-head-menu--portal'; portal.setAttribute('role', 'menu'); portal.innerHTML = ` - `; @@ -206,16 +275,17 @@ export function render({ navigate, chrome }) { window.addEventListener('resize', onMenuViewportChange, { passive: true }); window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true }); - const divider = document.createElement('div'); - divider.className = 'dm-divider'; - const list = document.createElement('div'); list.className = 'stack dm-list'; - function renderRow(item) { +function renderRow(item) { const row = document.createElement('article'); 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'); const avatarWrap = document.createElement('div'); avatarWrap.className = 'dm-av dm-av--default'; @@ -224,14 +294,14 @@ export function render({ navigate, chrome }) {
- ${item.notInContacts ? 'не в контактах' : ''} + ${relationBadge}

- ${item.unread ? `${item.unread > 99 ? '99+' : item.unread}` : ''} + ${item.unreadCount ? `${item.unreadCount > 99 ? '99+' : item.unreadCount}` : ''}
- ${item.time ? '' : ''} + ${item.lastMessageTimeMs ? '' : ''} ${SVG_CHEVRON}
@@ -239,11 +309,15 @@ export function render({ navigate, chrome }) { const titleEl = row.querySelector('.dm-row-title'); const previewEl = row.querySelector('.dm-row-last-message'); const timeEl = row.querySelector('.dm-row-time'); - if (titleEl) titleEl.textContent = String(item.name || ''); - if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.'; - if (timeEl) timeEl.textContent = String(item.time || ''); + if (titleEl) titleEl.textContent = String(item.peerLogin || ''); + if (previewEl) previewEl.textContent = 'Загрузка…'; + if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs); 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; } @@ -257,62 +331,64 @@ export function render({ navigate, chrome }) { } try { - const relations = await loadCurrentRelations(); - const contacts = relations.outContacts || []; + const payload = await authService.listContacts(); + const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; + const contacts = Array.isArray(payload?.contacts) ? payload.contacts : []; setContacts(contacts); list.innerHTML = ''; - const contactRows = contacts.map((login) => { - const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); - const canonicalLogin = normalizeDmChatId(login); - const chat = getChatMessages(canonicalLogin); - 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: canonicalLogin, - name: preview?.name || login, - lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', - time: formatChatRowTime(lastTimeMs), - unread, - notInContacts: false, - lastTimeMs, + const byPeer = new Map(); + dialogs.forEach((dialog) => { + const peerLogin = String(dialog?.peerLogin || '').trim(); + if (!peerLogin) return; + const key = peerLogin.toLowerCase(); + const relationFlag = normalizeRelationFlag(dialog?.relationFlag); + const next = { + id: peerLogin, + peerLogin, + relationFlag, + lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''), + lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), + unreadCount: Number(dialog?.unreadCount || 0), + hasDialog: Boolean(dialog?.hasDialog), }; + 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 || {}) - .filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase()) - .filter((id) => (getChatMessages(id) || []).length > 0); + const rows = Array.from(byPeer.values()).sort((a, b) => { + const orderA = relationOrder(a.relationFlag); + const orderB = relationOrder(b.relationFlag); + if (orderA !== orderB) return orderA - orderB; + return compareChatRows(a, b); + }); - const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase())); - 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) { const empty = document.createElement('div'); empty.className = 'card meta-muted'; - empty.textContent = 'Пока нет ни контактов, ни сообщений'; + empty.textContent = 'Пока нет диалогов'; list.append(empty); 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) { if (isSessionInvalidError(error)) { list.innerHTML = ''; @@ -352,7 +428,7 @@ export function render({ navigate, chrome }) { } chrome?.setTopbar(head); - screen.append(divider, list); + screen.append(list); loadList(); screen.cleanup = () => { diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index ba3dbe0b..6be4ab5c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -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 }) { const keyBytes = utf8Bytes(String(key || '')); const valueBytes = utf8Bytes(String(value || '')); @@ -2852,7 +2868,11 @@ export class AuthService { async listContacts() { const response = await this.ws.request('ListContacts', {}); 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; }