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)); +}