From 19362b950a80a86cb2fcd2c0debce137c296084220a4830ed60b49cad232d1fb Mon Sep 17 00:00:00 2001 From: peradas Date: Fri, 21 Aug 2026 14:05:45 +0300 Subject: [PATCH 1/9] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20UI=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/components/scroll-to-bottom-button.js | 141 +++++++ shine-UI/js/pages/channel-thread-view.js | 10 +- shine-UI/js/pages/channel-view.js | 6 +- shine-UI/js/pages/chat-view.js | 5 + shine-UI/js/pages/notifications-view.js | 367 ++++++++++++++++-- shine-UI/js/pages/profile-view.js | 96 ++++- shine-UI/styles/components.css | 171 ++++++++ 7 files changed, 754 insertions(+), 42 deletions(-) create mode 100644 shine-UI/js/components/scroll-to-bottom-button.js diff --git a/shine-UI/js/components/scroll-to-bottom-button.js b/shine-UI/js/components/scroll-to-bottom-button.js new file mode 100644 index 00000000..cd2aea97 --- /dev/null +++ b/shine-UI/js/components/scroll-to-bottom-button.js @@ -0,0 +1,141 @@ +function resolveElement(value) { + return typeof value === 'function' ? value() : value; +} + +function buildArrowIcon() { + const icon = document.createElement('span'); + icon.className = 'scroll-to-bottom-btn__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '↓'; + return icon; +} + +export function attachScrollToBottomButton({ + scrollContainer, + mountTarget = document.querySelector('.app-shell'), + thresholdPx = 160, + title = 'Вниз', +} = {}) { + const target = resolveElement(mountTarget); + if (!(target instanceof Element)) { + return { + button: null, + refresh() {}, + cleanup() {}, + }; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'scroll-to-bottom-btn'; + button.title = title; + button.setAttribute('aria-label', 'Прокрутить ленту вниз'); + button.append(buildArrowIcon()); + target.append(button); + + let disposed = false; + let boundContainer = null; + let resizeObserver = null; + let mutationObserver = null; + let refreshFrame = 0; + + const hide = () => { + button.classList.remove('is-visible'); + button.setAttribute('aria-hidden', 'true'); + }; + + const scheduleRefresh = () => { + if (disposed || refreshFrame) return; + refreshFrame = window.requestAnimationFrame(() => { + refreshFrame = 0; + refresh(); + }); + }; + + const unbindContainer = () => { + boundContainer?.removeEventListener('scroll', scheduleRefresh); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + resizeObserver = null; + mutationObserver = null; + boundContainer = null; + }; + + const bindContainer = () => { + const nextContainer = resolveElement(scrollContainer); + if (!(nextContainer instanceof Element)) { + if (boundContainer) unbindContainer(); + return null; + } + if (nextContainer === boundContainer) return boundContainer; + + unbindContainer(); + boundContainer = nextContainer; + boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true }); + + if (typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(scheduleRefresh); + resizeObserver.observe(boundContainer); + } + + if (typeof MutationObserver === 'function') { + mutationObserver = new MutationObserver(scheduleRefresh); + mutationObserver.observe(boundContainer, { childList: true, subtree: true }); + } + + return boundContainer; + }; + + function refresh() { + if (disposed) return; + const container = bindContainer(); + if (!container) { + hide(); + return; + } + + const scrollHeight = Number(container.scrollHeight || 0); + const clientHeight = Number(container.clientHeight || 0); + const scrollTop = Number(container.scrollTop || 0); + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const distanceToBottom = Math.max(0, maxScrollTop - scrollTop); + const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0)); + + button.classList.toggle('is-visible', shouldShow); + button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true'); + } + + const scrollToBottom = () => { + const container = bindContainer(); + if (!container) return; + if (typeof container.scrollTo === 'function') { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + } else { + container.scrollTop = container.scrollHeight; + } + scheduleRefresh(); + }; + + button.addEventListener('click', scrollToBottom); + window.addEventListener('resize', scheduleRefresh); + window.visualViewport?.addEventListener('resize', scheduleRefresh); + window.requestAnimationFrame(refresh); + + return { + button, + refresh: scheduleRefresh, + cleanup() { + if (disposed) return; + disposed = true; + if (refreshFrame) { + window.cancelAnimationFrame(refreshFrame); + refreshFrame = 0; + } + unbindContainer(); + window.removeEventListener('resize', scheduleRefresh); + window.visualViewport?.removeEventListener('resize', scheduleRefresh); + button.removeEventListener('click', scrollToBottom); + button.remove(); + }, + }; +} diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index f8a1cd56..ae090faf 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -1,4 +1,5 @@ -import { renderHeader } from '../components/header.js'; +import { renderHeader } from '../components/header.js'; +import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js'; import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js'; import { captureClientError } from '../services/client-error-reporter.js'; import { toUserMessage } from '../services/ui-error-texts.js'; @@ -1143,6 +1144,7 @@ export function render({ navigate, route, chrome }) { screen.className = 'stack channels-screen channels-screen--thread'; const appScreen = document.getElementById('app-screen'); appScreen?.classList.add('channels-scroll-clean'); + const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen }); const header = renderHeader({ title: '', @@ -1180,6 +1182,7 @@ export function render({ navigate, route, chrome }) { const current = document.querySelector('section.channels-screen--thread'); if (!current) return; const next = render({ navigate, route }); + current.cleanup?.(); current.replaceWith(next); } catch (error) { logThreadRuntimeError('rerender', error, { routePath: window.location.pathname }); @@ -1352,6 +1355,10 @@ export function render({ navigate, route, chrome }) { invalid.className = 'card meta-muted'; invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.'; screen.append(invalid); + screen.cleanup = () => { + scrollToBottomControl.cleanup(); + appScreen?.classList.remove('channels-scroll-clean'); + }; return screen; } @@ -1538,6 +1545,7 @@ export function render({ navigate, route, chrome }) { })(); screen.cleanup = () => { + scrollToBottomControl.cleanup(); appScreen?.classList.remove('channels-scroll-clean'); }; diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index b30b07dc..32fa3ae1 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1,4 +1,5 @@ -import { renderHeader } from '../components/header.js'; +import { renderHeader } from '../components/header.js'; +import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js'; import { authService, getMessageReactionState, @@ -2041,6 +2042,7 @@ export function render({ navigate, route, chrome }) { screen.className = 'stack channels-screen channels-screen--channel'; const appScreen = document.getElementById('app-screen'); appScreen?.classList.add('channels-scroll-clean'); + const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen }); const statusBox = document.createElement('div'); statusBox.className = 'card status-line is-unavailable channels-status'; @@ -2079,6 +2081,7 @@ export function render({ navigate, route, chrome }) { const current = document.querySelector('section.channels-screen--channel'); if (!current) return; const next = render({ navigate, route }); + current.cleanup?.(); current.replaceWith(next); }; let activeSelector = null; @@ -2485,6 +2488,7 @@ export function render({ navigate, route, chrome }) { })(); screen.cleanup = () => { + scrollToBottomControl.cleanup(); appScreen?.classList.remove('channels-scroll-clean'); if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking(); }; diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 06b67238..d3f75775 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -1,4 +1,5 @@ import { renderHeader } from '../components/header.js'; +import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, @@ -906,6 +907,9 @@ export function render({ navigate, route, chrome }) { const wrap = document.createElement('div'); wrap.className = 'chat-wrap dm-chat-wrap'; + const scrollToBottomControl = attachScrollToBottomButton({ + scrollContainer: () => boundScrollContainer || wrap, + }); const historyLoader = document.createElement('div'); historyLoader.className = 'dm-history-loader'; @@ -1559,6 +1563,7 @@ export function render({ navigate, route, chrome }) { void loadHistoryPage({ preserveScroll: true }); }); screen.cleanup = () => { + scrollToBottomControl.cleanup(); hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 27426c7e..578bfe70 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -1,34 +1,309 @@ import { renderHeader } from '../components/header.js'; -import { state } from '../state.js'; +import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js'; +import { renderUserAvatar } from '../components/avatar-image.js'; +import { authService, state } from '../state.js'; +import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js'; + +const CONNECTION_CLOSE_FRIEND = 10; +const profileSnapshotCache = new Map(); +const profileSnapshotPending = new Map(); + +function connectionTypeLabel(typeCode) { + switch (Number(typeCode)) { + case CONNECTION_CLOSE_FRIEND: + return 'близкие друзья'; + default: + return 'новую связь'; + } +} export const pageMeta = { id: 'notifications-view', title: 'Уведомления' }; -function renderList(container) { - const active = state.notificationsTab; - container.innerHTML = ''; - const card = document.createElement('article'); - card.className = 'card stack'; - - const title = document.createElement('strong'); - title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке'; - - const description = document.createElement('p'); - description.className = 'meta-muted'; - description.textContent = active === 'events' - ? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.' - : 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.'; - - const note = document.createElement('p'); - note.className = 'meta-muted'; - note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.'; - - card.append(title, description, note); - container.append(card); +function normalizeItem(item) { + return { + kind: String(item?.kind || ''), + createdAtMs: Number(item?.createdAtMs || 0), + sourceLogin: String(item?.sourceLogin || ''), + sourceBlockchainName: String(item?.sourceBlockchainName || ''), + sourceBlockNumber: Number(item?.sourceBlockNumber || 0), + sourceBlockHash: String(item?.sourceBlockHash || ''), + sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType), + sourceText: String(item?.sourceText || ''), + connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode), + targetLogin: String(item?.targetLogin || ''), + targetBlockchainName: String(item?.targetBlockchainName || ''), + targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber), + targetBlockHash: String(item?.targetBlockHash || ''), + profile: null, + engagement: null, + }; } -export function render({ chrome } = {}) { +function formatRelativeTime(value) { + const ts = Number(value || 0); + if (!Number.isFinite(ts) || ts <= 0) return ''; + + const diffMs = Math.max(0, Date.now() - ts); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + const week = 7 * day; + + if (diffMs < minute) return 'сейчас'; + if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`; + if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`; + if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`; + return `${Math.max(1, Math.floor(diffMs / week))} нед.`; +} + +function profileField(snapshot, key) { + const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : []) + .find((field) => String(field?.key || '') === key); + return String(row?.value || '').trim(); +} + +async function loadCachedProfileSnapshot(login) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) return null; + const key = cleanLogin.toLowerCase(); + if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key); + if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key); + + const pending = loadProfileSnapshot(cleanLogin) + .then((snapshot) => { + profileSnapshotCache.set(key, snapshot || null); + profileSnapshotPending.delete(key); + return snapshot || null; + }) + .catch(() => { + profileSnapshotCache.set(key, null); + profileSnapshotPending.delete(key); + return null; + }); + + profileSnapshotPending.set(key, pending); + return pending; +} + +function normalizeEngagement(source) { + if (!source || typeof source !== 'object') return null; + const likesCount = Math.max(0, Number(source.likesCount || 0)); + const repliesCount = Math.max(0, Number(source.repliesCount || 0)); + const ratingsCount = Math.max(0, Number(source.ratingsCount || 0)); + const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0)); + const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0)); + + const result = { + likesCount: Number.isFinite(likesCount) ? likesCount : 0, + repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0, + ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0, + repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0, + sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0, + }; + + return Object.values(result).some((count) => count > 0) ? result : null; +} + +async function loadSourceEngagement(item) { + const blockchainName = String(item?.sourceBlockchainName || '').trim(); + const blockNumber = Number(item?.sourceBlockNumber); + const blockHash = String(item?.sourceBlockHash || '').trim(); + if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null; + + try { + const payload = await authService.getMessageThread( + { blockchainName, blockNumber, blockHash }, + 0, + 0, + 1, + String(state.session.login || '').trim(), + ); + return normalizeEngagement(payload?.focus); + } catch { + return null; + } +} + +async function enrichItem(item, activeTab) { + const [profile, engagement] = await Promise.all([ + loadCachedProfileSnapshot(item.sourceLogin), + activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null), + ]); + return { ...item, profile, engagement }; +} + +function renderEmpty(activeTab) { + const card = document.createElement('article'); + card.className = 'card stack'; + const title = document.createElement('strong'); + title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов'; + const text = document.createElement('p'); + text.className = 'meta-muted'; + text.textContent = activeTab === 'events' + ? 'Когда кто-то добавит новую связь с вами, событие появится здесь.' + : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.'; + card.append(title, text); + return card; +} + +function renderIdentity(item) { + const profile = item.profile; + const firstName = profileField(profile, 'first_name'); + const lastName = profileField(profile, 'last_name'); + const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь'; + const avatar = profile?.avatar?.txId + ? { + ar: String(profile.avatar.txId || '').trim(), + sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(), + } + : null; + + const header = document.createElement('div'); + header.className = 'notification-identity'; + header.append(renderUserAvatar({ + login: item.sourceLogin || 'unknown', + firstName, + lastName, + avatar, + size: 'small', + className: 'notification-avatar', + })); + + const text = document.createElement('div'); + text.className = 'notification-identity-text'; + + const primary = document.createElement('div'); + primary.className = 'notification-identity-primary'; + const name = document.createElement('strong'); + name.className = 'notification-person-name'; + name.textContent = fullName; + primary.append(name); + + const login = String(item.sourceLogin || '').trim(); + if (login) { + const loginEl = document.createElement('span'); + loginEl.className = 'notification-login'; + loginEl.textContent = `@${login}`; + primary.append(loginEl); + } + + const relative = formatRelativeTime(item.createdAtMs); + if (relative) { + const separator = document.createElement('span'); + separator.className = 'notification-time-separator'; + separator.textContent = '·'; + const time = document.createElement('span'); + time.className = 'notification-time'; + time.textContent = relative; + primary.append(separator, time); + } + + text.append(primary); + header.append(text); + return header; +} + +function renderEngagement(engagement) { + if (!engagement) return null; + + const stats = [ + { key: 'likesCount', icon: '♥', label: 'Лайки' }, + { key: 'repliesCount', icon: '💬', label: 'Ответы' }, + { key: 'ratingsCount', icon: '★', label: 'Оценки' }, + { key: 'repostsCount', icon: '↻', label: 'Репосты' }, + { key: 'sharesCount', icon: '↗', label: 'Отправки' }, + ].filter(({ key }) => Number(engagement[key] || 0) > 0); + + if (!stats.length) return null; + + const row = document.createElement('div'); + row.className = 'notification-engagement'; + stats.forEach(({ key, icon, label }) => { + const stat = document.createElement('span'); + stat.className = 'notification-engagement-item'; + stat.title = label; + + const iconEl = document.createElement('span'); + iconEl.className = 'notification-engagement-icon'; + iconEl.setAttribute('aria-hidden', 'true'); + iconEl.textContent = icon; + + const countEl = document.createElement('span'); + countEl.className = 'notification-engagement-count'; + countEl.textContent = String(engagement[key]); + stat.append(iconEl, countEl); + row.append(stat); + }); + + return row; +} + +function notificationRoute(item, activeTab) { + if (activeTab === 'events') { + const login = String(item?.sourceLogin || '').trim(); + return login ? makeProfileRoute(login) : ''; + } + + const blockchainName = String(item?.sourceBlockchainName || '').trim(); + const blockNumber = Number(item?.sourceBlockNumber); + if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return ''; + + return makeShineMessageRoute({ + messageBlockchainName: blockchainName, + messageBlockNumber: blockNumber, + }); +} + +function bindNotificationNavigation(row, routePath, navigate) { + if (!routePath || typeof navigate !== 'function') return; + + row.classList.add('notification-card--clickable'); + row.tabIndex = 0; + row.setAttribute('role', 'link'); + + const open = () => navigate(routePath); + row.addEventListener('click', open); + row.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + open(); + }); +} + +function renderItem(item, activeTab, navigate) { + const row = document.createElement('article'); + row.className = 'card stack notification-card'; + bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate); + row.append(renderIdentity(item)); + + const action = document.createElement('p'); + action.className = 'notification-action'; + if (activeTab === 'events') { + action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`; + } else { + action.textContent = 'Ответил(а) на ваше сообщение.'; + } + row.append(action); + + if (activeTab === 'replies') { + const body = document.createElement('p'); + body.className = 'notification-content'; + body.textContent = item.sourceText || 'Ответ без текста.'; + row.append(body); + + const engagement = renderEngagement(item.engagement); + if (engagement) row.append(engagement); + } + + return row; +} + +export function render({ navigate, chrome } = {}) { const screen = document.createElement('section'); screen.className = 'stack notifications-screen'; + const appScreen = document.getElementById('app-screen'); + const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen }); chrome?.setTopbar(renderHeader({ title: 'Уведомления' })); const tabs = document.createElement('div'); @@ -40,17 +315,57 @@ export function render({ chrome } = {}) { const list = document.createElement('div'); list.className = 'stack notifications-list'; - renderList(list); + + let requestSeq = 0; + + async function load() { + const seq = ++requestSeq; + const activeTab = state.notificationsTab; + list.replaceChildren(renderEmpty(activeTab)); + + try { + const payload = await authService.getNotifications(50); + if (seq !== requestSeq) return; + const baseItems = (activeTab === 'events' ? payload.events : payload.replies) + .map(normalizeItem); + if (!baseItems.length) { + list.replaceChildren(renderEmpty(activeTab)); + return; + } + + const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab))); + if (seq !== requestSeq) return; + list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate))); + scrollToBottomControl.refresh(); + } catch (error) { + if (seq !== requestSeq) return; + const card = document.createElement('article'); + card.className = 'card stack'; + const title = document.createElement('strong'); + title.textContent = 'Не удалось загрузить уведомления'; + const text = document.createElement('p'); + text.className = 'meta-muted'; + text.textContent = error?.message || 'Ошибка запроса к серверу'; + card.append(title, text); + list.replaceChildren(card); + } + } tabs.querySelectorAll('.tab-btn').forEach((btn) => { btn.addEventListener('click', () => { - state.notificationsTab = btn.dataset.tab; + const nextTab = String(btn.dataset.tab || 'replies'); + if (state.notificationsTab === nextTab) return; + state.notificationsTab = nextTab; tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active')); btn.classList.add('active'); - renderList(list); + void load(); }); }); screen.append(tabs, list); + void load(); + screen.cleanup = () => { + scrollToBottomControl.cleanup(); + }; return screen; } diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 88d4952a..e1a0ccfa 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -1,4 +1,4 @@ -import { profile } from '../mock-data.js'; +import { profile } from '../mock-data.js'; import { state } from '../state.js'; import { PROFILE_GENDER_FEMALE, @@ -101,22 +101,90 @@ export function render({ navigate, chrome }) { const topbar = document.createElement('header'); topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile'; topbar.innerHTML = ` -
- - -
`; - const topActions = topbar.querySelector('.profile-top-actions'); - topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view')); - topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view')); - topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view')); + + const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap'); + const profileMenuButton = topbar.querySelector('.profile-head-menu-btn'); + let profileMenuPortal = null; + + const closeProfileMenu = () => { + profileMenuPortal?.remove(); + profileMenuPortal = null; + profileMenuButton?.setAttribute('aria-expanded', 'false'); + profileMenuWrap?.classList.remove('is-open'); + }; + + const positionProfileMenu = () => { + if (!profileMenuPortal || !profileMenuButton) return; + const rect = profileMenuButton.getBoundingClientRect(); + const margin = 10; + const menuWidth = profileMenuPortal.offsetWidth || 224; + const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth)); + profileMenuPortal.style.left = `${Math.round(left)}px`; + profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`; + }; + + const openProfileMenu = () => { + if (!profileMenuButton || profileMenuPortal) return; + const portal = document.createElement('div'); + portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu'; + portal.setAttribute('role', 'menu'); + portal.innerHTML = ` + + + + `; + + const goTo = (route) => { + closeProfileMenu(); + navigate(route); + }; + portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view')); + portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view')); + portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view')); + portal.addEventListener('click', (event) => event.stopPropagation()); + + document.body.append(portal); + profileMenuPortal = portal; + profileMenuButton.setAttribute('aria-expanded', 'true'); + profileMenuWrap?.classList.add('is-open'); + positionProfileMenu(); + }; + + profileMenuButton?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (profileMenuPortal) closeProfileMenu(); + else openProfileMenu(); + }); + + document.addEventListener('click', (event) => { + if (!profileMenuPortal) return; + if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return; + closeProfileMenu(); + }); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !profileMenuPortal) return; + closeProfileMenu(); + profileMenuButton?.focus(); + }); + window.addEventListener('resize', positionProfileMenu, { passive: true }); + window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true }); + chrome?.setTopbar(topbar); const card = document.createElement('div'); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 1588c746..9af9512b 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -7453,6 +7453,19 @@ html, body { overflow-x: hidden; } border-color: rgba(230, 236, 245, 0.28); } +.profile-head-menu-wrap { + margin-left: auto; +} + +.profile-head-menu-item img { + width: 24px; + height: 24px; + flex: 0 0 auto; + display: block; + opacity: 0.94; + filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.18)); +} + .profile-info-modal { padding: 16px; background: rgba(2, 5, 11, 0.7); @@ -8042,3 +8055,161 @@ html, body { overflow-x: hidden; } pointer-events: auto; isolation: isolate; } + +/* ===== Notifications: social event cards ===== */ +.notifications-screen .notification-card { + gap: 10px; +} + +.notification-identity { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.notification-avatar { + flex: 0 0 auto; +} + +.notification-identity-text { + min-width: 0; + flex: 1 1 auto; +} + +.notification-identity-primary { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.notification-person-name { + color: rgba(255, 255, 255, 0.96); + font-weight: 700; + overflow-wrap: anywhere; +} + +.notification-login, +.notification-time-separator, +.notification-time { + color: rgba(255, 255, 255, 0.5); + font-size: 12px; +} + +.notification-action, +.notification-content { + margin: 0; +} + +.notification-action { + color: rgba(255, 255, 255, 0.66); + font-size: 13px; +} + +.notification-content { + color: rgba(255, 255, 255, 0.94); + font-size: 15px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.notification-engagement { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + padding-top: 2px; + color: rgba(255, 255, 255, 0.58); +} + +.notification-engagement-item { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 24px; + font-size: 12px; + line-height: 1; +} + +.notification-engagement-icon { + font-size: 15px; +} + + +.notifications-screen .notification-card--clickable { + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease; +} + +.notifications-screen .notification-card--clickable:hover, +.notifications-screen .notification-card--clickable:focus-visible { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.055); + outline: none; +} + +.notifications-screen .notification-card--clickable:active { + transform: scale(0.99); +} + +/* ===== Shared scroll-to-bottom control ===== */ +.scroll-to-bottom-btn { + position: absolute; + right: 16px; + bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px) + 14px + env(safe-area-inset-bottom)); + z-index: 28; + width: 44px; + height: 44px; + padding: 0; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid rgba(212, 175, 55, 0.38); + background: rgba(15, 20, 31, 0.82); + color: rgba(255, 226, 143, 0.96); + box-shadow: + 0 8px 26px rgba(0, 0, 0, 0.34), + 0 0 18px rgba(212, 175, 55, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + opacity: 0; + pointer-events: none; + transform: translateY(10px) scale(0.92); + transition: opacity 160ms ease, transform 160ms ease, border-color 160ms ease, background 160ms ease; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.scroll-to-bottom-btn.is-visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0) scale(1); +} + +.scroll-to-bottom-btn:hover, +.scroll-to-bottom-btn:focus-visible { + border-color: rgba(255, 208, 82, 0.64); + background: rgba(24, 29, 43, 0.92); + outline: none; +} + +.scroll-to-bottom-btn:active { + transform: translateY(1px) scale(0.96); +} + +.scroll-to-bottom-btn__icon { + display: block; + margin-top: -2px; + font-size: 27px; + line-height: 1; + font-weight: 400; + text-shadow: 0 0 10px rgba(255, 201, 69, 0.34); +} + +.app-shell.keyboard-open .scroll-to-bottom-btn { + bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px) + 14px + env(safe-area-inset-bottom)); +} From d3e6aa2be22f40ad486ab641fbce0ea2cf44c50863763b220d418ef12283a908 Mon Sep 17 00:00:00 2001 From: peradas Date: Fri, 21 Aug 2026 13:23:41 +0300 Subject: [PATCH 2/9] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83?= =?UTF-8?q?=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/blockchain/MsgSubType.java | 9 +- .../java/blockchain/body/ConnectionBody.java | 6 +- .../java/shine/db/DatabaseInitializer.java | 12 +- .../src/main/java/shine/db/MsgSubType.java | 14 +- .../db/dao/BlockchainResyncCleanupDAO.java | 12 +- .../db/dao/UserNotificationsStateDAO.java | 105 +++++++++++ .../db/entities/UserNotificationEntry.java | 44 +++++ .../main/resources/postgres/migration_v10.sql | 32 ++++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../blockchain/Net_AddBlock_Handler.java | 71 ++++++- .../BlockchainWriter.java | 16 +- .../Net_GetNotifications_Handler.java | 94 ++++++++++ .../Net_GetNotifications_Request.java | 10 + .../Net_GetNotifications_Response.java | 62 +++++++ .../test/it/cases/IT_03_AddBlock_NoAuth.java | 4 +- .../notifications_projection_full_resync.md | 21 +++ bundle.sh | 173 ++++++++++++++++++ docs/API/09_Operations_Index.md | 1 + docs/API/15_Notifications_API.md | 81 ++++++++ shine-UI/js/pages/channel-view.js | 2 +- shine-UI/js/pages/notifications-view.js | 7 - shine-UI/js/services/auth-service.js | 8 + shine-UI/styles/components.css | 72 -------- 23 files changed, 744 insertions(+), 116 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java create mode 100644 TODO/Arweave/notifications_projection_full_resync.md create mode 100644 bundle.sh create mode 100644 docs/API/15_Notifications_API.md diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index eb780c99..3a0ae6e0 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -98,14 +98,9 @@ public final class MsgSubType { /* ===================== CONNECTION (msg_type=3) ===================== */ /** Добавить в близкие друзья (close friend). */ - public static final short CONNECTION_FRIEND = 10; + public static final short CONNECTION_CLOSE_FRIEND = 10; /** Удалить из близких друзей (close friend). */ - public static final short CONNECTION_UNFRIEND = 11; - - /** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */ - public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; - /** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */ - public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND; + public static final short CONNECTION_UNCLOSE_FRIEND = 11; /** Добавить в контакты. */ public static final short CONNECTION_CONTACT = 20; diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java index bb7ef404..53445aa8 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java @@ -66,7 +66,7 @@ import java.util.Objects; * toBlockHash32=hash32(CREATE_CHANNEL) * * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): - * - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). + * - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). * @@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL private static boolean isValidSubType(short st) { int v = st & 0xFFFF; - return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) - || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) + return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 73975a76..05ddfd75 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -26,6 +26,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_7 = 7; public static final int SCHEMA_VERSION_8 = 8; public static final int SCHEMA_VERSION_9 = 9; + public static final int SCHEMA_VERSION_10 = 10; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -35,6 +36,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql"; public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql"; public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql"; + public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql"; private DatabaseInitializer() {} @@ -53,10 +55,8 @@ public final class DatabaseInitializer { public static final short REACTION_LIKE = 1; public static final short REACTION_UNLIKE = 2; - public static final short CONNECTION_FRIEND = 10; - public static final short CONNECTION_UNFRIEND = 11; - public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; - public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND; + public static final short CONNECTION_CLOSE_FRIEND = 10; + public static final short CONNECTION_UNCLOSE_FRIEND = 11; public static final short CONNECTION_CONTACT = 20; public static final short CONNECTION_UNCONTACT = 21; @@ -130,6 +130,10 @@ public final class DatabaseInitializer { } if (currentVersion < SCHEMA_VERSION_9) { runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE); + currentVersion = SCHEMA_VERSION_9; + } + if (currentVersion < SCHEMA_VERSION_10) { + runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE); } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 3cd7456c..3c8a1366 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -58,23 +58,17 @@ public final class MsgSubType { /* ===================== CONNECTION (msg_type=3) ===================== */ /** * Совпадает с ConnectionBody: - * SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54, + * SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54, * KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74 - * UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55, + * UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55, * UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75 */ /** Добавить в близкие друзья (close friend). */ - public static final short CONNECTION_FRIEND = 10; + public static final short CONNECTION_CLOSE_FRIEND = 10; /** Удалить из близких друзей (close friend). */ - public static final short CONNECTION_UNFRIEND = 11; - - /** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */ - public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; - - /** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */ - public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND; + public static final short CONNECTION_UNCLOSE_FRIEND = 11; /** Добавить в контакты. */ public static final short CONNECTION_CONTACT = 20; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index e11a5fff..2771a743 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO { private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException { String sql = """ UPDATE message_stats - SET likes_count = MAX( + SET likes_count = GREATEST( 0, - likes_count - ( - SELECT COUNT(*) + likes_count - COALESCE(( + SELECT COUNT(*)::int FROM reactions_state rs WHERE rs.from_bch_name = ? AND rs.reaction_type = ? @@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO { AND rs.to_block_number = message_stats.to_block_number AND rs.to_block_hash = message_stats.to_block_hash AND rs.to_bch_name <> ? - ) + ), 0) ) WHERE EXISTS ( SELECT 1 @@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO { private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException { String sql = """ UPDATE message_stats - SET replies_count = MAX( + SET replies_count = GREATEST( 0, replies_count - COALESCE(( - SELECT COUNT(*) + SELECT COUNT(*)::int FROM blocks b WHERE b.bch_name = ? AND b.msg_type = 1 diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java new file mode 100644 index 00000000..21c800d5 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java @@ -0,0 +1,105 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.UserNotificationEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +public final class UserNotificationsStateDAO { + private static volatile UserNotificationsStateDAO instance; + private final DbController db = DbController.getInstance(); + + private UserNotificationsStateDAO() {} + + public static UserNotificationsStateDAO getInstance() { + if (instance == null) { + synchronized (UserNotificationsStateDAO.class) { + if (instance == null) instance = new UserNotificationsStateDAO(); + } + } + return instance; + } + + public int upsert(Connection c, UserNotificationEntry e) throws SQLException { + String sql = """ + INSERT INTO user_notifications_state ( + owner_login, notification_kind, created_at_ms, source_login, source_bch_name, + source_block_number, source_block_hash, target_login, target_bch_name, + target_block_number, target_block_hash, source_msg_sub_type, source_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET + created_at_ms = EXCLUDED.created_at_ms, + source_login = EXCLUDED.source_login, + target_login = EXCLUDED.target_login, + target_bch_name = EXCLUDED.target_bch_name, + target_block_number = EXCLUDED.target_block_number, + target_block_hash = EXCLUDED.target_block_hash, + source_msg_sub_type = EXCLUDED.source_msg_sub_type, + source_text = EXCLUDED.source_text + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + ps.setString(i++, e.getOwnerLogin()); + ps.setString(i++, e.getNotificationKind()); + ps.setLong(i++, e.getCreatedAtMs()); + ps.setString(i++, e.getSourceLogin()); + ps.setString(i++, e.getSourceBchName()); + ps.setInt(i++, e.getSourceBlockNumber()); + ps.setBytes(i++, e.getSourceBlockHash()); + if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR); + if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR); + if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER); + if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY); + ps.setInt(i++, e.getSourceMsgSubType()); + ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText()); + return ps.executeUpdate(); + } + } + + public List listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException { + String sql = """ + SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name, + source_block_number, source_block_hash, target_login, target_bch_name, + target_block_number, target_block_hash, source_msg_sub_type, source_text + FROM user_notifications_state + WHERE owner_login = ? AND notification_kind = ? + ORDER BY created_at_ms DESC, source_block_number DESC + LIMIT ? + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, kind); + ps.setInt(3, Math.max(1, limit)); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + + private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException { + UserNotificationEntry e = new UserNotificationEntry(); + e.setOwnerLogin(rs.getString("owner_login")); + e.setNotificationKind(rs.getString("notification_kind")); + e.setCreatedAtMs(rs.getLong("created_at_ms")); + e.setSourceLogin(rs.getString("source_login")); + e.setSourceBchName(rs.getString("source_bch_name")); + e.setSourceBlockNumber(rs.getInt("source_block_number")); + e.setSourceBlockHash(rs.getBytes("source_block_hash")); + e.setTargetLogin(rs.getString("target_login")); + e.setTargetBchName(rs.getString("target_bch_name")); + Object targetBlockNumber = rs.getObject("target_block_number"); + e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue()); + e.setTargetBlockHash(rs.getBytes("target_block_hash")); + e.setSourceMsgSubType(rs.getInt("source_msg_sub_type")); + e.setSourceText(rs.getString("source_text")); + return e; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java new file mode 100644 index 00000000..2a150d90 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java @@ -0,0 +1,44 @@ +package shine.db.entities; + +public class UserNotificationEntry { + private String ownerLogin; + private String notificationKind; + private long createdAtMs; + private String sourceLogin; + private String sourceBchName; + private int sourceBlockNumber; + private byte[] sourceBlockHash; + private String targetLogin; + private String targetBchName; + private Integer targetBlockNumber; + private byte[] targetBlockHash; + private int sourceMsgSubType; + private String sourceText; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public String getNotificationKind() { return notificationKind; } + public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public String getSourceLogin() { return sourceLogin; } + public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; } + public String getSourceBchName() { return sourceBchName; } + public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; } + public int getSourceBlockNumber() { return sourceBlockNumber; } + public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; } + public byte[] getSourceBlockHash() { return sourceBlockHash; } + public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; } + public String getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + public String getTargetBchName() { return targetBchName; } + public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; } + public Integer getTargetBlockNumber() { return targetBlockNumber; } + public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; } + public byte[] getTargetBlockHash() { return targetBlockHash; } + public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; } + public int getSourceMsgSubType() { return sourceMsgSubType; } + public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; } + public String getSourceText() { return sourceText; } + public void setSourceText(String sourceText) { this.sourceText = sourceText; } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql new file mode 100644 index 00000000..b3c35a17 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql @@ -0,0 +1,32 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_notifications_state ( + owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login), + notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')), + created_at_ms BIGINT NOT NULL, + source_login TEXT NOT NULL, + source_bch_name TEXT NOT NULL, + source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0), + source_block_hash BYTEA NOT NULL, + target_login TEXT, + target_bch_name TEXT, + target_block_number INTEGER, + target_block_hash BYTEA, + source_msg_sub_type INTEGER NOT NULL, + source_text TEXT NOT NULL DEFAULT '', + UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time + ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC); + +CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time + ON user_notifications_state(owner_login, created_at_ms DESC); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index d8aa3e15..3f182a42 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -88,9 +88,11 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; +import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; +import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request; import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; @@ -207,6 +209,7 @@ public final class JsonHandlerRegistry { Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), + Map.entry("GetNotifications", new Net_GetNotifications_Handler()), // --- direct messages / push --- Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), @@ -296,6 +299,7 @@ public final class JsonHandlerRegistry { Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), + Map.entry("GetNotifications", Net_GetNotifications_Request.class), // --- direct messages / push --- Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 6843ef11..b434eb5f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier; import blockchain.MsgSubType; import blockchain.body.BodyHasLine; import blockchain.body.BodyHasTarget; +import blockchain.body.ConnectionBody; import blockchain.body.CreateChannelBody; import blockchain.body.StatusActionBody; +import blockchain.body.TextReplyBody; import blockchain.body.TextLineBody; import blockchain.body.UserParamBody; import org.slf4j.Logger; @@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; +import shine.db.dao.UserNotificationsStateDAO; import shine.db.dao.UserParamsDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockEntry; import shine.db.entities.ChannelNameStateEntry; +import shine.db.entities.UserNotificationEntry; import shine.db.entities.UserParamEntry; import utils.blockchain.BlockchainNameUtil; @@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance(); private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService(); - private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO); + private final BlockchainWriter dbWriter = new BlockchainWriter( + blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance()); public Net_AddBlock_Handler() { ChannelNamesStateBootstrapper.bootstrapOrFailFast(); @@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { ); } - dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry); + UserNotificationEntry notificationEntry = buildNotificationEntry(block, be); + dbWriter.appendBlockAndState( + blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry); if (chat200CreateSeed != null) { upsertChat200StateFromCreate(chat200CreateSeed); @@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return b; } + private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) { + if (block == null || storedEntry == null) return null; + if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null; + + int msgType = block.type & 0xFFFF; + int msgSubType = block.subType & 0xFFFF; + long createdAtMs = block.timestamp * 1000L; + String ownerLogin = storedEntry.getToLogin(); + String sourceLogin = storedEntry.getLogin(); + + // A user must never receive a notification for replying to their own message/thread. + if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null; + + if (msgType == 1 + && msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF) + && block.body instanceof TextReplyBody replyBody) { + UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType); + entry.setNotificationKind("reply"); + entry.setSourceText(replyBody.message == null ? "" : replyBody.message); + return entry; + } + + // Connection notifications are intentionally modeled as a generic kind. + // Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types + // can reuse the same notification kind and expose their code via sourceMsgSubType. + if (msgType == 3 + && msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) + && block.body instanceof ConnectionBody) { + UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType); + entry.setNotificationKind("connection"); + entry.setSourceText(""); + return entry; + } + + return null; + } + + private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry, + String ownerLogin, + long createdAtMs, + int msgSubType) { + UserNotificationEntry entry = new UserNotificationEntry(); + entry.setOwnerLogin(ownerLogin); + entry.setCreatedAtMs(createdAtMs); + entry.setSourceLogin(storedEntry.getLogin()); + entry.setSourceBchName(blockchainNameFromEntry(storedEntry)); + entry.setSourceBlockNumber(storedEntry.getBlockNumber()); + entry.setSourceBlockHash(storedEntry.getBlockHash()); + entry.setTargetLogin(storedEntry.getToLogin()); + entry.setTargetBchName(storedEntry.getToBchName()); + entry.setTargetBlockNumber(storedEntry.getToBlockNumber()); + entry.setTargetBlockHash(storedEntry.getToBlockHash()); + entry.setSourceMsgSubType(msgSubType); + return entry; + } + + private String blockchainNameFromEntry(BlockEntry entry) { + return entry == null ? null : entry.getBchName(); + } + private static String toHex(byte[] bytes) { if (bytes == null) return "null"; char[] HEX = "0123456789abcdef".toCharArray(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 7c2171b9..72a730d1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; import shine.db.dao.UserParamsDAO; +import shine.db.dao.UserNotificationsStateDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockEntry; import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.UserParamEntry; +import shine.db.entities.UserNotificationEntry; import utils.files.FileStoreUtil; import java.io.IOException; @@ -41,16 +43,19 @@ public final class BlockchainWriter { private final BlockchainStateDAO stateDAO; private final ChannelNameStateDAO channelNameStateDAO; private final UserParamsDAO userParamsDAO; + private final UserNotificationsStateDAO userNotificationsStateDAO; private final FileStoreUtil fs = FileStoreUtil.getInstance(); public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO, UserParamsDAO userParamsDAO, - ChannelNameStateDAO channelNameStateDAO) { + ChannelNameStateDAO channelNameStateDAO, + UserNotificationsStateDAO userNotificationsStateDAO) { this.blocksDAO = blocksDAO; this.stateDAO = stateDAO; this.userParamsDAO = userParamsDAO; this.channelNameStateDAO = channelNameStateDAO; + this.userNotificationsStateDAO = userNotificationsStateDAO; } public void appendBlockAndState(String blockchainName, @@ -59,7 +64,8 @@ public final class BlockchainWriter { BlockEntry be, UserParamEntry userParamEntry, ChannelNameStateEntry channelNameStateEntry, - ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException { + ChannelNameStateEntry channelMetaUpdateEntry, + UserNotificationEntry notificationEntry) throws SQLException { long nowMs = System.currentTimeMillis(); byte[] blockBytes = block.toBytes(); @@ -96,6 +102,12 @@ public final class BlockchainWriter { channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); } + // Notification projection is part of the same SQL transaction as the block. + // If notification indexing fails, the block/state write is rolled back as well. + if (notificationEntry != null) { + userNotificationsStateDAO.upsert(c, notificationEntry); + } + c.commit(); committed = true; } catch (Exception e) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java new file mode 100644 index 00000000..5daaaba8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java @@ -0,0 +1,94 @@ +package server.logic.ws_protocol.JSON.handlers.notifications; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request; +import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.dao.UserNotificationsStateDAO; +import shine.db.entities.UserNotificationEntry; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; + +public final class Net_GetNotifications_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "NOT_AUTHENTICATED", + "Операция доступна только для авторизованных пользователей" + ); + } + + String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim(); + if (login.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя"); + } + + int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit())); + + try (Connection c = DbController.getInstance().getConnection()) { + List replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit); + List eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit); + + Net_GetNotifications_Response resp = new Net_GetNotifications_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(login); + resp.setReplies(mapRows(replyRows)); + resp.setEvents(mapRows(eventRows)); + return resp; + } catch (Exception e) { + log.error("GetNotifications failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + } + } + + private List mapRows(List rows) { + List out = new ArrayList<>(); + for (UserNotificationEntry row : rows) { + Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem(); + item.setKind(row.getNotificationKind()); + item.setCreatedAtMs(row.getCreatedAtMs()); + item.setSourceLogin(row.getSourceLogin()); + item.setSourceBlockchainName(row.getSourceBchName()); + item.setSourceBlockNumber(row.getSourceBlockNumber()); + item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash())); + item.setSourceMsgSubType(row.getSourceMsgSubType()); + item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null); + item.setSourceText(row.getSourceText()); + item.setTargetLogin(row.getTargetLogin()); + item.setTargetBlockchainName(row.getTargetBchName()); + item.setTargetBlockNumber(row.getTargetBlockNumber()); + item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash())); + out.add(item); + } + return out; + } + + private static String bytesToHex(byte[] bytes) { + if (bytes == null) return null; + char[] HEX = "0123456789abcdef".toCharArray(); + char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xff; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0f]; + } + return new String(out); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java new file mode 100644 index 00000000..4642f08f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.handlers.notifications.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetNotifications_Request extends Net_Request { + private Integer limit; + + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java new file mode 100644 index 00000000..bb0581a2 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java @@ -0,0 +1,62 @@ +package server.logic.ws_protocol.JSON.handlers.notifications.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_GetNotifications_Response extends Net_Response { + private String login; + private List replies = new ArrayList<>(); + private List events = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public List getReplies() { return replies; } + public void setReplies(List replies) { this.replies = replies; } + public List getEvents() { return events; } + public void setEvents(List events) { this.events = events; } + + public static class NotificationItem { + private String kind; + private long createdAtMs; + private String sourceLogin; + private String sourceBlockchainName; + private int sourceBlockNumber; + private String sourceBlockHash; + private Integer sourceMsgSubType; + private Integer connectionTypeCode; + private String sourceText; + private String targetLogin; + private String targetBlockchainName; + private Integer targetBlockNumber; + private String targetBlockHash; + + public String getKind() { return kind; } + public void setKind(String kind) { this.kind = kind; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public String getSourceLogin() { return sourceLogin; } + public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; } + public String getSourceBlockchainName() { return sourceBlockchainName; } + public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; } + public int getSourceBlockNumber() { return sourceBlockNumber; } + public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; } + public String getSourceBlockHash() { return sourceBlockHash; } + public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; } + public Integer getSourceMsgSubType() { return sourceMsgSubType; } + public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; } + public Integer getConnectionTypeCode() { return connectionTypeCode; } + public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; } + public String getSourceText() { return sourceText; } + public void setSourceText(String sourceText) { this.sourceText = sourceText; } + public String getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + public String getTargetBlockchainName() { return targetBlockchainName; } + public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; } + public Integer getTargetBlockNumber() { return targetBlockNumber; } + public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; } + public String getTargetBlockHash() { return targetBlockHash; } + public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; } + } +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index b767688c..68d61890 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth { "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); // 3) FRIEND взаимно (на HEADER) - sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND, + sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: FRIEND", t); - sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND, + sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND, bch1, u1HeaderBlock, u1HeaderHash, "U2 -> U1: FRIEND", t); diff --git a/TODO/Arweave/notifications_projection_full_resync.md b/TODO/Arweave/notifications_projection_full_resync.md new file mode 100644 index 00000000..f24db8c7 --- /dev/null +++ b/TODO/Arweave/notifications_projection_full_resync.md @@ -0,0 +1,21 @@ +# TODO: уведомления и full-resync после фиксации блоков в Arweave + +## Контекст + +`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup. + +На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave. + +## Что проверить после внедрения Arweave + +Когда схема Arweave и правила восстановления цепочки стабилизируются: + +- определить окончательный source of truth для accepted/finalized blocks; +- проверить поведение `user_notifications_state` при rollback/full-resync; +- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков; +- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку; +- добавить интеграционный тест на divergence + full-resync + notification projection. + +## Важно + +До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария. diff --git a/bundle.sh b/bundle.sh new file mode 100644 index 00000000..16adab75 --- /dev/null +++ b/bundle.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Build a source bundle ZIP while excluding credentials, private keys, +# local state, generated artifacts and other likely secrets. +# +# Usage: +# ./bundle.sh +# ./bundle.sh path/to/output.zip +# +# Run from anywhere inside the project; the script resolves its own directory. + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}" +case "$OUT" in + /*) ;; + *) OUT="$ROOT/$OUT" ;; +esac + +if ! command -v zip >/dev/null 2>&1; then + echo "ERROR: 'zip' is required." >&2 + exit 1 +fi + +TMP="$(mktemp -d)" +LIST="$TMP/files.txt" +SAFE_LIST="$TMP/safe-files.txt" +trap 'rm -rf "$TMP"' EXIT + +# Paths / filenames that must never be bundled. +is_denied_path() { + local p="/$1" + + case "$p" in + */.git/*|*/.git|\ + */.gradle/*|*/.gradle|\ + */.gradle-home/*|*/.gradle-home|\ + */.idea/*|*/.idea|\ + */.vscode/*|*/.vscode|\ + */node_modules/*|*/node_modules|\ + */target/*|*/target|\ + */build/*|*/build|\ + */out/*|*/out|\ + */bin/*|*/bin|\ + */logs/*|*/logs|\ + */data/*|*/data|\ + */test-ledger/*|*/test-ledger|\ + */.anchor/*|*/.anchor|\ + */.yarn/*|*/.yarn|\ + */.vendor/*|*/.vendor|\ + */.agents/*|*/.agents|\ + */.codex/*|*/.codex|\ + */.claude/*|*/.claude|\ + */deploy/backup/archive/*|\ + */scripts/*/runs/*|\ + */scripts/*/keypairs/*|\ + */keys/*|\ + */.git-local-backup/*) + return 0 + ;; + esac + + local base="${p##*/}" + local lower + lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')" + + case "$lower" in + .env|.env.*|\ + .debug-token|\ + .npmrc|.pypirc|.netrc|\ + credentials|credentials.*|\ + secrets|secrets.*|\ + secret|secret.*|\ + id_rsa|id_dsa|id_ecdsa|id_ed25519|\ + *.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\ + *keypair*.json|\ + service-account*.json|\ + firebase-adminsdk*.json|\ + google-services.json|\ + validator.log) + return 0 + ;; + esac + + case "$lower" in + *.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\ + *.elf|*.map|*.uf2|*.bin|*.merged.bin|\ + *.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\ + .ds_store) + return 0 + ;; + esac + + return 1 +} + +# Collect files. Prefer Git because it naturally avoids most ignored local files. +if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z" +else + find "$ROOT" -type f -print0 > "$TMP/files.z" +fi + +# Convert to project-relative paths and enforce hard deny rules. +: > "$LIST" +while IFS= read -r -d '' f; do + if [[ "$f" = /* ]]; then + rel="${f#"$ROOT"/}" + else + rel="$f" + fi + + [[ "$rel" == "$OUT" ]] && continue + [[ -z "$rel" ]] && continue + + if is_denied_path "$rel"; then + continue + fi + + printf '%s\n' "$rel" >> "$LIST" +done < "$TMP/files.z" + +sort -u "$LIST" -o "$LIST" + +# Content scan: fail closed on common credential/private-key patterns. +# We scan only text-ish files; grep -I skips binary data. +SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}' + +: > "$SAFE_LIST" +found_secret=0 + +while IFS= read -r rel; do + [[ -f "$ROOT/$rel" ]] || continue + + # Files that contain examples/templates can legitimately mention secret keys + # with placeholders. They are scanned too, but placeholder-looking values + # are less likely to match the regex above. + if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then + echo "BLOCKED: possible secret in $rel" >&2 + LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \ + | sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \ + | head -n 3 >&2 || true + found_secret=1 + continue + fi + + printf '%s\n' "$rel" >> "$SAFE_LIST" +done < "$LIST" + +if (( found_secret != 0 )); then + echo >&2 + echo "Bundle NOT created because possible secrets were detected." >&2 + echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2 + exit 2 +fi + +if [[ ! -s "$SAFE_LIST" ]]; then + echo "ERROR: no files left to bundle." >&2 + exit 3 +fi + +rm -f -- "$OUT" + +( + cd "$ROOT" + zip -q -9 "$OUT" -@ < "$SAFE_LIST" +) + +echo "Created: $OUT" +echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')" +echo "Size: $(du -h "$OUT" | awk '{print $1}')" diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 8d922090..2716626c 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -58,6 +58,7 @@ | `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя | | `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя | | `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга | +| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений | | `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | diff --git a/docs/API/15_Notifications_API.md b/docs/API/15_Notifications_API.md new file mode 100644 index 00000000..c0c40c8a --- /dev/null +++ b/docs/API/15_Notifications_API.md @@ -0,0 +1,81 @@ +# API для разработчиков: уведомления + +Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`. + +Текущая операция: + +- `GetNotifications` + +## 1. `GetNotifications` + +Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию. + +Возвращаются две отдельные ленты: + +- `replies` — ответы на сообщения пользователя в каналах и тредах; +- `events` — события добавления в `close_friend`. + +### Запрос + +```json +{ + "op": "GetNotifications", + "requestId": "notif-001", + "payload": { + "login": "alice", + "limit": 50 + } +} +``` + +### Успешный ответ + +```json +{ + "op": "GetNotifications", + "requestId": "notif-001", + "status": 200, + "ok": true, + "payload": { + "login": "Alice", + "replies": [ + { + "kind": "reply", + "createdAtMs": 1755673200000, + "sourceLogin": "Bob", + "sourceBlockchainName": "bob-001", + "sourceBlockNumber": 42, + "sourceBlockHash": "ab12...", + "sourceMsgSubType": 20, + "sourceText": "Спасибо!", + "targetLogin": "Alice", + "targetBlockchainName": "alice-001", + "targetBlockNumber": 18, + "targetBlockHash": "cd34..." + } + ], + "events": [ + { + "kind": "close_friend", + "createdAtMs": 1755673300000, + "sourceLogin": "Kate", + "sourceBlockchainName": "kate-001", + "sourceBlockNumber": 7, + "sourceBlockHash": "ef56...", + "sourceMsgSubType": 10, + "sourceText": "close_friend", + "targetLogin": "Alice", + "targetBlockchainName": "alice-001", + "targetBlockNumber": 0, + "targetBlockHash": "0000..." + } + ] + } +} +``` + +### Примечание + +- `replies` заполняется только для `TEXT_REPLY`. +- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`. +- Другие типы связей в эту ленту не попадают. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 32fa3ae1..d2122b16 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1371,7 +1371,7 @@ async function loadFromApi(route, channelId) { throw new Error('Канал не найден.'); } unreadCount = Number(channel?.unreadCount || 0); - messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0); + messagesCount = Number(channel?.messagesCount || 0); selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 578bfe70..d9418ef9 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -1,5 +1,4 @@ import { renderHeader } from '../components/header.js'; -import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { authService, state } from '../state.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; @@ -302,8 +301,6 @@ function renderItem(item, activeTab, navigate) { export function render({ navigate, chrome } = {}) { const screen = document.createElement('section'); screen.className = 'stack notifications-screen'; - const appScreen = document.getElementById('app-screen'); - const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen }); chrome?.setTopbar(renderHeader({ title: 'Уведомления' })); const tabs = document.createElement('div'); @@ -336,7 +333,6 @@ export function render({ navigate, chrome } = {}) { const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab))); if (seq !== requestSeq) return; list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate))); - scrollToBottomControl.refresh(); } catch (error) { if (seq !== requestSeq) return; const card = document.createElement('article'); @@ -364,8 +360,5 @@ export function render({ navigate, chrome } = {}) { screen.append(tabs, list); void load(); - screen.cleanup = () => { - scrollToBottomControl.cleanup(); - }; return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index ba3dbe0b..8863a616 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2862,6 +2862,14 @@ export class AuthService { return response.payload || {}; } + async getNotifications(limit = 50) { + const payload = {}; + if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit))); + const response = await this.ws.request('GetNotifications', payload); + if (response.status !== 200) throw opError('GetNotifications', response); + return response.payload || {}; + } + async getUserConnectionsGraph(login) { const response = await this.ws.request('GetUserConnectionsGraph', { login }); if (response.status !== 200) throw opError('GetUserConnectionsGraph', response); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 9af9512b..286cc78d 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -7453,19 +7453,6 @@ html, body { overflow-x: hidden; } border-color: rgba(230, 236, 245, 0.28); } -.profile-head-menu-wrap { - margin-left: auto; -} - -.profile-head-menu-item img { - width: 24px; - height: 24px; - flex: 0 0 auto; - display: block; - opacity: 0.94; - filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.18)); -} - .profile-info-modal { padding: 16px; background: rgba(2, 5, 11, 0.7); @@ -8154,62 +8141,3 @@ html, body { overflow-x: hidden; } .notifications-screen .notification-card--clickable:active { transform: scale(0.99); } - -/* ===== Shared scroll-to-bottom control ===== */ -.scroll-to-bottom-btn { - position: absolute; - right: 16px; - bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px) + 14px + env(safe-area-inset-bottom)); - z-index: 28; - width: 44px; - height: 44px; - padding: 0; - display: grid; - place-items: center; - border-radius: 50%; - border: 1px solid rgba(212, 175, 55, 0.38); - background: rgba(15, 20, 31, 0.82); - color: rgba(255, 226, 143, 0.96); - box-shadow: - 0 8px 26px rgba(0, 0, 0, 0.34), - 0 0 18px rgba(212, 175, 55, 0.14), - inset 0 1px 0 rgba(255, 255, 255, 0.08); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - opacity: 0; - pointer-events: none; - transform: translateY(10px) scale(0.92); - transition: opacity 160ms ease, transform 160ms ease, border-color 160ms ease, background 160ms ease; - cursor: pointer; - -webkit-tap-highlight-color: transparent; -} - -.scroll-to-bottom-btn.is-visible { - opacity: 1; - pointer-events: auto; - transform: translateY(0) scale(1); -} - -.scroll-to-bottom-btn:hover, -.scroll-to-bottom-btn:focus-visible { - border-color: rgba(255, 208, 82, 0.64); - background: rgba(24, 29, 43, 0.92); - outline: none; -} - -.scroll-to-bottom-btn:active { - transform: translateY(1px) scale(0.96); -} - -.scroll-to-bottom-btn__icon { - display: block; - margin-top: -2px; - font-size: 27px; - line-height: 1; - font-weight: 400; - text-shadow: 0 0 10px rgba(255, 201, 69, 0.34); -} - -.app-shell.keyboard-open .scroll-to-bottom-btn { - bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px) + 14px + env(safe-area-inset-bottom)); -} From bc8c7f318b01b271ff0c21b39d71be666ab4f5069756890e081fda684859c2dd Mon Sep 17 00:00:00 2001 From: peradas Date: Sat, 22 Aug 2026 11:28:12 +0300 Subject: [PATCH 3/9] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20UI-=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/index.html | 2 +- shine-UI/js/app.js | 25 ++- .../js/components/scroll-to-bottom-button.js | 3 + shine-UI/js/pages/channel-thread-view.js | 4 - shine-UI/js/pages/channel-view.js | 3 - shine-UI/js/pages/chat-view.js | 6 - shine-UI/js/pages/notifications-view.js | 46 ++++- shine-UI/styles/buttons-white.css | 165 ++++++++++++++++++ shine-UI/styles/components.css | 58 +++++- 9 files changed, 286 insertions(+), 26 deletions(-) create mode 100644 shine-UI/styles/buttons-white.css diff --git a/shine-UI/index.html b/shine-UI/index.html index db4c53ad..94c9d7b4 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -18,7 +18,7 @@ window.__SHINE_BUILD_HASH__ = '20260819190000';