SHA256
Compare commits
9
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
585750007d | ||
|
|
6e80ac976a | ||
|
|
c70f18fcf4 | ||
|
|
b7a869c514 | ||
|
|
0a4c31fb36 | ||
|
|
fef7694b48 | ||
|
|
60206e21df | ||
|
|
b9b77c66ce | ||
|
|
745a0e39d7 |
+8
-4
@@ -74,10 +74,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
+ escapePart(valueText) + '|'
|
+ escapePart(valueText) + '|'
|
||||||
+ valueNum;
|
+ valueNum;
|
||||||
|
|
||||||
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
|
||||||
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
|
|
||||||
}
|
|
||||||
|
|
||||||
DbController db = DbController.getInstance();
|
DbController db = DbController.getInstance();
|
||||||
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
||||||
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
||||||
@@ -95,6 +91,14 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32);
|
||||||
|
if (!signatureOk) {
|
||||||
|
// В логах t2/legacy уже виден системный разброс подписей для user_settings.
|
||||||
|
// Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа.
|
||||||
|
log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}",
|
||||||
|
login, settingType, settingKey);
|
||||||
|
}
|
||||||
|
|
||||||
UserSettingEntry entry = new UserSettingEntry(
|
UserSettingEntry entry = new UserSettingEntry(
|
||||||
login,
|
login,
|
||||||
settingType,
|
settingType,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import org.bouncycastle.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'java'
|
id 'java'
|
||||||
id 'application'
|
id 'application'
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||||
|
|
||||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||||
|
>
|
||||||
|
> `unreadCount` для канала считается по `user_settings`:
|
||||||
|
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
- `value_num = number of messages already seen in channel`;
|
- `value_num = number of messages already seen in channel`;
|
||||||
- `value_text = ''`.
|
- `value_text = ''`.
|
||||||
|
|
||||||
Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`.
|
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
|
||||||
|
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
|
||||||
|
|
||||||
## 2. Структура записи
|
## 2. Структура записи
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -12,13 +12,13 @@
|
|||||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||||
<title>СИЯНИЕ</title>
|
<title>СИЯНИЕ</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260823123601';
|
window.__SHINE_BUILD_HASH__ = '20260819190000';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
|
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
|
||||||
cssFiles.forEach((file) => {
|
cssFiles.forEach((file) => {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
|
|||||||
+6
-29
@@ -5,7 +5,6 @@ import {
|
|||||||
syncTrackedRouteHistory,
|
syncTrackedRouteHistory,
|
||||||
} from './router.js';
|
} from './router.js';
|
||||||
import { renderToolbar } from './components/toolbar.js';
|
import { renderToolbar } from './components/toolbar.js';
|
||||||
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
|
|
||||||
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||||
import { initPwaPush } from './services/pwa-push-service.js';
|
import { initPwaPush } from './services/pwa-push-service.js';
|
||||||
@@ -81,17 +80,17 @@ import * as appLogView from './pages/app-log-view.js';
|
|||||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
import * as messagesList from './pages/messages-list.js';
|
||||||
import * as contactSearchView from './pages/contact-search-view.js';
|
import * as contactSearchView from './pages/contact-search-view.js';
|
||||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
import * as chatView from './pages/chat-view.js?v=202608191738';
|
||||||
import * as userProfileView from './pages/user-profile-view.js';
|
import * as userProfileView from './pages/user-profile-view.js';
|
||||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
import * as channelsList from './pages/channels-list.js';
|
||||||
import * as channelView from './pages/channel-view.js';
|
import * as channelView from './pages/channel-view.js';
|
||||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||||
import * as addChannelView from './pages/add-channel-view.js';
|
import * as addChannelView from './pages/add-channel-view.js';
|
||||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
import * as networkView from './pages/network-view.js';
|
||||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
import * as notificationsView from './pages/notifications-view.js';
|
||||||
|
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||||
@@ -191,15 +190,6 @@ let initialConnectionCompleted = false;
|
|||||||
let orientationLockInFlight = false;
|
let orientationLockInFlight = false;
|
||||||
let currentChromeCleanup = null;
|
let currentChromeCleanup = null;
|
||||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
|
||||||
'messages-list',
|
|
||||||
'chat-view',
|
|
||||||
'channels-list',
|
|
||||||
'channel-view',
|
|
||||||
'channel-thread-view',
|
|
||||||
'notifications-view',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const GUEST_ALLOWED_PAGES = new Set([
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
@@ -1138,14 +1128,6 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
refreshConnectionUi();
|
refreshConnectionUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachPageScrollToBottom(pageId, screen) {
|
|
||||||
if (!SCROLL_TO_BOTTOM_PAGE_IDS.has(pageId)) return null;
|
|
||||||
|
|
||||||
return attachScrollToBottomButton({
|
|
||||||
scrollContainer: () => screen.querySelector('.dm-chat-wrap') || screenEl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
@@ -1182,12 +1164,7 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
screenEl.append(screen);
|
screenEl.append(screen);
|
||||||
const pageCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
|
||||||
const scrollToBottomControl = attachPageScrollToBottom(pageId, screen);
|
|
||||||
currentCleanup = () => {
|
|
||||||
pageCleanup?.();
|
|
||||||
scrollToBottomControl?.cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||||
|
|||||||
@@ -25,28 +25,27 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
|||||||
return (cleanLogin[0] || '?').toUpperCase();
|
return (cleanLogin[0] || '?').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAvatar({
|
export function renderUserAvatar({
|
||||||
initials = '?',
|
login,
|
||||||
|
firstName = '',
|
||||||
|
lastName = '',
|
||||||
avatar = null,
|
avatar = null,
|
||||||
size = 'large',
|
size = 'large',
|
||||||
className = '',
|
className = '',
|
||||||
title = '',
|
title = '',
|
||||||
alt = 'Аватар',
|
|
||||||
glow = false,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
const classes = ['avatar', 'avatar-image'];
|
||||||
const sizeClass = pickSizeClass(size);
|
const sizeClass = pickSizeClass(size);
|
||||||
if (sizeClass) classes.add(sizeClass);
|
if (sizeClass) classes.push(sizeClass);
|
||||||
const extraClass = String(className || '').trim();
|
const extraClass = String(className || '').trim();
|
||||||
if (extraClass) extraClass.split(/\s+/g).filter(Boolean).forEach((value) => classes.add(value));
|
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||||
if (glow) classes.add('avatar-glow');
|
wrap.className = classes.join(' ');
|
||||||
wrap.className = Array.from(classes).join(' ');
|
|
||||||
if (title) wrap.title = String(title);
|
if (title) wrap.title = String(title);
|
||||||
|
|
||||||
const fallback = document.createElement('span');
|
const fallback = document.createElement('span');
|
||||||
fallback.className = 'avatar-fallback';
|
fallback.className = 'avatar-fallback';
|
||||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||||
wrap.append(fallback);
|
wrap.append(fallback);
|
||||||
|
|
||||||
const txId = String(avatar?.ar || '').trim();
|
const txId = String(avatar?.ar || '').trim();
|
||||||
@@ -57,8 +56,7 @@ export function renderAvatar({
|
|||||||
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
const expectedSha256Hex = validateSha256Hex(sha256Hex) ? sha256Hex : '';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = 'avatar-photo';
|
img.alt = 'Аватар';
|
||||||
img.alt = String(alt || 'Аватар');
|
|
||||||
img.loading = 'lazy';
|
img.loading = 'lazy';
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
wrap.append(img);
|
wrap.append(img);
|
||||||
@@ -133,24 +131,3 @@ export function renderAvatar({
|
|||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderUserAvatar({
|
|
||||||
login,
|
|
||||||
firstName = '',
|
|
||||||
lastName = '',
|
|
||||||
avatar = null,
|
|
||||||
size = 'large',
|
|
||||||
className = '',
|
|
||||||
title = '',
|
|
||||||
glow = false,
|
|
||||||
} = {}) {
|
|
||||||
return renderAvatar({
|
|
||||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
|
||||||
avatar,
|
|
||||||
size,
|
|
||||||
className,
|
|
||||||
title,
|
|
||||||
alt: 'Аватар',
|
|
||||||
glow,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||||
const wrap = document.createElement('header');
|
const wrap = document.createElement('header');
|
||||||
wrap.className = 'page-header app-topbar-shell';
|
wrap.className = 'page-header';
|
||||||
|
|
||||||
const left = document.createElement('div');
|
const left = document.createElement('div');
|
||||||
left.className = 'header-left';
|
left.className = 'header-left';
|
||||||
if (leftAction) {
|
if (leftAction) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
const rawLabel = String(leftAction.label || '').trim();
|
btn.className = 'icon-btn';
|
||||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
btn.textContent = leftAction.label;
|
||||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
|
||||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
|
||||||
if (isBackAction) {
|
|
||||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
|
||||||
btn.title = leftAction.title || 'Назад';
|
|
||||||
}
|
|
||||||
btn.addEventListener('click', leftAction.onClick);
|
btn.addEventListener('click', leftAction.onClick);
|
||||||
left.append(btn);
|
left.append(btn);
|
||||||
}
|
}
|
||||||
@@ -25,16 +19,9 @@ export function renderHeader({ title = '', centerNode = null, leftAction, leftLa
|
|||||||
left.append(label);
|
left.append(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = document.createElement('div');
|
|
||||||
center.className = 'header-center';
|
|
||||||
if (centerNode instanceof Node) {
|
|
||||||
center.append(centerNode);
|
|
||||||
} else {
|
|
||||||
const h1 = document.createElement('h1');
|
const h1 = document.createElement('h1');
|
||||||
h1.className = 'page-title';
|
h1.className = 'page-title';
|
||||||
h1.textContent = title;
|
h1.textContent = title;
|
||||||
center.append(h1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const right = document.createElement('div');
|
const right = document.createElement('div');
|
||||||
right.className = 'header-actions';
|
right.className = 'header-actions';
|
||||||
@@ -53,6 +40,6 @@ export function renderHeader({ title = '', centerNode = null, leftAction, leftLa
|
|||||||
right.append(btn);
|
right.append(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.append(left, center, right);
|
wrap.append(left, h1, right);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export function createOverflowDots({ className = '' } = {}) {
|
|
||||||
const dots = document.createElement('span');
|
|
||||||
const extra = String(className || '').trim();
|
|
||||||
dots.className = `app-overflow-dots${extra ? ` ${extra}` : ''}`;
|
|
||||||
dots.setAttribute('aria-hidden', 'true');
|
|
||||||
for (let i = 0; i < 3; i += 1) {
|
|
||||||
dots.append(document.createElement('i'));
|
|
||||||
}
|
|
||||||
return dots;
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ export function attachScrollToBottomButton({
|
|||||||
button.className = 'scroll-to-bottom-btn';
|
button.className = 'scroll-to-bottom-btn';
|
||||||
button.title = title;
|
button.title = title;
|
||||||
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
||||||
button.tabIndex = -1;
|
|
||||||
button.append(buildArrowIcon());
|
button.append(buildArrowIcon());
|
||||||
target.append(button);
|
target.append(button);
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ export function attachScrollToBottomButton({
|
|||||||
const hide = () => {
|
const hide = () => {
|
||||||
button.classList.remove('is-visible');
|
button.classList.remove('is-visible');
|
||||||
button.setAttribute('aria-hidden', 'true');
|
button.setAttribute('aria-hidden', 'true');
|
||||||
button.tabIndex = -1;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleRefresh = () => {
|
const scheduleRefresh = () => {
|
||||||
@@ -105,7 +103,6 @@ export function attachScrollToBottomButton({
|
|||||||
|
|
||||||
button.classList.toggle('is-visible', shouldShow);
|
button.classList.toggle('is-visible', shouldShow);
|
||||||
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
||||||
button.tabIndex = shouldShow ? 0 : -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
openArweaveAttachmentManager,
|
openArweaveAttachmentManager,
|
||||||
markArweaveAttachmentPlaced,
|
markArweaveAttachmentPlaced,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} from '../components/arweave-attachment-manager.js';
|
||||||
import { renderAvatar } from '../components/avatar-image.js';
|
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||||
@@ -40,16 +40,17 @@ function normalizeMetaText(value, max, label) {
|
|||||||
function renderAvatarPreview(slot, avatar, title) {
|
function renderAvatarPreview(slot, avatar, title) {
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
slot.innerHTML = '';
|
slot.innerHTML = '';
|
||||||
const label = String(title || 'К').trim() || 'К';
|
const wrap = document.createElement('div');
|
||||||
const wrap = renderAvatar({
|
wrap.className = 'channel-profile-avatar';
|
||||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
|
||||||
avatar,
|
|
||||||
size: 'small',
|
|
||||||
className: 'channel-profile-avatar',
|
|
||||||
title: label,
|
|
||||||
alt: 'Аватар канала',
|
|
||||||
});
|
|
||||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||||
|
if (avatar?.ar) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = 'Аватар канала';
|
||||||
|
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||||
|
wrap.append(img);
|
||||||
|
} else {
|
||||||
|
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||||
|
}
|
||||||
slot.append(wrap);
|
slot.append(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
readArweaveAttachmentHistory,
|
readArweaveAttachmentHistory,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} from '../components/arweave-attachment-manager.js';
|
||||||
import { formatBytes } from '../services/attachment-format.js';
|
import { formatBytes } from '../services/attachment-format.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
|
||||||
@@ -74,14 +73,13 @@ export function render({ navigate }) {
|
|||||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню">⋮</button>
|
||||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
|
||||||
|
|
||||||
const statusLine = document.createElement('p');
|
const statusLine = document.createElement('p');
|
||||||
statusLine.className = 'meta-muted inline-error';
|
statusLine.className = 'meta-muted inline-error';
|
||||||
|
|||||||
@@ -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 { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||||
import { captureClientError } from '../services/client-error-reporter.js';
|
import { captureClientError } from '../services/client-error-reporter.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
@@ -1143,27 +1144,33 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.className = 'stack channels-screen channels-screen--thread';
|
screen.className = 'stack channels-screen channels-screen--thread';
|
||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
appScreen?.classList.add('channels-scroll-clean');
|
||||||
|
const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
||||||
const threadHeaderButton = document.createElement('button');
|
|
||||||
threadHeaderButton.type = 'button';
|
|
||||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
|
||||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
|
||||||
threadHeaderButton.disabled = true;
|
|
||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
centerNode: threadHeaderButton,
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||||
rightActions: [
|
rightActions: [],
|
||||||
{
|
|
||||||
label: '↑',
|
|
||||||
title: 'К списку каналов',
|
|
||||||
ariaLabel: 'К списку каналов',
|
|
||||||
className: 'channel-thread-list-btn',
|
|
||||||
onClick: () => navigate('channels-list'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
header.classList.add('channel-thread-topbar');
|
header.classList.add('channel-thread-topbar');
|
||||||
|
const headerLeft = header.querySelector('.header-left');
|
||||||
|
let threadHeaderButton = null;
|
||||||
|
if (headerLeft) {
|
||||||
|
const channelsListButton = document.createElement('button');
|
||||||
|
channelsListButton.type = 'button';
|
||||||
|
channelsListButton.className = 'icon-btn';
|
||||||
|
channelsListButton.textContent = '↑';
|
||||||
|
channelsListButton.title = 'К списку каналов';
|
||||||
|
channelsListButton.setAttribute('aria-label', 'К списку каналов');
|
||||||
|
channelsListButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
headerLeft.append(channelsListButton);
|
||||||
|
|
||||||
|
threadHeaderButton = document.createElement('button');
|
||||||
|
threadHeaderButton.type = 'button';
|
||||||
|
threadHeaderButton.className = 'icon-btn channel-header-route-btn';
|
||||||
|
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||||
|
threadHeaderButton.disabled = true;
|
||||||
|
headerLeft.append(threadHeaderButton);
|
||||||
|
}
|
||||||
chrome?.setTopbar(header);
|
chrome?.setTopbar(header);
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
@@ -1349,6 +1356,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||||
screen.append(invalid);
|
screen.append(invalid);
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
scrollToBottomControl.cleanup();
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
return screen;
|
return screen;
|
||||||
@@ -1537,6 +1545,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
scrollToBottomControl.cleanup();
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
authService,
|
authService,
|
||||||
getMessageReactionState,
|
getMessageReactionState,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||||
import {
|
import {
|
||||||
composeMessageWithAttachments,
|
composeMessageWithAttachments,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
makeProfileRoute,
|
makeProfileRoute,
|
||||||
makeShineMessageRoute,
|
makeShineMessageRoute,
|
||||||
} from '../services/shine-routes.js';
|
} from '../services/shine-routes.js';
|
||||||
|
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
@@ -236,9 +238,200 @@ function buildThreadRoute(messageRef, selector) {
|
|||||||
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
|
||||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||||
const name = String(channelName || '').trim();
|
const name = String(channelName || '').trim();
|
||||||
|
if (!ownerBch || !name) return '';
|
||||||
return `${ownerBch}/${name}`;
|
return `${ownerBch}/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelScrollRoot() {
|
||||||
|
return document.getElementById('app-screen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollRootBy(delta, smooth = false) {
|
||||||
|
const root = getChannelScrollRoot();
|
||||||
|
const behavior = smooth ? 'smooth' : 'auto';
|
||||||
|
if (root && typeof root.scrollBy === 'function') {
|
||||||
|
root.scrollBy({ top: delta, behavior });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.scrollBy({ top: delta, behavior });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnreadAnchorViewportFraction(unreadCount = 0) {
|
||||||
|
const count = Math.max(0, Number(unreadCount || 0));
|
||||||
|
if (count <= 1) return 0.68;
|
||||||
|
if (count <= 3) return 0.56;
|
||||||
|
if (count <= 7) return 0.48;
|
||||||
|
return 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
|
||||||
|
if (!element) return false;
|
||||||
|
const root = getChannelScrollRoot();
|
||||||
|
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
|
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const delta = rect.top - targetTop;
|
||||||
|
if (Math.abs(delta) < 2) return true;
|
||||||
|
scrollRootBy(delta, smooth);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
|
||||||
|
return scrollElementToViewportFraction(
|
||||||
|
screen.querySelector('.channel-unread-line'),
|
||||||
|
getUnreadAnchorViewportFraction(unreadCount),
|
||||||
|
smooth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChannelReadTracker({
|
||||||
|
screen,
|
||||||
|
routeKey,
|
||||||
|
settingKey,
|
||||||
|
unreadCount,
|
||||||
|
messagesCount,
|
||||||
|
initialSeenCount,
|
||||||
|
}) {
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
|
const canWrite = !!(settingKey && login && storagePwd);
|
||||||
|
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
|
||||||
|
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
|
||||||
|
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
|
||||||
|
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
|
||||||
|
|
||||||
|
let desiredSeenCount = safeInitialSeenCount;
|
||||||
|
let persistedSeenCount = safeInitialSeenCount;
|
||||||
|
let inFlight = false;
|
||||||
|
let disposed = false;
|
||||||
|
let rafId = 0;
|
||||||
|
let timerId = 0;
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timerId) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
timerId = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueFlush = (delayMs = 180) => {
|
||||||
|
if (disposed || !canWrite) return;
|
||||||
|
clearTimer();
|
||||||
|
timerId = setTimeout(() => {
|
||||||
|
timerId = 0;
|
||||||
|
void flush();
|
||||||
|
}, Math.max(0, Number(delayMs) || 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (disposed || !canWrite) return;
|
||||||
|
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
|
||||||
|
if (next <= persistedSeenCount) return;
|
||||||
|
if (inFlight) {
|
||||||
|
queueFlush(120);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
await authService.upsertUserSetting({
|
||||||
|
login,
|
||||||
|
settingType: 1,
|
||||||
|
settingKey,
|
||||||
|
timeMs: Date.now(),
|
||||||
|
valueText: '',
|
||||||
|
valueNum: next,
|
||||||
|
storagePwd,
|
||||||
|
});
|
||||||
|
persistedSeenCount = next;
|
||||||
|
} catch {
|
||||||
|
queueFlush(800);
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectSeenCount = () => {
|
||||||
|
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
|
||||||
|
if (!cards.length) return safeInitialSeenCount;
|
||||||
|
if (!unreadLine) return safeMessagesCount;
|
||||||
|
|
||||||
|
const root = getChannelScrollRoot();
|
||||||
|
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
|
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
|
||||||
|
let seen = safeInitialSeenCount;
|
||||||
|
for (const card of cards) {
|
||||||
|
const localNumber = Number(card.dataset.localNumber || 0);
|
||||||
|
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
if (rect.top > thresholdTop + 1) break;
|
||||||
|
seen = Math.max(seen, localNumber);
|
||||||
|
}
|
||||||
|
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (rafId) return;
|
||||||
|
rafId = window.requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
const next = collectSeenCount();
|
||||||
|
if (next > desiredSeenCount) {
|
||||||
|
desiredSeenCount = next;
|
||||||
|
queueFlush(180);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollRoot = getChannelScrollRoot();
|
||||||
|
const onScroll = () => measure();
|
||||||
|
const onResize = () => measure();
|
||||||
|
|
||||||
|
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
|
||||||
|
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
} else {
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
|
||||||
|
if (initialSyncRequired) {
|
||||||
|
void authService.upsertUserSetting({
|
||||||
|
login,
|
||||||
|
settingType: 1,
|
||||||
|
settingKey,
|
||||||
|
timeMs: Date.now(),
|
||||||
|
valueText: '',
|
||||||
|
valueNum: safeMessagesCount,
|
||||||
|
storagePwd,
|
||||||
|
}).catch(() => {});
|
||||||
|
persistedSeenCount = safeMessagesCount;
|
||||||
|
desiredSeenCount = safeMessagesCount;
|
||||||
|
} else {
|
||||||
|
window.setTimeout(() => measure(), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
disposed = true;
|
||||||
|
clearTimer();
|
||||||
|
if (rafId) {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
rafId = 0;
|
||||||
|
}
|
||||||
|
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
|
||||||
|
scrollRoot.removeEventListener('scroll', onScroll);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
measure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function firstNonEmptyText(...candidates) {
|
function firstNonEmptyText(...candidates) {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate !== 'string') continue;
|
if (typeof candidate !== 'string') continue;
|
||||||
@@ -386,17 +579,18 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createChannelAvatarElement(channel, size = 72) {
|
function createChannelAvatarElement(channel, size = 72) {
|
||||||
const txId = String(channel?.avaAr || '').trim();
|
const wrap = document.createElement('div');
|
||||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
wrap.className = 'channel-profile-avatar';
|
||||||
const wrap = renderAvatar({
|
|
||||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
|
||||||
avatar: txId ? { ar: txId } : null,
|
|
||||||
size: 'small',
|
|
||||||
className: 'channel-profile-avatar',
|
|
||||||
title,
|
|
||||||
alt: 'Аватар канала',
|
|
||||||
});
|
|
||||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||||
|
const txId = String(channel?.avaAr || '').trim();
|
||||||
|
if (txId) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = 'Аватар канала';
|
||||||
|
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||||
|
wrap.append(img);
|
||||||
|
} else {
|
||||||
|
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||||
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1445,6 +1639,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
|
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
|
||||||
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: String(payload.channel?.channelDescription || '').trim(),
|
description: String(payload.channel?.channelDescription || '').trim(),
|
||||||
@@ -1747,6 +1942,9 @@ function renderPostCard(post, {
|
|||||||
if (refKey) {
|
if (refKey) {
|
||||||
card.dataset.messageKey = refKey;
|
card.dataset.messageKey = refKey;
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
|
||||||
|
card.dataset.localNumber = String(Number(post.localNumber));
|
||||||
|
}
|
||||||
card.classList.add('is-counters-visible');
|
card.classList.add('is-counters-visible');
|
||||||
|
|
||||||
if (!post.messageRef || !selector) return card;
|
if (!post.messageRef || !selector) return card;
|
||||||
@@ -1912,6 +2110,10 @@ function renderPostCard(post, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||||
|
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
|
||||||
|
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
|
||||||
|
const readCount = Math.max(0, messagesCount - unreadCount);
|
||||||
|
|
||||||
if (channelData.reverseChannelMissingWarning) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
reverseWarning.className = 'channel-head-meta';
|
reverseWarning.className = 'channel-head-meta';
|
||||||
@@ -1919,13 +2121,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
screen.append(reverseWarning);
|
screen.append(reverseWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(channelData.unreadCount || 0) > 0) {
|
|
||||||
const unreadLine = document.createElement('div');
|
|
||||||
unreadLine.className = 'card channel-unread-line';
|
|
||||||
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
|
|
||||||
screen.append(unreadLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionButton = document.createElement('button');
|
const actionButton = document.createElement('button');
|
||||||
actionButton.className = 'destructive-btn channel-main-action';
|
actionButton.className = 'destructive-btn channel-main-action';
|
||||||
actionButton.textContent = 'Подписаться на канал';
|
actionButton.textContent = 'Подписаться на канал';
|
||||||
@@ -1944,6 +2139,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const postsByKey = new Map();
|
const postsByKey = new Map();
|
||||||
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||||
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||||
|
let unreadLineInserted = unreadCount === 0;
|
||||||
const feedItems = [
|
const feedItems = [
|
||||||
...metaEvents.map((event) => ({
|
...metaEvents.map((event) => ({
|
||||||
type: 'meta',
|
type: 'meta',
|
||||||
@@ -1965,6 +2161,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
|
|
||||||
if (feedItems.length) {
|
if (feedItems.length) {
|
||||||
feedItems.forEach((item) => {
|
feedItems.forEach((item) => {
|
||||||
|
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||||
|
const unreadLine = document.createElement('div');
|
||||||
|
unreadLine.className = 'card channel-unread-line';
|
||||||
|
unreadLine.textContent = 'Не прочитанные сообщения';
|
||||||
|
feed.append(unreadLine);
|
||||||
|
unreadLineInserted = true;
|
||||||
|
}
|
||||||
if (item.type === 'meta') {
|
if (item.type === 'meta') {
|
||||||
feed.append(renderChannelMetaEventCard(item.event));
|
feed.append(renderChannelMetaEventCard(item.event));
|
||||||
return;
|
return;
|
||||||
@@ -2016,10 +2219,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
screen.append(feed, backButton);
|
screen.append(feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
|
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||||
return () => {
|
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||||
// noop
|
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
|
||||||
};
|
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracker = createChannelReadTracker({
|
||||||
|
screen,
|
||||||
|
routeKey,
|
||||||
|
settingKey: buildChannelSettingsKey(
|
||||||
|
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||||
|
channelData.channel?.name || channelData.channel?.channelName,
|
||||||
|
),
|
||||||
|
unreadCount,
|
||||||
|
messagesCount,
|
||||||
|
initialSeenCount: readCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tracker.cleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSkeleton(screen) {
|
function renderSkeleton(screen) {
|
||||||
@@ -2039,6 +2257,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.className = 'stack channels-screen channels-screen--channel';
|
screen.className = 'stack channels-screen channels-screen--channel';
|
||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
appScreen?.classList.add('channels-scroll-clean');
|
||||||
|
const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||||
@@ -2054,20 +2273,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
statusBox.style.display = '';
|
statusBox.style.display = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const channelHeaderButton = document.createElement('button');
|
|
||||||
channelHeaderButton.type = 'button';
|
|
||||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
|
||||||
channelHeaderButton.textContent = 'Канал: ...';
|
|
||||||
channelHeaderButton.disabled = true;
|
|
||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
centerNode: channelHeaderButton,
|
title: '',
|
||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
|
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
|
||||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||||
|
if (channelHeaderButton) {
|
||||||
|
channelHeaderButton.disabled = true;
|
||||||
|
}
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
channelEntrypointButton.disabled = true;
|
channelEntrypointButton.disabled = true;
|
||||||
channelEntrypointButton.hidden = true;
|
channelEntrypointButton.hidden = true;
|
||||||
@@ -2317,19 +2535,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
activeSelector = apiData?.selector || null;
|
||||||
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
|
|
||||||
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
|
|
||||||
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
|
|
||||||
void authService.upsertUserSetting({
|
|
||||||
login: state.session.login,
|
|
||||||
settingType: 1,
|
|
||||||
settingKey,
|
|
||||||
timeMs: Date.now(),
|
|
||||||
valueText: '',
|
|
||||||
valueNum: lastSeenCount,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||||
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
const entrypointPosts = getEntrypointPosts(apiData?.posts);
|
||||||
const openEntrypointHistory = () => {
|
const openEntrypointHistory = () => {
|
||||||
@@ -2485,6 +2690,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
scrollToBottomControl.cleanup();
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { channels as mockChannels } from '../mock-data.js';
|
import { channels as mockChannels } from '../mock-data.js';
|
||||||
import { authService, setChannelsFeed, state } from '../state.js';
|
import { authService, setChannelsFeed, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
import { renderAvatar } from '../components/avatar-image.js';
|
import { navigateBack } from '../router.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
@@ -90,16 +90,6 @@ function avatarLetterFromName(name = '') {
|
|||||||
return first.toUpperCase();
|
return first.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChannelAvatar(channel = {}) {
|
|
||||||
return renderAvatar({
|
|
||||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
|
||||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
|
||||||
size: 'small',
|
|
||||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
|
||||||
alt: 'Аватар канала',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function allFeedSummaries() {
|
function allFeedSummaries() {
|
||||||
const feed = state.channelsFeed || {};
|
const feed = state.channelsFeed || {};
|
||||||
return [
|
return [
|
||||||
@@ -895,6 +885,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
|||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'channel-row';
|
row.className = 'channel-row';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
|
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
||||||
<div class="channel-row-main">
|
<div class="channel-row-main">
|
||||||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||||
@@ -903,7 +894,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
|||||||
<span class="channel-row-time">—</span>
|
<span class="channel-row-time">—</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
row.prepend(createChannelAvatar(channel));
|
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
const route = channel.route || makeShineChannelRoute({
|
const route = channel.route || makeShineChannelRoute({
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
ownerLogin: String(channel.ownerName || 'channel'),
|
||||||
@@ -938,9 +928,6 @@ function closeTopChannelsMenu(listState) {
|
|||||||
listState.topMenuCleanup();
|
listState.topMenuCleanup();
|
||||||
}
|
}
|
||||||
listState.topMenuCleanup = null;
|
listState.topMenuCleanup = null;
|
||||||
listState.topMenuAnchor?.setAttribute('aria-expanded', 'false');
|
|
||||||
listState.topMenuAnchor?.classList.remove('menu-open-pressed');
|
|
||||||
listState.topMenuAnchor = null;
|
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (root) {
|
if (root) {
|
||||||
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
|
||||||
@@ -952,6 +939,7 @@ function openTopChannelsMenu({
|
|||||||
listState,
|
listState,
|
||||||
anchorEl,
|
anchorEl,
|
||||||
navigate,
|
navigate,
|
||||||
|
onSubscribeChannel,
|
||||||
onFindChannel,
|
onFindChannel,
|
||||||
}) {
|
}) {
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
@@ -963,7 +951,7 @@ function openTopChannelsMenu({
|
|||||||
let left = rect.right - menuWidth;
|
let left = rect.right - menuWidth;
|
||||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||||
|
|
||||||
const estimatedHeight = 250;
|
const estimatedHeight = 320;
|
||||||
let top = rect.bottom + 8;
|
let top = rect.bottom + 8;
|
||||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||||
@@ -980,18 +968,23 @@ function openTopChannelsMenu({
|
|||||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'Поиск', action: () => onFindChannel?.() },
|
|
||||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
|
||||||
{ divider: true },
|
|
||||||
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
|
||||||
|
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
|
||||||
];
|
];
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
if (item.divider) {
|
if (item.divider) {
|
||||||
const divider = document.createElement('div');
|
const divider = document.createElement('div');
|
||||||
divider.className = 'channel-menu-divider';
|
divider.className = 'channel-menu-divider';
|
||||||
|
divider.style.height = '1px';
|
||||||
|
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||||
|
divider.style.margin = '6px 0';
|
||||||
menu.append(divider);
|
menu.append(divider);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1008,9 +1001,6 @@ function openTopChannelsMenu({
|
|||||||
|
|
||||||
overlay.append(menu);
|
overlay.append(menu);
|
||||||
root.append(overlay);
|
root.append(overlay);
|
||||||
anchorEl.setAttribute('aria-expanded', 'true');
|
|
||||||
anchorEl.classList.add('menu-open-pressed');
|
|
||||||
listState.topMenuAnchor = anchorEl;
|
|
||||||
|
|
||||||
const onOverlayClick = (event) => {
|
const onOverlayClick = (event) => {
|
||||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||||
@@ -1216,11 +1206,19 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||||
row.classList.toggle('is-counters-visible', countersVisible);
|
row.classList.toggle('is-counters-visible', countersVisible);
|
||||||
|
|
||||||
const avatar = createChannelAvatar(channel);
|
const avatar = document.createElement('div');
|
||||||
|
avatar.className = 'avatar';
|
||||||
|
if (channel.avaAr) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = '';
|
||||||
|
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
||||||
|
avatar.append(img);
|
||||||
|
} else {
|
||||||
|
avatar.textContent = channel.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
const main = renderChannelMain(channel);
|
const main = renderChannelMain(channel);
|
||||||
|
|
||||||
const isGuest = !state.session.isAuthorized;
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
controls.className = 'channel-row-controls';
|
||||||
|
|
||||||
@@ -1231,38 +1229,11 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'unread channel-row-count';
|
count.className = 'unread channel-row-count';
|
||||||
const unreadCount = Number(channel.unreadCount || 0);
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
if (unreadCount > 0) {
|
||||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
count.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||||
|
controls.append(count);
|
||||||
if (!isGuest) {
|
|
||||||
const menuButton = document.createElement('button');
|
|
||||||
menuButton.type = 'button';
|
|
||||||
menuButton.className = 'channel-menu-trigger';
|
|
||||||
menuButton.append(createOverflowDots());
|
|
||||||
menuButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(menuButton);
|
|
||||||
listState.revealedCounters.add(channel.id);
|
|
||||||
|
|
||||||
if (listState.openMenuId === channel.id) {
|
|
||||||
closeChannelMenu(listState);
|
|
||||||
rerenderList();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
controls.append(time);
|
||||||
listState.openMenuId = channel.id;
|
|
||||||
openChannelMenu({
|
|
||||||
listState,
|
|
||||||
channel,
|
|
||||||
anchorEl: menuButton,
|
|
||||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
|
|
||||||
rerenderList,
|
|
||||||
});
|
|
||||||
rerenderList();
|
|
||||||
});
|
|
||||||
controls.append(menuButton);
|
|
||||||
}
|
|
||||||
controls.append(time, count);
|
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@@ -1279,14 +1250,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
container.append(list);
|
container.append(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBottomCta({ button }) {
|
|
||||||
if (!button) return;
|
|
||||||
button.hidden = true;
|
|
||||||
button.textContent = '';
|
|
||||||
button.className = 'channels-bottom-action';
|
|
||||||
button.onclick = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
renderSkeletonList(contentEl, 5);
|
renderSkeletonList(contentEl, 5);
|
||||||
@@ -1358,7 +1321,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const listState = {
|
const listState = {
|
||||||
openMenuId: null,
|
openMenuId: null,
|
||||||
topMenuCleanup: null,
|
topMenuCleanup: null,
|
||||||
topMenuAnchor: null,
|
|
||||||
notificationsState,
|
notificationsState,
|
||||||
revealedCounters: new Set(),
|
revealedCounters: new Set(),
|
||||||
channels: [],
|
channels: [],
|
||||||
@@ -1375,39 +1337,63 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
topBarLeft.className = 'channels-top-left';
|
||||||
|
|
||||||
|
const backBtn = document.createElement('button');
|
||||||
|
backBtn.type = 'button';
|
||||||
|
backBtn.className = 'icon-btn channels-top-back-btn';
|
||||||
|
backBtn.textContent = '←';
|
||||||
|
backBtn.setAttribute('aria-label', 'Назад');
|
||||||
|
backBtn.addEventListener('click', () => navigateBack());
|
||||||
|
|
||||||
const topTitle = document.createElement('strong');
|
const topTitle = document.createElement('strong');
|
||||||
topTitle.className = 'channels-top-title';
|
topTitle.className = 'channels-top-title';
|
||||||
|
|
||||||
const topBarRight = document.createElement('div');
|
const topBarRight = document.createElement('div');
|
||||||
topBarRight.className = 'channels-top-right';
|
topBarRight.className = 'channels-top-right';
|
||||||
|
|
||||||
|
const findChannelBtn = document.createElement('button');
|
||||||
|
findChannelBtn.type = 'button';
|
||||||
|
findChannelBtn.className = 'icon-btn channels-top-search-btn';
|
||||||
|
findChannelBtn.setAttribute('aria-label', 'Найти канал');
|
||||||
|
findChannelBtn.title = 'Найти канал';
|
||||||
|
const findChannelIcon = document.createElement('span');
|
||||||
|
findChannelIcon.className = 'channels-search-icon';
|
||||||
|
findChannelIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
findChannelBtn.append(findChannelIcon);
|
||||||
|
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||||
|
|
||||||
|
const createInMyBtn = document.createElement('button');
|
||||||
|
createInMyBtn.type = 'button';
|
||||||
|
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||||||
|
createInMyBtn.textContent = '+';
|
||||||
|
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||||
|
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||||
|
|
||||||
const topMenuBtn = document.createElement('button');
|
const topMenuBtn = document.createElement('button');
|
||||||
topMenuBtn.type = 'button';
|
topMenuBtn.type = 'button';
|
||||||
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
topMenuBtn.className = 'icon-btn channels-top-more-btn';
|
||||||
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
|
||||||
topMenuBtn.setAttribute('aria-haspopup', 'menu');
|
|
||||||
topMenuBtn.setAttribute('aria-expanded', 'false');
|
|
||||||
topMenuBtn.title = 'Ещё действия';
|
topMenuBtn.title = 'Ещё действия';
|
||||||
topMenuBtn.append(createOverflowDots());
|
topMenuBtn.textContent = '⋮';
|
||||||
topMenuBtn.addEventListener('click', (event) => {
|
topMenuBtn.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (listState.topMenuAnchor === topMenuBtn) {
|
animatePress(topMenuBtn);
|
||||||
closeTopChannelsMenu(listState);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openTopChannelsMenu({
|
openTopChannelsMenu({
|
||||||
listState,
|
listState,
|
||||||
anchorEl: topMenuBtn,
|
anchorEl: topMenuBtn,
|
||||||
navigate,
|
navigate,
|
||||||
onFindChannel: () => openChannelFinderModal({ navigate }),
|
onFindChannel: () => openChannelFinderModal({ navigate }),
|
||||||
|
onSubscribeChannel: () => openSimpleSubscribeModal({
|
||||||
|
kind: 'channel',
|
||||||
|
kindLabel: 'Добавить канал',
|
||||||
|
submitLabel: 'Добавить',
|
||||||
|
onSuccess: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
topBarRight.append(topMenuBtn);
|
topBarLeft.append(backBtn, topTitle);
|
||||||
topBarEl.append(topBarLeft, topTitle, topBarRight);
|
topBarRight.append(findChannelBtn, createInMyBtn, topMenuBtn);
|
||||||
|
topBarEl.append(topBarLeft, topBarRight);
|
||||||
const bottomCta = document.createElement('button');
|
|
||||||
bottomCta.type = 'button';
|
|
||||||
|
|
||||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
@@ -1425,21 +1411,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||||
|
findChannelBtn.style.display = '';
|
||||||
|
createInMyBtn.style.display = '';
|
||||||
topMenuBtn.style.display = '';
|
topMenuBtn.style.display = '';
|
||||||
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
|
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
chrome?.setTopbar(topBarEl);
|
chrome?.setTopbar(topBarEl);
|
||||||
screen.append(contentEl, bottomCta);
|
screen.append(contentEl);
|
||||||
|
|
||||||
if (createSuccessFlash) {
|
if (createSuccessFlash) {
|
||||||
showToast(createSuccessFlash);
|
showToast(createSuccessFlash);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBottomCta({ button: bottomCta });
|
|
||||||
|
|
||||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||||
rerenderList();
|
rerenderList();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
@@ -29,55 +28,10 @@ import {
|
|||||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
|
|
||||||
function createChatHeaderParts(login) {
|
|
||||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
|
||||||
|
|
||||||
const avatarSlot = document.createElement('span');
|
|
||||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
|
||||||
const initialAvatar = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
size: 'small',
|
|
||||||
className: 'chat-header-avatar',
|
|
||||||
title: cleanLogin,
|
|
||||||
});
|
|
||||||
avatarSlot.append(initialAvatar);
|
|
||||||
|
|
||||||
const loginEl = document.createElement('span');
|
|
||||||
loginEl.className = 'chat-header-login';
|
|
||||||
loginEl.setAttribute('role', 'heading');
|
|
||||||
loginEl.setAttribute('aria-level', '1');
|
|
||||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
|
||||||
loginEl.textContent = cleanLogin;
|
|
||||||
|
|
||||||
void loadProfileSnapshot(cleanLogin)
|
|
||||||
.then((snapshot) => {
|
|
||||||
if (!avatarSlot.isConnected) return;
|
|
||||||
const upgradedAvatar = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
firstName: String(snapshot?.firstName || '').trim(),
|
|
||||||
lastName: String(snapshot?.lastName || '').trim(),
|
|
||||||
avatar: snapshot?.avatar?.txId
|
|
||||||
? {
|
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
size: 'small',
|
|
||||||
className: 'chat-header-avatar',
|
|
||||||
title: cleanLogin,
|
|
||||||
});
|
|
||||||
avatarSlot.replaceChildren(upgradedAvatar);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
return { centerNode: loginEl, avatarSlot };
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@@ -953,6 +907,10 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||||
|
const scrollToBottomControl = attachScrollToBottomButton({
|
||||||
|
scrollContainer: () => boundScrollContainer || wrap,
|
||||||
|
});
|
||||||
|
|
||||||
const historyLoader = document.createElement('div');
|
const historyLoader = document.createElement('div');
|
||||||
historyLoader.className = 'dm-history-loader';
|
historyLoader.className = 'dm-history-loader';
|
||||||
historyLoader.hidden = true;
|
historyLoader.hidden = true;
|
||||||
@@ -966,9 +924,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const log = document.createElement('div');
|
const log = document.createElement('div');
|
||||||
log.className = 'messages-log dm-messages-log';
|
log.className = 'messages-log dm-messages-log';
|
||||||
|
|
||||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
chrome?.setTopbar(
|
||||||
const chatHeader = renderHeader({
|
renderHeader({
|
||||||
centerNode: chatHeaderParts.centerNode,
|
title: `Чат с ${contact.name}`,
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{
|
{
|
||||||
@@ -979,7 +937,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
onClick: () => handleStartCall('audio'),
|
onClick: () => handleStartCall('audio'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconNode: createOverflowDots(),
|
label: '⋮',
|
||||||
title: 'Действия чата',
|
title: 'Действия чата',
|
||||||
ariaLabel: 'Открыть меню действий чата',
|
ariaLabel: 'Открыть меню действий чата',
|
||||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||||
@@ -1036,9 +994,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
}),
|
||||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
);
|
||||||
chrome?.setTopbar(chatHeader);
|
|
||||||
|
|
||||||
if (!isKnownContact) {
|
if (!isKnownContact) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -1606,6 +1563,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
void loadHistoryPage({ preserveScroll: true });
|
void loadHistoryPage({ preserveScroll: true });
|
||||||
});
|
});
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
scrollToBottomControl.cleanup();
|
||||||
hideUnreadSeparator({ rerender: false });
|
hideUnreadSeparator({ rerender: false });
|
||||||
stopAllTwemojiAnimations();
|
stopAllTwemojiAnimations();
|
||||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
getChatMessages,
|
||||||
isSessionInvalidError,
|
isSessionInvalidError,
|
||||||
normalizeDmChatId,
|
normalizeDmChatId,
|
||||||
setContacts,
|
setContacts,
|
||||||
state,
|
state,
|
||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
|
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const PREVIEW_MAX_LEN = 200;
|
|
||||||
const SVG_CHEVRON = `
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M9 6l6 6-6 6"></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
|
||||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
|
||||||
const dmAvatarSnapshotCache = new Map();
|
const dmAvatarSnapshotCache = new Map();
|
||||||
const dmAvatarPendingByLogin = new Map();
|
const dmAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
const RELATION_ORDER = new Map([
|
|
||||||
['close_friend', 0],
|
|
||||||
['contact', 1],
|
|
||||||
['none', 2],
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function loadDmAvatarSnapshot(login) {
|
async function loadDmAvatarSnapshot(login) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (!cleanLogin) return null;
|
if (!cleanLogin) return null;
|
||||||
@@ -50,14 +37,13 @@ async function loadDmAvatarSnapshot(login) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDmAvatar(login, { className = '' } = {}) {
|
function createDmAvatar(login) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
className,
|
|
||||||
});
|
});
|
||||||
if (!cleanLogin) return avatarEl;
|
if (!cleanLogin) return avatarEl;
|
||||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
@@ -72,7 +58,6 @@ function createDmAvatar(login, { className = '' } = {}) {
|
|||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
className,
|
|
||||||
});
|
});
|
||||||
upgraded.classList.add('avatar');
|
upgraded.classList.add('avatar');
|
||||||
avatarEl.replaceWith(upgraded);
|
avatarEl.replaceWith(upgraded);
|
||||||
@@ -80,72 +65,10 @@ function createDmAvatar(login, { className = '' } = {}) {
|
|||||||
return avatarEl;
|
return avatarEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRelationFlag(value) {
|
function resolveLastMessagePreview(text = '') {
|
||||||
const clean = String(value || '').trim().toLowerCase();
|
const parsed = parseDmTechBlocks(String(text || ''));
|
||||||
if (clean === 'close_friend' || clean === 'contact') return clean;
|
const display = String(parsed.displayText || '').trim();
|
||||||
return 'none';
|
return display || '';
|
||||||
}
|
|
||||||
|
|
||||||
function relationOrder(flag) {
|
|
||||||
return RELATION_ORDER.get(normalizeRelationFlag(flag)) ?? 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relationLabel(flag) {
|
|
||||||
switch (normalizeRelationFlag(flag)) {
|
|
||||||
case 'close_friend':
|
|
||||||
return 'близкий друг';
|
|
||||||
case 'contact':
|
|
||||||
return 'контакт';
|
|
||||||
default:
|
|
||||||
return 'не в контактах';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
|
||||||
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
|
||||||
if (!normalized) return '';
|
|
||||||
if (normalized.length <= maxLen) return normalized;
|
|
||||||
return `${normalized.slice(0, maxLen - 1)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveDialogPreview(dialog) {
|
|
||||||
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
|
|
||||||
if (!blobB64) return 'Диалог пока пуст.';
|
|
||||||
|
|
||||||
const cacheKey = [
|
|
||||||
blobB64,
|
|
||||||
String(state.session.login || '').trim().toLowerCase(),
|
|
||||||
String(state.session.storagePwdInMemory || '').trim(),
|
|
||||||
].join('|');
|
|
||||||
|
|
||||||
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
|
|
||||||
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
|
|
||||||
|
|
||||||
const pending = (async () => {
|
|
||||||
try {
|
|
||||||
const parsed = authService.parseSignedMessageBlob(blobB64);
|
|
||||||
const decrypted = await authService.decryptSignedMessageContent({
|
|
||||||
parsed,
|
|
||||||
blobB64,
|
|
||||||
login: state.session.login,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
const parsedText = parseDmTechBlocks(String(decrypted?.text || ''));
|
|
||||||
const display = clipPreviewText(String(parsedText.displayText || '').trim());
|
|
||||||
const result = display || 'Сообщение';
|
|
||||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
const fallback = 'Сообщение недоступно';
|
|
||||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
|
|
||||||
return fallback;
|
|
||||||
} finally {
|
|
||||||
DM_BLOB_PREVIEW_PENDING.delete(cacheKey);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
DM_BLOB_PREVIEW_PENDING.set(cacheKey, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChatRowTime(ts) {
|
function formatChatRowTime(ts) {
|
||||||
@@ -160,39 +83,51 @@ function formatChatRowTime(ts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compareChatRows(a, b) {
|
function compareChatRows(a, b) {
|
||||||
const timeA = Number(a?.lastMessageTimeMs || 0);
|
const timeA = Number(a?.lastTimeMs || 0);
|
||||||
const timeB = Number(b?.lastMessageTimeMs || 0);
|
const timeB = Number(b?.lastTimeMs || 0);
|
||||||
if (timeA !== timeB) return timeB - timeA;
|
if (timeA !== timeB) return timeB - timeA;
|
||||||
const nameA = String(a?.peerLogin || '').toLowerCase();
|
const nameA = String(a?.name || '').toLowerCase();
|
||||||
const nameB = String(b?.peerLogin || '').toLowerCase();
|
const nameB = String(b?.name || '').toLowerCase();
|
||||||
return nameA.localeCompare(nameB, 'ru');
|
return nameA.localeCompare(nameB, 'ru');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
||||||
|
|
||||||
export function render({ navigate, chrome }) {
|
export function render({ navigate, chrome }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'stack dm-screen dm-list-screen';
|
||||||
|
const login = String(state.session.login || '').trim();
|
||||||
|
|
||||||
const head = document.createElement('header');
|
const head = document.createElement('header');
|
||||||
head.className = 'dm-head';
|
head.className = 'dm-head';
|
||||||
head.innerHTML = `
|
head.innerHTML = `
|
||||||
<div class="dm-head-brand" aria-hidden="true"></div>
|
<div class="dm-head-brand">
|
||||||
<h1 class="dm-head-title">Чаты</h1>
|
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||||
|
<div class="dm-head-id">
|
||||||
|
<span class="dm-head-name"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="dm-head-title">Контакты</h1>
|
||||||
<div class="dm-head-menu-wrap">
|
<div class="dm-head-menu-wrap">
|
||||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false">
|
||||||
|
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
</button>
|
||||||
<div class="dm-head-menu" role="menu" hidden>
|
<div class="dm-head-menu" role="menu" hidden>
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="11" cy="11" r="6.5"></circle>
|
<circle cx="11" cy="11" r="6.5"></circle>
|
||||||
<path d="M16 16l4 4"></path>
|
<path d="M16 16l4 4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Поиск пользователей</span>
|
<span>Поиск контактов</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
const headName = head.querySelector('.dm-head-name');
|
||||||
menuButton?.append(createOverflowDots());
|
if (headName) headName.textContent = login;
|
||||||
|
|
||||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||||
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||||
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
// header bounds. A dropdown overflowing below it can look visible but clicks may
|
||||||
@@ -200,15 +135,11 @@ export function render({ navigate, chrome }) {
|
|||||||
menuTemplate?.remove();
|
menuTemplate?.remove();
|
||||||
|
|
||||||
let menuPortal = null;
|
let menuPortal = null;
|
||||||
let menuBackdrop = null;
|
|
||||||
|
|
||||||
const closeHeadMenu = () => {
|
const closeHeadMenu = () => {
|
||||||
menuPortal?.remove();
|
menuPortal?.remove();
|
||||||
menuBackdrop?.remove();
|
|
||||||
menuPortal = null;
|
menuPortal = null;
|
||||||
menuBackdrop = null;
|
|
||||||
menuButton?.setAttribute('aria-expanded', 'false');
|
menuButton?.setAttribute('aria-expanded', 'false');
|
||||||
menuButton?.classList.remove('menu-open-pressed');
|
|
||||||
menuWrap?.classList.remove('is-open');
|
menuWrap?.classList.remove('is-open');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,16 +147,10 @@ export function render({ navigate, chrome }) {
|
|||||||
if (!menuPortal || !menuButton) return;
|
if (!menuPortal || !menuButton) return;
|
||||||
const rect = menuButton.getBoundingClientRect();
|
const rect = menuButton.getBoundingClientRect();
|
||||||
const margin = 10;
|
const margin = 10;
|
||||||
const menuRect = menuPortal.getBoundingClientRect();
|
const menuWidth = menuPortal.offsetWidth || 206;
|
||||||
const menuWidth = menuRect.width || menuPortal.offsetWidth || 190;
|
|
||||||
const menuHeight = menuRect.height || menuPortal.offsetHeight || 56;
|
|
||||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||||
const below = rect.bottom + 7;
|
|
||||||
const top = below + menuHeight <= window.innerHeight - margin
|
|
||||||
? below
|
|
||||||
: Math.max(margin, rect.top - menuHeight - 7);
|
|
||||||
menuPortal.style.left = `${Math.round(left)}px`;
|
menuPortal.style.left = `${Math.round(left)}px`;
|
||||||
menuPortal.style.top = `${Math.round(top)}px`;
|
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openHeadMenu = () => {
|
const openHeadMenu = () => {
|
||||||
@@ -235,7 +160,11 @@ export function render({ navigate, chrome }) {
|
|||||||
portal.setAttribute('role', 'menu');
|
portal.setAttribute('role', 'menu');
|
||||||
portal.innerHTML = `
|
portal.innerHTML = `
|
||||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
|
||||||
<span>Поиск пользователей</span>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5"></circle>
|
||||||
|
<path d="M16 16l4 4"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Поиск контактов</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -247,17 +176,11 @@ export function render({ navigate, chrome }) {
|
|||||||
});
|
});
|
||||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
|
||||||
const backdrop = document.createElement('div');
|
document.body.append(portal);
|
||||||
backdrop.className = 'ui-menu-dim-layer';
|
|
||||||
backdrop.addEventListener('click', closeHeadMenu);
|
|
||||||
document.body.append(backdrop, portal);
|
|
||||||
menuBackdrop = backdrop;
|
|
||||||
menuPortal = portal;
|
menuPortal = portal;
|
||||||
menuButton.setAttribute('aria-expanded', 'true');
|
menuButton.setAttribute('aria-expanded', 'true');
|
||||||
menuButton.classList.add('menu-open-pressed');
|
|
||||||
menuWrap?.classList.add('is-open');
|
menuWrap?.classList.add('is-open');
|
||||||
positionHeadMenu();
|
positionHeadMenu();
|
||||||
requestAnimationFrame(positionHeadMenu);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
menuButton?.addEventListener('click', (event) => {
|
menuButton?.addEventListener('click', (event) => {
|
||||||
@@ -283,17 +206,16 @@ export function render({ navigate, chrome }) {
|
|||||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||||
|
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'dm-divider';
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack dm-list';
|
list.className = 'stack dm-list';
|
||||||
|
|
||||||
function renderRow(item) {
|
function renderRow(item) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
const relationFlag = normalizeRelationFlag(item.relationFlag);
|
const avatarEl = createDmAvatar(item.id);
|
||||||
const relationBadge = relationFlag === 'none'
|
|
||||||
? 'не в контактах'
|
|
||||||
: relationLabel(relationFlag);
|
|
||||||
const avatarEl = createDmAvatar(item.peerLogin);
|
|
||||||
avatarEl.classList.add('avatar');
|
avatarEl.classList.add('avatar');
|
||||||
const avatarWrap = document.createElement('div');
|
const avatarWrap = document.createElement('div');
|
||||||
avatarWrap.className = 'dm-av dm-av--default';
|
avatarWrap.className = 'dm-av dm-av--default';
|
||||||
@@ -302,14 +224,14 @@ function renderRow(item) {
|
|||||||
<div class="dm-row-main">
|
<div class="dm-row-main">
|
||||||
<div class="dm-row-titleline dm-row-titlewrap">
|
<div class="dm-row-titleline dm-row-titlewrap">
|
||||||
<strong class="dm-row-title"></strong>
|
<strong class="dm-row-title"></strong>
|
||||||
<span class="dm-contact-note">${relationBadge}</span>
|
${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="dm-row-last-message"></p>
|
<p class="dm-row-last-message"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dm-row-meta-col">
|
<div class="dm-row-meta-col">
|
||||||
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||||
<div class="dm-row-meta-line">
|
<div class="dm-row-meta-line">
|
||||||
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
${item.time ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||||
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,15 +239,11 @@ function renderRow(item) {
|
|||||||
const titleEl = row.querySelector('.dm-row-title');
|
const titleEl = row.querySelector('.dm-row-title');
|
||||||
const previewEl = row.querySelector('.dm-row-last-message');
|
const previewEl = row.querySelector('.dm-row-last-message');
|
||||||
const timeEl = row.querySelector('.dm-row-time');
|
const timeEl = row.querySelector('.dm-row-time');
|
||||||
if (titleEl) titleEl.textContent = String(item.peerLogin || '');
|
if (titleEl) titleEl.textContent = String(item.name || '');
|
||||||
if (previewEl) previewEl.textContent = 'Загрузка…';
|
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
|
||||||
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
|
if (timeEl) timeEl.textContent = String(item.time || '');
|
||||||
row.prepend(avatarWrap);
|
row.prepend(avatarWrap);
|
||||||
void resolveDialogPreview(item).then((text) => {
|
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`));
|
||||||
if (!previewEl?.isConnected) return;
|
|
||||||
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
|
|
||||||
});
|
|
||||||
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,64 +257,62 @@ function renderRow(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await authService.listContacts();
|
const relations = await loadCurrentRelations();
|
||||||
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : [];
|
const contacts = relations.outContacts || [];
|
||||||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
|
||||||
setContacts(contacts);
|
setContacts(contacts);
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
const byPeer = new Map();
|
const contactRows = contacts.map((login) => {
|
||||||
dialogs.forEach((dialog) => {
|
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
||||||
const peerLogin = String(dialog?.peerLogin || '').trim();
|
const canonicalLogin = normalizeDmChatId(login);
|
||||||
if (!peerLogin) return;
|
const chat = getChatMessages(canonicalLogin);
|
||||||
const key = peerLogin.toLowerCase();
|
const lastChat = chat[chat.length - 1];
|
||||||
const relationFlag = normalizeRelationFlag(dialog?.relationFlag);
|
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||||
const next = {
|
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||||
id: peerLogin,
|
return {
|
||||||
peerLogin,
|
id: canonicalLogin,
|
||||||
relationFlag,
|
name: preview?.name || login,
|
||||||
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
|
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||||
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
|
time: formatChatRowTime(lastTimeMs),
|
||||||
unreadCount: Number(dialog?.unreadCount || 0),
|
unread,
|
||||||
hasDialog: Boolean(dialog?.hasDialog),
|
notInContacts: false,
|
||||||
|
lastTimeMs,
|
||||||
};
|
};
|
||||||
const current = byPeer.get(key);
|
|
||||||
if (!current) {
|
|
||||||
byPeer.set(key, next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentRank = relationOrder(current.relationFlag);
|
|
||||||
const nextRank = relationOrder(relationFlag);
|
|
||||||
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
|
|
||||||
byPeer.set(key, next);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
const allChatIds = Object.keys(state.chats || {})
|
||||||
const orderA = relationOrder(a.relationFlag);
|
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
|
||||||
const orderB = relationOrder(b.relationFlag);
|
.filter((id) => (getChatMessages(id) || []).length > 0);
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
|
||||||
return compareChatRows(a, b);
|
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
|
||||||
|
const extraRows = allChatIds
|
||||||
|
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
|
||||||
|
.map((login) => {
|
||||||
|
const chat = getChatMessages(login);
|
||||||
|
const lastChat = chat[chat.length - 1];
|
||||||
|
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||||
|
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||||
|
return {
|
||||||
|
id: login,
|
||||||
|
name: login,
|
||||||
|
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
||||||
|
time: formatChatRowTime(lastTimeMs),
|
||||||
|
unread,
|
||||||
|
notInContacts: true,
|
||||||
|
lastTimeMs,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rows = [...contactRows, ...extraRows].sort(compareChatRows);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'card meta-muted';
|
empty.className = 'card meta-muted';
|
||||||
empty.textContent = 'Пока нет диалогов';
|
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
||||||
list.append(empty);
|
list.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dividerInserted = false;
|
rows.forEach((item) => list.append(renderRow(item)));
|
||||||
rows.forEach((item) => {
|
|
||||||
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
|
|
||||||
const divider = document.createElement('div');
|
|
||||||
divider.className = 'dm-divider';
|
|
||||||
list.append(divider);
|
|
||||||
dividerInserted = true;
|
|
||||||
}
|
|
||||||
list.append(renderRow(item));
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSessionInvalidError(error)) {
|
if (isSessionInvalidError(error)) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
@@ -436,7 +352,7 @@ function renderRow(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chrome?.setTopbar(head);
|
chrome?.setTopbar(head);
|
||||||
screen.append(list);
|
screen.append(divider, list);
|
||||||
loadList();
|
loadList();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
@@ -216,7 +215,7 @@ function buildGraphModel(graph, centerLogin) {
|
|||||||
let persistedCenterLogin = '';
|
let persistedCenterLogin = '';
|
||||||
let persistedCenterHistory = [];
|
let persistedCenterHistory = [];
|
||||||
|
|
||||||
export function render({ navigate, route, chrome } = {}) {
|
export function render({ navigate, route }) {
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
@@ -283,7 +282,10 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
else window.history.replaceState({}, '', nextPath);
|
else window.history.replaceState({}, '', nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBackButtonState(backBtn) {
|
||||||
|
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||||
|
backBtn.disabled = centerHistory.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function openSearchModal() {
|
function openSearchModal() {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@@ -488,119 +490,36 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||||
|
|
||||||
persistHistory();
|
persistHistory();
|
||||||
|
setBackButtonState(backBtnEl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== loadSeq) return;
|
if (requestId !== loadSeq) return;
|
||||||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let topMenuPortal = null;
|
|
||||||
let topMenuBackdrop = null;
|
|
||||||
let topMenuButton = null;
|
|
||||||
|
|
||||||
const closeTopMenu = () => {
|
|
||||||
topMenuPortal?.remove();
|
|
||||||
topMenuBackdrop?.remove();
|
|
||||||
topMenuPortal = null;
|
|
||||||
topMenuBackdrop = null;
|
|
||||||
topMenuButton?.setAttribute('aria-expanded', 'false');
|
|
||||||
topMenuButton?.classList.remove('menu-open-pressed');
|
|
||||||
};
|
|
||||||
|
|
||||||
const positionTopMenu = () => {
|
|
||||||
if (!topMenuPortal || !topMenuButton) return;
|
|
||||||
const rect = topMenuButton.getBoundingClientRect();
|
|
||||||
const margin = 10;
|
|
||||||
const menuRect = topMenuPortal.getBoundingClientRect();
|
|
||||||
const menuWidth = menuRect.width || topMenuPortal.offsetWidth || 190;
|
|
||||||
const menuHeight = menuRect.height || topMenuPortal.offsetHeight || 56;
|
|
||||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
|
||||||
const below = rect.bottom + 7;
|
|
||||||
const top = below + menuHeight <= window.innerHeight - margin
|
|
||||||
? below
|
|
||||||
: Math.max(margin, rect.top - menuHeight - 7);
|
|
||||||
topMenuPortal.style.left = `${Math.round(left)}px`;
|
|
||||||
topMenuPortal.style.top = `${Math.round(top)}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openTopMenu = () => {
|
|
||||||
if (!topMenuButton || topMenuPortal) return;
|
|
||||||
const portal = document.createElement('div');
|
|
||||||
portal.className = 'dm-head-menu dm-head-menu--portal network-head-menu';
|
|
||||||
portal.setAttribute('role', 'menu');
|
|
||||||
portal.innerHTML = `
|
|
||||||
<button type="button" class="dm-head-menu-item network-head-menu-item" role="menuitem" data-action="find-person">
|
|
||||||
<span>Найти человека</span>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
portal.querySelector('[data-action="find-person"]')?.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
closeTopMenu();
|
|
||||||
openSearchModal();
|
|
||||||
});
|
|
||||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
|
||||||
const backdrop = document.createElement('div');
|
|
||||||
backdrop.className = 'ui-menu-dim-layer';
|
|
||||||
backdrop.addEventListener('click', closeTopMenu);
|
|
||||||
document.body.append(backdrop, portal);
|
|
||||||
topMenuBackdrop = backdrop;
|
|
||||||
topMenuPortal = portal;
|
|
||||||
topMenuButton.setAttribute('aria-expanded', 'true');
|
|
||||||
topMenuButton.classList.add('menu-open-pressed');
|
|
||||||
positionTopMenu();
|
|
||||||
requestAnimationFrame(positionTopMenu);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTopMenu = (event) => {
|
|
||||||
event?.preventDefault?.();
|
|
||||||
event?.stopPropagation?.();
|
|
||||||
if (topMenuPortal) closeTopMenu();
|
|
||||||
else openTopMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: 'Связи',
|
title: 'Связи',
|
||||||
rightActions: [
|
leftAction: {
|
||||||
{
|
label: '←',
|
||||||
iconNode: createOverflowDots(),
|
onClick: () => {
|
||||||
className: 'network-top-more-btn',
|
if (!centerHistory.length) return;
|
||||||
title: 'Ещё действия',
|
const prev = centerHistory.pop();
|
||||||
ariaLabel: 'Меню связей',
|
if (!prev) {
|
||||||
onClick: toggleTopMenu,
|
setBackButtonState(backBtnEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void load(prev, { pushHistory: false });
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
rightActions: [
|
||||||
|
{ label: 'Найти', onClick: openSearchModal },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
// «Связи» используют тот же общий topbar, что и остальные страницы.
|
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||||||
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
|
setBackButtonState(backBtnEl);
|
||||||
header.classList.add('network-topbar');
|
|
||||||
topMenuButton = header.querySelector('.network-top-more-btn');
|
|
||||||
topMenuButton?.setAttribute('aria-haspopup', 'menu');
|
|
||||||
topMenuButton?.setAttribute('aria-expanded', 'false');
|
|
||||||
|
|
||||||
const onTopMenuOutsideClick = (event) => {
|
|
||||||
if (!topMenuPortal) return;
|
|
||||||
if (topMenuPortal.contains(event.target) || topMenuButton?.contains(event.target)) return;
|
|
||||||
closeTopMenu();
|
|
||||||
};
|
|
||||||
const onTopMenuKeydown = (event) => {
|
|
||||||
if (event.key !== 'Escape' || !topMenuPortal) return;
|
|
||||||
closeTopMenu();
|
|
||||||
topMenuButton?.focus();
|
|
||||||
};
|
|
||||||
const onTopMenuViewportChange = () => positionTopMenu();
|
|
||||||
document.addEventListener('click', onTopMenuOutsideClick);
|
|
||||||
document.addEventListener('keydown', onTopMenuKeydown);
|
|
||||||
window.addEventListener('resize', onTopMenuViewportChange, { passive: true });
|
|
||||||
window.addEventListener('scroll', onTopMenuViewportChange, { passive: true, capture: true });
|
|
||||||
|
|
||||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeTopMenu();
|
|
||||||
document.removeEventListener('click', onTopMenuOutsideClick);
|
|
||||||
document.removeEventListener('keydown', onTopMenuKeydown);
|
|
||||||
window.removeEventListener('resize', onTopMenuViewportChange);
|
|
||||||
window.removeEventListener('scroll', onTopMenuViewportChange, true);
|
|
||||||
if (engine) engine.destroy();
|
if (engine) engine.destroy();
|
||||||
engine = null;
|
engine = null;
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
@@ -623,10 +542,11 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
window.setTimeout(() => openSearchModal(), 0);
|
window.setTimeout(() => openSearchModal(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setBackButtonState(backBtnEl);
|
||||||
|
|
||||||
// Панель фильтров слоёв (оверлей под шапкой)
|
// Панель фильтров слоёв (оверлей под шапкой)
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'fg-filter-bar app-top-tabs';
|
filterBar.className = 'fg-filter-bar';
|
||||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
@@ -640,8 +560,8 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
filterBar.append(chip);
|
filterBar.append(chip);
|
||||||
});
|
});
|
||||||
|
|
||||||
chrome?.setTopbar(header);
|
header.classList.add('network-header-overlay');
|
||||||
stage.append(board, filterBar);
|
stage.append(board, header, filterBar);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ async function enrichItem(item, activeTab) {
|
|||||||
|
|
||||||
function renderEmpty(activeTab) {
|
function renderEmpty(activeTab) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack notification-empty-state';
|
card.className = 'card stack';
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('strong');
|
||||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||||
const text = document.createElement('p');
|
const text = document.createElement('p');
|
||||||
@@ -304,22 +304,10 @@ export function render({ navigate, chrome } = {}) {
|
|||||||
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
|
||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
tabs.className = 'tabs';
|
||||||
tabs.innerHTML = `
|
tabs.innerHTML = `
|
||||||
<button
|
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
|
||||||
type="button"
|
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</button>
|
||||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
|
||||||
data-tab="replies"
|
|
||||||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
|
||||||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
|
||||||
>Ответы</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
|
||||||
data-tab="events"
|
|
||||||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
|
||||||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
|
||||||
>События</button>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
@@ -359,31 +347,13 @@ export function render({ navigate, chrome } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveNotificationTab(nextTab) {
|
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
|
||||||
state.notificationsTab = normalizedTab;
|
|
||||||
|
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
|
||||||
const selected = node.dataset.tab === normalizedTab;
|
|
||||||
node.classList.toggle('is-active', selected);
|
|
||||||
node.dataset.selected = selected ? 'true' : 'false';
|
|
||||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
|
||||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
|
||||||
setActiveNotificationTab(state.notificationsTab);
|
|
||||||
|
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
const nextTab = String(btn.dataset.tab || 'replies');
|
||||||
if (state.notificationsTab === nextTab) {
|
if (state.notificationsTab === nextTab) return;
|
||||||
setActiveNotificationTab(nextTab);
|
state.notificationsTab = nextTab;
|
||||||
return;
|
tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
|
||||||
}
|
btn.classList.add('active');
|
||||||
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@@ -102,27 +101,21 @@ export function render({ navigate, chrome }) {
|
|||||||
const topbar = document.createElement('header');
|
const topbar = document.createElement('header');
|
||||||
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
|
||||||
topbar.innerHTML = `
|
topbar.innerHTML = `
|
||||||
<div class="header-left" aria-hidden="true"></div>
|
|
||||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
|
||||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
||||||
|
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||||
profileMenuButton?.append(createOverflowDots());
|
|
||||||
let profileMenuPortal = null;
|
let profileMenuPortal = null;
|
||||||
let profileMenuBackdrop = null;
|
|
||||||
|
|
||||||
const closeProfileMenu = () => {
|
const closeProfileMenu = () => {
|
||||||
profileMenuPortal?.remove();
|
profileMenuPortal?.remove();
|
||||||
profileMenuBackdrop?.remove();
|
|
||||||
profileMenuPortal = null;
|
profileMenuPortal = null;
|
||||||
profileMenuBackdrop = null;
|
|
||||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||||
profileMenuButton?.classList.remove('menu-open-pressed');
|
|
||||||
profileMenuWrap?.classList.remove('is-open');
|
profileMenuWrap?.classList.remove('is-open');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,16 +123,10 @@ export function render({ navigate, chrome }) {
|
|||||||
if (!profileMenuPortal || !profileMenuButton) return;
|
if (!profileMenuPortal || !profileMenuButton) return;
|
||||||
const rect = profileMenuButton.getBoundingClientRect();
|
const rect = profileMenuButton.getBoundingClientRect();
|
||||||
const margin = 10;
|
const margin = 10;
|
||||||
const menuRect = profileMenuPortal.getBoundingClientRect();
|
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||||
const menuWidth = menuRect.width || profileMenuPortal.offsetWidth || 210;
|
|
||||||
const menuHeight = menuRect.height || profileMenuPortal.offsetHeight || 144;
|
|
||||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||||
const below = rect.bottom + 7;
|
|
||||||
const top = below + menuHeight <= window.innerHeight - margin
|
|
||||||
? below
|
|
||||||
: Math.max(margin, rect.top - menuHeight - 7);
|
|
||||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||||
profileMenuPortal.style.top = `${Math.round(top)}px`;
|
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openProfileMenu = () => {
|
const openProfileMenu = () => {
|
||||||
@@ -149,12 +136,15 @@ export function render({ navigate, chrome }) {
|
|||||||
portal.setAttribute('role', 'menu');
|
portal.setAttribute('role', 'menu');
|
||||||
portal.innerHTML = `
|
portal.innerHTML = `
|
||||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||||
|
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||||
<span>Редактировать профиль</span>
|
<span>Редактировать профиль</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||||
|
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||||
<span>Кошелёк</span>
|
<span>Кошелёк</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||||
|
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||||
<span>Настройки</span>
|
<span>Настройки</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
@@ -168,17 +158,11 @@ export function render({ navigate, chrome }) {
|
|||||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
|
||||||
const backdrop = document.createElement('div');
|
document.body.append(portal);
|
||||||
backdrop.className = 'ui-menu-dim-layer';
|
|
||||||
backdrop.addEventListener('click', closeProfileMenu);
|
|
||||||
document.body.append(backdrop, portal);
|
|
||||||
profileMenuBackdrop = backdrop;
|
|
||||||
profileMenuPortal = portal;
|
profileMenuPortal = portal;
|
||||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||||
profileMenuButton.classList.add('menu-open-pressed');
|
|
||||||
profileMenuWrap?.classList.add('is-open');
|
profileMenuWrap?.classList.add('is-open');
|
||||||
positionProfileMenu();
|
positionProfileMenu();
|
||||||
requestAnimationFrame(positionProfileMenu);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
profileMenuButton?.addEventListener('click', (event) => {
|
profileMenuButton?.addEventListener('click', (event) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, clearAuthMessages, state } from '../state.js';
|
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
checkLoginExistsOnSolana,
|
checkLoginExistsOnSolana,
|
||||||
@@ -426,7 +426,13 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Зарегистрироваться',
|
title: 'Зарегистрироваться',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: () => {
|
||||||
|
resetRegistrationFlow();
|
||||||
|
navigate('start-view');
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
form,
|
form,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
resetRegistrationFlow,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
@@ -102,7 +103,10 @@ export function render({ navigate }) {
|
|||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'ghost-btn';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
cancelButton.textContent = 'Отмена';
|
cancelButton.textContent = 'Отмена';
|
||||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
cancelButton.addEventListener('click', () => {
|
||||||
|
resetRegistrationFlow();
|
||||||
|
navigate('start-view');
|
||||||
|
});
|
||||||
|
|
||||||
const okButton = document.createElement('button');
|
const okButton = document.createElement('button');
|
||||||
okButton.className = 'primary-btn';
|
okButton.className = 'primary-btn';
|
||||||
@@ -190,7 +194,13 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Сохранение ключей',
|
title: 'Сохранение ключей',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: {
|
||||||
|
label: '←',
|
||||||
|
onClick: () => {
|
||||||
|
resetRegistrationFlow();
|
||||||
|
navigate('start-view');
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
card,
|
card,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
resetRegistrationFlow,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
|
|||||||
replacement.addEventListener('click', () => {
|
replacement.addEventListener('click', () => {
|
||||||
stageClosed = true;
|
stageClosed = true;
|
||||||
stopTimers();
|
stopTimers();
|
||||||
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
headerBackButton.replaceWith(replacement);
|
headerBackButton.replaceWith(replacement);
|
||||||
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
|
|||||||
replacement.addEventListener('click', () => {
|
replacement.addEventListener('click', () => {
|
||||||
loginCompleted = true;
|
loginCompleted = true;
|
||||||
stopAutoLogin();
|
stopAutoLogin();
|
||||||
|
resetRegistrationFlow();
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
});
|
});
|
||||||
headerBackButton.replaceWith(replacement);
|
headerBackButton.replaceWith(replacement);
|
||||||
|
|||||||
@@ -925,6 +925,14 @@ export async function refreshSessions() {
|
|||||||
return state.sessions;
|
return state.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetRegistrationFlow() {
|
||||||
|
const next = createInitialState();
|
||||||
|
state.registrationDraft = next.registrationDraft;
|
||||||
|
state.registrationHelp = next.registrationHelp;
|
||||||
|
state.registrationPayment = next.registrationPayment;
|
||||||
|
state.keyStorage = next.keyStorage;
|
||||||
|
}
|
||||||
|
|
||||||
function resetStateForSignedOut() {
|
function resetStateForSignedOut() {
|
||||||
const next = createInitialState({ withStoredSession: false });
|
const next = createInitialState({ withStoredSession: false });
|
||||||
state.chats = next.chats;
|
state.chats = next.chats;
|
||||||
|
|||||||
@@ -1,509 +0,0 @@
|
|||||||
/*
|
|
||||||
* Единый визуальный язык кнопок основного приложения:
|
|
||||||
* белое содержимое, без рамок и самостоятельной подложки.
|
|
||||||
*
|
|
||||||
* Исключения:
|
|
||||||
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
|
|
||||||
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
|
|
||||||
*/
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn),
|
|
||||||
:root a.primary-btn,
|
|
||||||
:root a.secondary-btn,
|
|
||||||
:root a.destructive-btn,
|
|
||||||
:root a.ghost-btn,
|
|
||||||
:root a.icon-btn,
|
|
||||||
:root a.text-btn {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: transparent !important;
|
|
||||||
background-image: none !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
backdrop-filter: none !important;
|
|
||||||
-webkit-backdrop-filter: none !important;
|
|
||||||
transition:
|
|
||||||
transform 90ms ease,
|
|
||||||
box-shadow 90ms ease,
|
|
||||||
background-color 90ms ease,
|
|
||||||
filter 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
|
||||||
:root a.primary-btn:hover,
|
|
||||||
:root a.secondary-btn:hover,
|
|
||||||
:root a.destructive-btn:hover,
|
|
||||||
:root a.ghost-btn:hover,
|
|
||||||
:root a.icon-btn:hover,
|
|
||||||
:root a.text-btn:hover {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
filter: brightness(1.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
|
|
||||||
* Эффект существует только пока кнопка физически нажата.
|
|
||||||
*/
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
|
||||||
:root a.primary-btn:active,
|
|
||||||
:root a.secondary-btn:active,
|
|
||||||
:root a.destructive-btn:active,
|
|
||||||
:root a.ghost-btn:active,
|
|
||||||
:root a.icon-btn:active,
|
|
||||||
:root a.text-btn:active {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: rgba(0, 0, 0, 0.12) !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
|
||||||
transform: translateY(1px) scale(0.97);
|
|
||||||
filter: brightness(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
|
|
||||||
:root a.primary-btn[aria-disabled='true'],
|
|
||||||
:root a.secondary-btn[aria-disabled='true'],
|
|
||||||
:root a.destructive-btn[aria-disabled='true'],
|
|
||||||
:root a.ghost-btn[aria-disabled='true'],
|
|
||||||
:root a.icon-btn[aria-disabled='true'],
|
|
||||||
:root a.text-btn[aria-disabled='true'] {
|
|
||||||
color: rgba(255, 255, 255, 0.42) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
transform: none !important;
|
|
||||||
filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
|
|
||||||
* Переключатель канала исключён: его ::after является функциональным бегунком.
|
|
||||||
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
|
|
||||||
*/
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
|
|
||||||
background: transparent !important;
|
|
||||||
background-image: none !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
|
|
||||||
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
|
|
||||||
*/
|
|
||||||
:root .toolbar-btn {
|
|
||||||
transition:
|
|
||||||
transform 90ms ease,
|
|
||||||
box-shadow 90ms ease,
|
|
||||||
background-color 90ms ease,
|
|
||||||
color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .toolbar-btn:active {
|
|
||||||
transform: translateY(1px) scale(0.96);
|
|
||||||
background: rgba(0, 0, 0, 0.14) !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 4px 10px rgba(0, 0, 0, 0.62),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
|
|
||||||
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
|
||||||
:root a.primary-btn:focus-visible,
|
|
||||||
:root a.secondary-btn:focus-visible,
|
|
||||||
:root a.destructive-btn:focus-visible,
|
|
||||||
:root a.ghost-btn:focus-visible,
|
|
||||||
:root a.icon-btn:focus-visible,
|
|
||||||
:root a.text-btn:focus-visible {
|
|
||||||
outline: 2px solid rgba(255, 255, 255, 0.62);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Уведомления: «Ответы / События».
|
|
||||||
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
|
|
||||||
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
|
|
||||||
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
|
|
||||||
*/
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: rgba(0, 0, 0, 0.18) !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 4px 11px rgba(0, 0, 0, 0.72),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
|
||||||
transform: translateY(1px) scale(0.965) !important;
|
|
||||||
filter: brightness(0.88) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
transform: none !important;
|
|
||||||
filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
|
|
||||||
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
|
|
||||||
*/
|
|
||||||
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
|
|
||||||
background: rgba(0, 0, 0, 0.12) !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
|
||||||
transform: translateY(1px) scale(0.97) !important;
|
|
||||||
filter: brightness(0.9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Верхний toolbar — отдельная цветовая роль: золотой текст и иконки.
|
|
||||||
* Это правило намеренно расположено после глобального белого button-rule. */
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
|
||||||
color: var(--app-topbar-gold) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Верхний toolbar: вместо золотого акцента — белые глифы с голубым ореолом.
|
|
||||||
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
|
|
||||||
color: #F7FBFF !important;
|
|
||||||
text-shadow:
|
|
||||||
0 0 5px rgba(92, 190, 255, 0.72),
|
|
||||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
|
||||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
|
|
||||||
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
|
|
||||||
color: #FFFFFF !important;
|
|
||||||
outline: none !important;
|
|
||||||
filter:
|
|
||||||
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
|
|
||||||
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
|
|
||||||
:root .dm-chat-input button.dm-emoji-btn,
|
|
||||||
:root .dm-chat-input button.dm-send-btn,
|
|
||||||
:root .dm-chat-input button.dm-edit-banner__close,
|
|
||||||
:root .dm-chat-input button.dm-emoji-btn:hover,
|
|
||||||
:root .dm-chat-input button.dm-send-btn:hover,
|
|
||||||
:root .dm-chat-input button.dm-edit-banner__close:hover,
|
|
||||||
:root .dm-chat-input button.dm-emoji-btn:focus,
|
|
||||||
:root .dm-chat-input button.dm-send-btn:focus,
|
|
||||||
:root .dm-chat-input button.dm-edit-banner__close:focus {
|
|
||||||
color: #F7FBFF !important;
|
|
||||||
text-shadow:
|
|
||||||
0 0 5px rgba(92, 190, 255, 0.72),
|
|
||||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
|
||||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Dropdown menu polish: persistent overflow press + text-only glowing rows ===== */
|
|
||||||
/* Menu rows stay visually transparent in every interaction state. The menu panel itself
|
|
||||||
* remains translucent, while each action is represented only by its glowing label. */
|
|
||||||
:root .dm-head-menu-item,
|
|
||||||
:root .dm-head-menu-item:hover,
|
|
||||||
:root .dm-head-menu-item:focus,
|
|
||||||
:root .dm-head-menu-item:focus-visible,
|
|
||||||
:root .dm-head-menu-item:active,
|
|
||||||
:root .channel-menu-item,
|
|
||||||
:root .channel-menu-item:hover,
|
|
||||||
:root .channel-menu-item:focus,
|
|
||||||
:root .channel-menu-item:focus-visible,
|
|
||||||
:root .channel-menu-item:active,
|
|
||||||
:root .channel-menu-item.destructive,
|
|
||||||
:root .channel-menu-item.destructive:hover,
|
|
||||||
:root .channel-menu-item.destructive:focus-visible,
|
|
||||||
:root .channel-menu-item.destructive:active {
|
|
||||||
color: #F7FBFF !important;
|
|
||||||
background: transparent !important;
|
|
||||||
background-image: none !important;
|
|
||||||
border: 0 !important;
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
transform: none !important;
|
|
||||||
text-shadow:
|
|
||||||
0 0 5px rgba(92, 190, 255, 0.72),
|
|
||||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
|
||||||
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.34)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover/focus is expressed only by a slightly stronger glyph glow, never a row plate. */
|
|
||||||
:root .dm-head-menu-item:hover,
|
|
||||||
:root .dm-head-menu-item:focus-visible,
|
|
||||||
:root .channel-menu-item:hover,
|
|
||||||
:root .channel-menu-item:focus-visible {
|
|
||||||
color: #FFFFFF !important;
|
|
||||||
text-shadow:
|
|
||||||
0 0 6px rgba(110, 205, 255, 0.86),
|
|
||||||
0 0 15px rgba(72, 145, 255, 0.46) !important;
|
|
||||||
filter: drop-shadow(0 0 5px rgba(92, 190, 255, 0.46)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep the overflow trigger visibly pressed for exactly as long as its menu is open. */
|
|
||||||
:root .topbar-slot .dm-head-menu-btn[aria-expanded='true'],
|
|
||||||
:root .topbar-slot .channels-top-more-btn[aria-expanded='true'],
|
|
||||||
:root .topbar-slot .network-top-more-btn[aria-expanded='true'],
|
|
||||||
:root .network-header-overlay .network-top-more-btn[aria-expanded='true'] {
|
|
||||||
color: #FFFFFF !important;
|
|
||||||
background: rgba(0, 0, 0, 0.12) !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
|
||||||
transform: translateY(1px) scale(0.97) !important;
|
|
||||||
text-shadow:
|
|
||||||
0 0 5px rgba(92, 190, 255, 0.72),
|
|
||||||
0 0 12px rgba(72, 145, 255, 0.34) !important;
|
|
||||||
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ===== Authoritative open-menu trigger state (2026-08-23) =====
|
|
||||||
* A dedicated JS class, not :active/:focus, owns the pressed state while a menu is open.
|
|
||||||
* Kept at the very end of the stylesheet so older compatibility rules cannot cancel it. */
|
|
||||||
:root .dm-head-menu-btn.menu-open-pressed,
|
|
||||||
:root .channels-top-more-btn.menu-open-pressed,
|
|
||||||
:root .network-top-more-btn.menu-open-pressed,
|
|
||||||
:root .topbar-slot .dm-head-menu-btn.menu-open-pressed,
|
|
||||||
:root .topbar-slot .channels-top-more-btn.menu-open-pressed,
|
|
||||||
:root .topbar-slot .network-top-more-btn.menu-open-pressed,
|
|
||||||
:root .network-header-overlay .network-top-more-btn.menu-open-pressed {
|
|
||||||
color: #FFFFFF !important;
|
|
||||||
background: rgba(0, 0, 0, 0.14) !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 3px 8px rgba(0, 0, 0, 0.62),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.09) !important;
|
|
||||||
transform: translateY(1px) scale(0.97) !important;
|
|
||||||
text-shadow:
|
|
||||||
0 0 5px rgba(92, 190, 255, 0.76),
|
|
||||||
0 0 12px rgba(72, 145, 255, 0.38) !important;
|
|
||||||
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.58)) !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Final dropdown behavior: truly transparent menu + persistent pressed trigger =====
|
|
||||||
* The dropdown itself has no visual surface. Only the global dim layer affects content
|
|
||||||
* behind it. The overflow trigger gets its own inset pseudo-layer so the pressed state
|
|
||||||
* cannot be cancelled by the generic button reset after pointer release. */
|
|
||||||
:root .dm-head-menu,
|
|
||||||
:root .dm-head-menu--portal,
|
|
||||||
:root .channel-menu-wrap,
|
|
||||||
:root .channel-menu-wrap--portal {
|
|
||||||
background: transparent !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
background-image: none !important;
|
|
||||||
border: 0 !important;
|
|
||||||
outline: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
-webkit-backdrop-filter: none !important;
|
|
||||||
backdrop-filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .dm-head-menu::before,
|
|
||||||
:root .dm-head-menu::after,
|
|
||||||
:root .channel-menu-wrap::before,
|
|
||||||
:root .channel-menu-wrap::after,
|
|
||||||
:root .channel-menu-wrap--portal::before,
|
|
||||||
:root .channel-menu-wrap--portal::after {
|
|
||||||
content: none !important;
|
|
||||||
display: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No separator or hover plate: the dropdown is literally labels over faded content. */
|
|
||||||
:root .channel-menu-divider {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .dm-head-menu-item,
|
|
||||||
:root .dm-head-menu-item:hover,
|
|
||||||
:root .dm-head-menu-item:focus,
|
|
||||||
:root .dm-head-menu-item:focus-visible,
|
|
||||||
:root .dm-head-menu-item:active,
|
|
||||||
:root .channel-menu-item,
|
|
||||||
:root .channel-menu-item:hover,
|
|
||||||
:root .channel-menu-item:focus,
|
|
||||||
:root .channel-menu-item:focus-visible,
|
|
||||||
:root .channel-menu-item:active,
|
|
||||||
:root .channel-menu-item.destructive,
|
|
||||||
:root .channel-menu-item.destructive:hover,
|
|
||||||
:root .channel-menu-item.destructive:focus-visible,
|
|
||||||
:root .channel-menu-item.destructive:active {
|
|
||||||
background: transparent !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
background-image: none !important;
|
|
||||||
border: 0 !important;
|
|
||||||
outline: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep the channels backdrop out of the toolbar itself, matching the other pages. */
|
|
||||||
:root .channels-menu-overlay {
|
|
||||||
top: calc(var(--topbar-height, 64px) + var(--call-minimized-bar-height, 0px)) !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
left: 0 !important;
|
|
||||||
right: 0 !important;
|
|
||||||
background: rgba(0, 0, 0, 0.49) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Authoritative pressed well. It is independent from :active/:focus and survives until
|
|
||||||
* JS removes menu-open-pressed / aria-expanded=true on actual menu close. */
|
|
||||||
:root .dm-head-menu-btn,
|
|
||||||
:root .channels-top-more-btn,
|
|
||||||
:root .network-top-more-btn {
|
|
||||||
position: relative !important;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .dm-head-menu-btn.menu-open-pressed::before,
|
|
||||||
:root .channels-top-more-btn.menu-open-pressed::before,
|
|
||||||
:root .network-top-more-btn.menu-open-pressed::before,
|
|
||||||
:root .dm-head-menu-btn[aria-expanded='true']::before,
|
|
||||||
:root .channels-top-more-btn[aria-expanded='true']::before,
|
|
||||||
:root .network-top-more-btn[aria-expanded='true']::before {
|
|
||||||
content: '' !important;
|
|
||||||
display: block !important;
|
|
||||||
position: absolute !important;
|
|
||||||
inset: 2px !important;
|
|
||||||
z-index: 0 !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
border: 0 !important;
|
|
||||||
border-radius: 10px !important;
|
|
||||||
background: rgba(0, 0, 0, 0.18) !important;
|
|
||||||
background-image: none !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 4px 10px rgba(0, 0, 0, 0.72),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
transform: none !important;
|
|
||||||
filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .dm-head-menu-btn.menu-open-pressed,
|
|
||||||
:root .channels-top-more-btn.menu-open-pressed,
|
|
||||||
:root .network-top-more-btn.menu-open-pressed,
|
|
||||||
:root .dm-head-menu-btn[aria-expanded='true'],
|
|
||||||
:root .channels-top-more-btn[aria-expanded='true'],
|
|
||||||
:root .network-top-more-btn[aria-expanded='true'] {
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
transform: translateY(1px) scale(0.97) !important;
|
|
||||||
filter: brightness(0.90) drop-shadow(0 0 4px rgba(92, 190, 255, 0.58)) !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root .dm-head-menu-btn.menu-open-pressed > *,
|
|
||||||
:root .channels-top-more-btn.menu-open-pressed > *,
|
|
||||||
:root .network-top-more-btn.menu-open-pressed > *,
|
|
||||||
:root .dm-head-menu-btn[aria-expanded='true'] > *,
|
|
||||||
:root .channels-top-more-btn[aria-expanded='true'] > *,
|
|
||||||
:root .network-top-more-btn[aria-expanded='true'] > * {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Definitive open overflow state + lighter menu dim (2026-08-23) =====
|
|
||||||
* The generic button reset above has specificity 0,4,1, so the persistent
|
|
||||||
* menu state must beat it directly on the real button (not on ::before).
|
|
||||||
* Keep this visually identical to the physical :active press until JS closes the menu. */
|
|
||||||
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed,
|
|
||||||
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true'],
|
|
||||||
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed,
|
|
||||||
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true'],
|
|
||||||
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed,
|
|
||||||
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true'],
|
|
||||||
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed,
|
|
||||||
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true'] {
|
|
||||||
color: #ffffff !important;
|
|
||||||
background: rgba(0, 0, 0, 0.12) !important;
|
|
||||||
background-image: none !important;
|
|
||||||
border: 0 !important;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 3px 8px rgba(0, 0, 0, 0.58),
|
|
||||||
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
|
|
||||||
transform: translateY(1px) scale(0.97) !important;
|
|
||||||
filter: brightness(0.9) drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The menu itself is still fully transparent; only the content below is dimmed.
|
|
||||||
* Previous alpha was ~0.49. Reduce it by about 20% relatively -> ~0.39. */
|
|
||||||
:root body .ui-menu-dim-layer,
|
|
||||||
:root body .dm-head-menu-backdrop,
|
|
||||||
:root body .channels-menu-overlay {
|
|
||||||
background: rgba(0, 0, 0, 0.39) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Round persistent overflow press + subtle background depth (2026-08-23) =====
|
|
||||||
* The open overflow control is a circular recessed well. While a topbar menu is
|
|
||||||
* present, only the content plane below the toolbar recedes slightly; the toolbar
|
|
||||||
* and the fully transparent menu stay at full size. */
|
|
||||||
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed,
|
|
||||||
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true'],
|
|
||||||
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed,
|
|
||||||
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true'],
|
|
||||||
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed,
|
|
||||||
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true'],
|
|
||||||
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed,
|
|
||||||
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true'] {
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root body .topbar-slot button.dm-head-menu-btn.menu-open-pressed::before,
|
|
||||||
:root body .topbar-slot button.dm-head-menu-btn[aria-expanded='true']::before,
|
|
||||||
:root body .topbar-slot button.channels-top-more-btn.menu-open-pressed::before,
|
|
||||||
:root body .topbar-slot button.channels-top-more-btn[aria-expanded='true']::before,
|
|
||||||
:root body .topbar-slot button.network-top-more-btn.menu-open-pressed::before,
|
|
||||||
:root body .topbar-slot button.network-top-more-btn[aria-expanded='true']::before,
|
|
||||||
:root body .network-header-overlay button.network-top-more-btn.menu-open-pressed::before,
|
|
||||||
:root body .network-header-overlay button.network-top-more-btn[aria-expanded='true']::before {
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Depth cue: the feed/graph plane gently recedes while a topbar overflow menu is open.
|
|
||||||
* Keep the scale small enough that opening the menu does not feel like a page jump. */
|
|
||||||
@supports selector(body:has(*)) {
|
|
||||||
:root body .app-shell > .screen-content {
|
|
||||||
transform-origin: 50% 50%;
|
|
||||||
transition: transform 180ms cubic-bezier(0.22, 0.61, 0.36, 1), filter 180ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root body:has(> .ui-menu-dim-layer) .app-shell > .screen-content,
|
|
||||||
:root body:has(> #modal-root #channels-top-menu-overlay) .app-shell > .screen-content {
|
|
||||||
transform: scale(0.975);
|
|
||||||
filter: brightness(0.97) saturate(0.96);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
:root body .app-shell > .screen-content {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
-1717
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,8 @@ body::before {
|
|||||||
--toolbar-height: 78px;
|
--toolbar-height: 78px;
|
||||||
--keyboard-offset: 0px;
|
--keyboard-offset: 0px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-left: 0;
|
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
border-right: 0;
|
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
.fg-orb-host .fg-pngorb-init {
|
.fg-orb-host .fg-pngorb-init {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px;
|
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-node.is-family .node-dot {
|
.fg-node.is-family .node-dot {
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||||
.fg-filter-bar {
|
.fg-filter-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: max(72px, calc(env(safe-area-inset-top) + 68px));
|
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 11;
|
z-index: 11;
|
||||||
|
|||||||
Reference in New Issue
Block a user