diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 209c696..311c2be 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -16,6 +16,7 @@ import { state, terminateCurrentSession, addSignedMessageToChat, + markIncomingReadByBaseKey, markOutgoingReadByBaseKey, setContacts, } from './state.js'; @@ -398,16 +399,20 @@ async function init() { } catch {} } } else if (messageType === 3 || messageType === 4) { - const refBaseKey = String(payload.receiptRefBaseKey || '').trim(); - if (refBaseKey) { - markOutgoingReadByBaseKey(refBaseKey); - } else { + let refBaseKey = String(payload.receiptRefBaseKey || '').trim(); + if (!refBaseKey) { try { const ref = authService.parseReadReceiptPayload(parsed.payloadBytes); - const fallbackRefBase = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; - markOutgoingReadByBaseKey(fallbackRefBase); + refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; } catch {} } + if (refBaseKey) { + if (messageType === 3) { + markOutgoingReadByBaseKey(refBaseKey); + } else { + markIncomingReadByBaseKey(refBaseKey); + } + } addAppLogEntry({ level: 'info', source: 'signed-dm', diff --git a/shine-UI/js/components/header.js b/shine-UI/js/components/header.js index 7f5766c..7a5b68e 100644 --- a/shine-UI/js/components/header.js +++ b/shine-UI/js/components/header.js @@ -1,4 +1,4 @@ -export function renderHeader({ title, leftAction, rightActions = [] }) { +export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) { const wrap = document.createElement('header'); wrap.className = 'page-header'; @@ -11,6 +11,12 @@ export function renderHeader({ title, leftAction, rightActions = [] }) { btn.addEventListener('click', leftAction.onClick); left.append(btn); } + if (leftLabel) { + const label = document.createElement('span'); + label.className = 'header-left-label'; + label.textContent = leftLabel; + left.append(label); + } const h1 = document.createElement('h1'); h1.className = 'page-title'; diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index d250969..f1716bb 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -508,17 +508,13 @@ function toListModel(groups) { function renderEmptyState(activeTab, navigate) { const wrap = document.createElement('div'); - wrap.className = 'channels-empty-state'; - - const icon = document.createElement('div'); - icon.className = 'channels-empty-icon'; - icon.textContent = '◌'; + wrap.className = 'channels-empty-state channels-empty-state--compact'; const text = document.createElement('div'); text.className = 'meta-muted'; - text.textContent = 'В этом разделе пока нет каналов'; + text.textContent = 'В этом разделе нет сообщений'; - wrap.append(icon, text); + wrap.append(text); if (activeTab === 'my') { const cta = document.createElement('button'); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 0e3ec49..a08da08 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -7,6 +7,7 @@ import { getChatMessages, markChatRead, markOutgoingSent, + markReadReceiptSentByBaseKey, authService, state, } from '../state.js'; @@ -33,10 +34,10 @@ function renderLog(list, chatId) { messages.forEach((msg) => { if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) { const sep = document.createElement('div'); - sep.className = 'meta-muted'; - sep.style.textAlign = 'center'; - sep.style.margin = '8px 0'; - sep.textContent = 'Новые сообщения'; + sep.className = 'chat-unread-separator'; + const label = document.createElement('span'); + label.textContent = 'Новые сообщения'; + sep.append(label); list.append(sep); unreadSeparatorInserted = true; } @@ -216,7 +217,11 @@ async function sendReadReceiptsForVisible(chatId) { refNonce: ref.nonce, refType: 1, }); - row.readReceiptSent = true; + if (row.baseKey) { + markReadReceiptSentByBaseKey(row.baseKey); + } else { + row.readReceiptSent = true; + } } catch (e) { addAppLogEntry({ level: 'warn', diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 58e3e65..dad7b45 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -12,6 +12,7 @@ export function render({ navigate }) { screen.append( renderHeader({ title: 'Личные сообщения', + leftLabel: String(state.session.login || '').trim(), rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }], }), ); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index f596dfc..c8ac30b 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -348,6 +348,37 @@ export function markOutgoingReadByBaseKey(baseKey) { }); } +export function markIncomingReadByBaseKey(baseKey) { + if (!baseKey) return; + const keys = Object.keys(state.chats || {}); + keys.forEach((chatId) => { + const list = getChatMessages(chatId); + list.forEach((row) => { + if (row?.from !== 'in') return; + if (row.baseKey === baseKey) { + row.unread = false; + row.readReceiptSent = true; + persistMessageRecord(chatId, row); + } + }); + }); +} + +export function markReadReceiptSentByBaseKey(baseKey) { + if (!baseKey) return; + const keys = Object.keys(state.chats || {}); + keys.forEach((chatId) => { + const list = getChatMessages(chatId); + list.forEach((row) => { + if (row?.from !== 'in') return; + if (row.baseKey === baseKey) { + row.readReceiptSent = true; + persistMessageRecord(chatId, row); + } + }); + }); +} + export function addSignedMessageToChat({ chatId, messageKey, diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 380c46b..6ad8481 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -24,6 +24,16 @@ justify-content: flex-start; } +.header-left-label { + display: inline-block; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #a8bcdf; +} + .header-actions { justify-content: flex-end; } @@ -669,6 +679,27 @@ align-content: start; } +.chat-unread-separator { + display: flex; + align-items: center; + gap: 10px; + margin: 8px 0; + color: #9ab0de; + font-size: 12px; +} + +.chat-unread-separator::before, +.chat-unread-separator::after { + content: ''; + flex: 1 1 auto; + height: 1px; + background: linear-gradient(90deg, rgba(126, 150, 199, 0.12), rgba(126, 150, 199, 0.45), rgba(126, 150, 199, 0.12)); +} + +.chat-unread-separator > span { + white-space: nowrap; +} + .bubble { max-width: 76%; padding: 10px 12px; @@ -992,15 +1023,15 @@ textarea.input { .node.is-shine .node-dot::before { content: ''; position: absolute; - inset: -7px; + inset: -14px; border-radius: 50%; - background: radial-gradient(circle, rgba(130, 235, 255, 0.45) 0%, rgba(130, 235, 255, 0.18) 45%, rgba(130, 235, 255, 0) 72%); - filter: blur(1px); + background: radial-gradient(circle, rgba(130, 235, 255, 0.62) 0%, rgba(130, 235, 255, 0.28) 42%, rgba(130, 235, 255, 0) 76%); + filter: blur(2px); z-index: -1; } .node.is-shine .node-dot { - box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.38), 0 0 16px rgba(102, 220, 255, 0.38), 0 8px 16px rgba(4, 8, 15, 0.35); + box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.5), 0 0 28px rgba(102, 220, 255, 0.58), 0 8px 16px rgba(4, 8, 15, 0.35); } .node.is-friend .node-dot { @@ -1375,6 +1406,7 @@ textarea.input { .channels-groups { gap: 11px; + justify-items: start; } .channels-section { @@ -1407,6 +1439,7 @@ textarea.input { grid-template-columns: 46px minmax(0, 1fr) 72px; gap: 12px; padding: 14px 13px; + width: min(100%, 340px); border-radius: 16px; border: 1px solid rgba(193, 157, 82, 0.28); background: @@ -1865,6 +1898,14 @@ textarea.input { background: rgba(10, 18, 34, 0.52); } +.channels-empty-state--compact { + border: 0; + background: transparent; + border-radius: 0; + padding: 4px 2px; + gap: 6px; +} + .channels-empty-icon { font-size: 20px; color: #d9b56d; @@ -2076,7 +2117,7 @@ textarea.input { .channels-list-content { display: grid; gap: 10px; - min-height: 42vh; + min-height: 0; } .channels-list-body-fade { diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 25888db..8fe1e0b 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -528,6 +528,18 @@ public final class DatabaseInitializer { ON signed_messages_v2 (base_key, message_type); """); + st.executeUpdate(""" + CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming + ON signed_messages_v2 (target_login, receipt_ref_base_key) + WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; + """); + + st.executeUpdate(""" + CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing + ON signed_messages_v2 (target_login, receipt_ref_base_key) + WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; + """); + // 14) signed_message_session_delivery (доставка по сессиям) st.executeUpdate(""" CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( diff --git a/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 91a7903..00e42c2 100644 --- a/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -90,6 +90,7 @@ public final class SqliteDbController { ensureConnectionsIndexes(st); ensureReactionsIndexes(st); ensureChannelNamesIndexes(st); + ensureSignedMessageReceiptUniq(c, st); DatabaseTriggersInstaller.createAllTriggers(st); @@ -223,6 +224,53 @@ public final class SqliteDbController { """); } + private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException { + if (!tableExists(c, "signed_messages_v2")) return; + + if (tableExists(c, "signed_message_session_delivery")) { + st.executeUpdate(""" + DELETE FROM signed_message_session_delivery + WHERE message_key IN ( + SELECT message_key + FROM signed_messages_v2 + WHERE message_type IN (3, 4) + AND receipt_ref_base_key IS NOT NULL + AND rowid NOT IN ( + SELECT MIN(rowid) + FROM signed_messages_v2 + WHERE message_type IN (3, 4) + AND receipt_ref_base_key IS NOT NULL + GROUP BY target_login, message_type, receipt_ref_base_key + ) + ); + """); + } + + st.executeUpdate(""" + DELETE FROM signed_messages_v2 + WHERE message_type IN (3, 4) + AND receipt_ref_base_key IS NOT NULL + AND rowid NOT IN ( + SELECT MIN(rowid) + FROM signed_messages_v2 + WHERE message_type IN (3, 4) + AND receipt_ref_base_key IS NOT NULL + GROUP BY target_login, message_type, receipt_ref_base_key + ); + """); + + st.executeUpdate(""" + CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming + ON signed_messages_v2 (target_login, receipt_ref_base_key) + WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; + """); + st.executeUpdate(""" + CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing + ON signed_messages_v2 (target_login, receipt_ref_base_key) + WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; + """); + } + private static void rebuildConnectionsStateTable(Statement st) throws SQLException { st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2"); st.executeUpdate(""" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 9ae3cbf..f95b7b7 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -43,8 +43,11 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - SignedMessagesCore.saveIfAbsent(entry); - SignedMessagesRealtime.DeliveryCounters counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); + boolean inserted = SignedMessagesCore.saveIfAbsent(entry); + SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); + if (inserted) { + counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); + } Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response(); resp.setOp(req.getOp()); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index 4ee26da..a0e9de5 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -53,15 +53,19 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - SignedMessagesCore.saveIfAbsent(incomingEntry); - SignedMessagesCore.saveIfAbsent(outgoingEntry); + boolean incomingInserted = SignedMessagesCore.saveIfAbsent(incomingEntry); + boolean outgoingInserted = SignedMessagesCore.saveIfAbsent(outgoingEntry); - SignedMessagesRealtime.DeliveryCounters inCounters = - SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null); + SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); + if (incomingInserted) { + inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null); + } String excludeSessionId = outgoingEntry.getTargetLogin().equalsIgnoreCase(ctx.getLogin()) ? ctx.getSessionId() : null; - SignedMessagesRealtime.DeliveryCounters outCounters = - SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId); + SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); + if (outgoingInserted) { + outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId); + } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); resp.setOp(req.getOp()); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index e666781..4909fc0 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -83,7 +83,7 @@ final class SignedMessagesCore { return entry; } - static void saveIfAbsent(SignedMessageV2Entry entry) throws Exception { - SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception { + return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); } }