SHA256
Смерджить расширение функционала каналов в main
This commit is contained in:
+47
-4
@@ -43,6 +43,7 @@ import {
|
||||
deleteConversationMessagesBefore,
|
||||
deleteSignedMessageByBaseKey,
|
||||
markIncomingReadByBaseKey,
|
||||
markOutgoingDeliveryState,
|
||||
markOutgoingReadByBaseKey,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
@@ -92,6 +93,7 @@ import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelAboutView from './pages/channel-about-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
@@ -154,6 +156,7 @@ const routes = {
|
||||
user: userProfileView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'channel-about-view': channelAboutView,
|
||||
'channel-thread-view': channelThreadView,
|
||||
'add-channel-view': addChannelView,
|
||||
'add-personal-public-chat-view': addPersonalPublicChatView,
|
||||
@@ -197,20 +200,45 @@ let orientationLockInFlight = false;
|
||||
let currentChromeCleanup = null;
|
||||
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 FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
|
||||
'settings-view',
|
||||
]);
|
||||
|
||||
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
|
||||
'access-servers-view',
|
||||
'server-settings-view',
|
||||
'arweave-uploads-view',
|
||||
'developer-settings-view',
|
||||
'trusted-device-login-settings-view',
|
||||
'device-view',
|
||||
'device-session-view',
|
||||
'connect-device-view',
|
||||
'device-pairing-view',
|
||||
'device-qr-view',
|
||||
'device-camera-view',
|
||||
'show-keys-view',
|
||||
'remote-addblock-session-view',
|
||||
'app-log-view',
|
||||
'pwa-diagnostics-view',
|
||||
'solana-users-init-view',
|
||||
'solana-rpc-check-view',
|
||||
]);
|
||||
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
'language-view',
|
||||
'network-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
'channel-about-view',
|
||||
'channel-thread-view',
|
||||
'user',
|
||||
'contact-search-view',
|
||||
@@ -1171,7 +1199,8 @@ function renderApp() {
|
||||
}
|
||||
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
@@ -1200,6 +1229,8 @@ function renderApp() {
|
||||
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
||||
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
|
||||
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
@@ -1217,7 +1248,8 @@ function refreshToolbarOnly() {
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
const page = routes[pageId] || routes['start-view'];
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
toolbarEl.innerHTML = '';
|
||||
if (showAppChrome) {
|
||||
@@ -1390,6 +1422,17 @@ async function init() {
|
||||
});
|
||||
});
|
||||
|
||||
authService.onEvent('DmDeliveryStateChanged', (evt) => {
|
||||
const payload = evt?.payload || {};
|
||||
const changed = markOutgoingDeliveryState({
|
||||
outgoingKey: payload.outgoingKey,
|
||||
baseKey: payload.baseKey,
|
||||
deliveryState: payload.deliveryState,
|
||||
});
|
||||
if (!changed) return;
|
||||
window.dispatchEvent(new CustomEvent('shine-dm-delivery-updated', { detail: payload }));
|
||||
});
|
||||
|
||||
authService.onEvent('SignedMessageArrived', async (evt) => {
|
||||
const payload = evt?.payload || {};
|
||||
const messageKey = String(payload.messageKey || '').trim();
|
||||
|
||||
@@ -8,9 +8,12 @@ function normalizeLogin(value) {
|
||||
|
||||
function pickSizeClass(size) {
|
||||
const raw = String(size || '').trim().toLowerCase();
|
||||
if (raw === 'large') return 'large';
|
||||
if (raw === 'node') return 'node-dot';
|
||||
if (raw === 'small') return '';
|
||||
if (raw === 'xs' || raw === 'xsmall' || raw === 'tiny') return 'avatar-xs';
|
||||
if (raw === 'sm' || raw === 'small') return 'avatar-sm';
|
||||
if (raw === 'md' || raw === 'medium' || raw === '') return 'avatar-md';
|
||||
if (raw === 'lg' || raw === 'big') return 'avatar-lg';
|
||||
if (raw === 'xl' || raw === 'xlarge' || raw === 'large' || raw === 'very-large') return 'avatar-xl';
|
||||
return raw || '';
|
||||
}
|
||||
|
||||
@@ -28,7 +31,7 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
||||
export function renderAvatar({
|
||||
initials = '?',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
size = 'xl',
|
||||
className = '',
|
||||
title = '',
|
||||
alt = 'Аватар',
|
||||
@@ -139,7 +142,7 @@ export function renderUserAvatar({
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
size = 'xl',
|
||||
className = '',
|
||||
title = '',
|
||||
glow = false,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
export function createDropdownMenu({
|
||||
anchorEl,
|
||||
items = [],
|
||||
className = '',
|
||||
minWidth = 210,
|
||||
offset = 7,
|
||||
align = 'right',
|
||||
leftShift = 0,
|
||||
onOpen = null,
|
||||
onClose = null,
|
||||
} = {}) {
|
||||
let portal = null;
|
||||
|
||||
const close = () => {
|
||||
if (!portal) return;
|
||||
portal.remove();
|
||||
portal = null;
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const position = () => {
|
||||
if (!portal || !anchorEl) return;
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = portal.offsetWidth || minWidth;
|
||||
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const desiredLeft = baseLeft - Number(leftShift || 0);
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
|
||||
let top = rect.bottom + offset;
|
||||
const menuHeight = portal.offsetHeight || 180;
|
||||
if (top + menuHeight > window.innerHeight - margin) {
|
||||
top = Math.max(margin, rect.top - menuHeight - offset);
|
||||
}
|
||||
portal.style.left = `${Math.round(left)}px`;
|
||||
portal.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!anchorEl || portal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const menu = document.createElement('div');
|
||||
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.style.minWidth = `${minWidth}px`;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item?.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
menu.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
if (item?.iconHtml) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shared-dropdown-menu__icon';
|
||||
icon.innerHTML = item.iconHtml;
|
||||
btn.append(icon);
|
||||
} else if (item?.iconSrc) {
|
||||
const icon = document.createElement('img');
|
||||
icon.src = item.iconSrc;
|
||||
icon.alt = '';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.append(icon);
|
||||
}
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(item?.label || '');
|
||||
btn.append(label);
|
||||
btn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
item?.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
});
|
||||
|
||||
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||
document.body.append(menu);
|
||||
portal = menu;
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
onOpen?.();
|
||||
position();
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (portal) close();
|
||||
else open();
|
||||
};
|
||||
|
||||
const onAnchorClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
};
|
||||
const onOutsideClick = (event) => {
|
||||
if (!portal) return;
|
||||
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!portal || event?.detail?.owner === anchorEl) return;
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close();
|
||||
anchorEl?.focus();
|
||||
};
|
||||
|
||||
anchorEl?.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
anchorEl?.addEventListener('click', onAnchorClick);
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', position, { passive: true });
|
||||
window.addEventListener('scroll', position, { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
position,
|
||||
destroy() {
|
||||
close();
|
||||
anchorEl?.removeEventListener('click', onAnchorClick);
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', position);
|
||||
window.removeEventListener('scroll', position, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg?v=2026082601';
|
||||
|
||||
export function createShineConnectionsLogo({ className = '' } = {}) {
|
||||
const img = document.createElement('img');
|
||||
img.src = SHINE_CONNECTIONS_LOGO_SRC;
|
||||
img.alt = '';
|
||||
img.setAttribute('aria-hidden', 'true');
|
||||
img.className = String(className || '').trim();
|
||||
return img;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
|
||||
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
|
||||
@@ -8,7 +9,7 @@ import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true },
|
||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
];
|
||||
@@ -82,6 +83,10 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
} else if (isNetwork) {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.setAttribute('aria-label', item.label);
|
||||
btn.title = item.label;
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { resolveShineServerByServerLogin } from '../services/shine-server-resolv
|
||||
import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js';
|
||||
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||
const MAX_ACCESS_SERVERS = 2;
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Сервер доступа' };
|
||||
const MAX_ACCESS_SERVERS = 1;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -281,9 +281,10 @@ export function render({ navigate }) {
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Где хранятся личные данные</p>
|
||||
<p class="meta-muted">
|
||||
Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках.
|
||||
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
|
||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||
а доступ пользователя и приватная переписка хранятся только на этих серверах.
|
||||
а настройки и приватная переписка хранятся только на выбранном сервере.
|
||||
При смене сервера прежняя переписка и настройки автоматически не переносятся.
|
||||
</p>
|
||||
`;
|
||||
|
||||
@@ -291,7 +292,7 @@ export function render({ navigate }) {
|
||||
listCard.className = 'card stack';
|
||||
const listTitle = document.createElement('p');
|
||||
listTitle.className = 'field-label';
|
||||
listTitle.textContent = 'Текущий список серверов доступа';
|
||||
listTitle.textContent = 'Текущий сервер доступа';
|
||||
const listHint = document.createElement('p');
|
||||
listHint.className = 'meta-muted';
|
||||
listHint.textContent = sessionLogin
|
||||
@@ -308,10 +309,10 @@ export function render({ navigate }) {
|
||||
addCard.className = 'card stack';
|
||||
const addTitle = document.createElement('p');
|
||||
addTitle.className = 'field-label';
|
||||
addTitle.textContent = 'Добавить сервер доступа';
|
||||
addTitle.textContent = 'Сменить сервер доступа';
|
||||
const addHint = document.createElement('p');
|
||||
addHint.className = 'meta-muted';
|
||||
addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
addHint.textContent = 'Введите логин нового сервера или несколько первых букв, затем выберите сервер из подсказок. Новый сервер заменит текущий.';
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'input';
|
||||
addInput.type = 'text';
|
||||
@@ -326,13 +327,12 @@ export function render({ navigate }) {
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Добавить сервер';
|
||||
addButton.textContent = 'Сменить сервер';
|
||||
addButton.disabled = true;
|
||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||
|
||||
const refreshAddButton = () => {
|
||||
addButton.disabled = operationBusy
|
||||
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
|| (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
};
|
||||
|
||||
@@ -352,9 +352,7 @@ export function render({ navigate }) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
|
||||
: 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -368,69 +366,15 @@ export function render({ navigate }) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAccessServers.forEach((server, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'text-btn';
|
||||
button.type = 'button';
|
||||
button.disabled = operationBusy;
|
||||
button.innerHTML = `
|
||||
currentAccessServers.forEach((server) => {
|
||||
const row = document.createElement('div');
|
||||
row.innerHTML = `
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>@${escapeHtml(server.login)}</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
|
||||
</span>
|
||||
`;
|
||||
button.addEventListener('click', () => {
|
||||
if (operationBusy) return;
|
||||
const isLast = currentAccessServers.length <= 1;
|
||||
const performDisable = async () => {
|
||||
const nextList = currentAccessServers
|
||||
.map((item) => item.login)
|
||||
.filter((login) => login !== server.login);
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: listStatus,
|
||||
successText: `Сервер доступа @${server.login} отключён.`,
|
||||
inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
}
|
||||
};
|
||||
|
||||
const askFinal = (noteText = '') => {
|
||||
confirmModal?.open({
|
||||
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
|
||||
text: isLast
|
||||
? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.'
|
||||
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
|
||||
note: noteText,
|
||||
confirmLabel: isLast ? 'Понимаю' : 'Да',
|
||||
cancelLabel: isLast ? 'Отмена' : 'Нет',
|
||||
onConfirm: performDisable,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLast) {
|
||||
confirmModal?.open({
|
||||
title: 'Отключить последний сервер?',
|
||||
text: `У пользователя остался только один сервер доступа: @${server.login}.`,
|
||||
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
|
||||
confirmLabel: 'Продолжить',
|
||||
cancelLabel: 'Отмена',
|
||||
onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
askFinal();
|
||||
});
|
||||
listBody.append(button);
|
||||
if (index < currentAccessServers.length - 1) {
|
||||
const divider = document.createElement('div');
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
listBody.append(divider);
|
||||
}
|
||||
listBody.append(row);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -465,16 +409,13 @@ export function render({ navigate }) {
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? `Найдено серверов доступа: ${rows.length}`
|
||||
? 'Сервер доступа загружен из PDA.'
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
if (rows.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
|
||||
}
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать сервер доступа.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -559,7 +500,7 @@ export function render({ navigate }) {
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
@@ -604,7 +545,7 @@ export function render({ navigate }) {
|
||||
|
||||
const normalizedList = uniqueLogins(nextLogins);
|
||||
setOperationBusy(true);
|
||||
target.textContent = String(inFlightText || 'Обновляем список серверов доступа...');
|
||||
target.textContent = String(inFlightText || 'Обновляем сервер доступа...');
|
||||
let signingMaterial = null;
|
||||
try {
|
||||
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
@@ -626,7 +567,7 @@ export function render({ navigate }) {
|
||||
if (isInsufficientFundsForRentError(error)) {
|
||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||
} else {
|
||||
target.textContent = error?.message || 'Не удалось обновить список серверов доступа.';
|
||||
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -662,29 +603,25 @@ export function render({ navigate }) {
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.some((item) => item.login === login)) {
|
||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
|
||||
addStatus.textContent = `Сервер @${login} уже выбран.`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
confirmModal?.open({
|
||||
title: 'Добавить сервер доступа?',
|
||||
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
||||
title: 'Сменить сервер доступа?',
|
||||
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.`
|
||||
: 'Изменение будет записано в Solana user PDA.',
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
|
||||
onConfirm: async () => {
|
||||
const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]);
|
||||
const nextList = [resolved.serverLogin];
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа @${resolved.serverLogin} добавлен.`,
|
||||
inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`,
|
||||
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
||||
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
@@ -698,7 +635,7 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Серверы доступа',
|
||||
title: 'Сервер доступа',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
introCard,
|
||||
|
||||
@@ -44,12 +44,12 @@ function renderAvatarPreview(slot, avatar, title) {
|
||||
const wrap = renderAvatar({
|
||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar,
|
||||
size: 'small',
|
||||
size: 'xl',
|
||||
className: 'channel-profile-avatar',
|
||||
title: label,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||
wrap.style.setProperty('--channel-avatar-size', '96px');
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,9 @@ export function render({ navigate }) {
|
||||
<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>
|
||||
<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="clear">Очистить историю</button>
|
||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-about-view', title: 'О канале' };
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function normalizeHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
return normalized || '0';
|
||||
}
|
||||
|
||||
function toSafeInt(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash) {
|
||||
const ownerBch = String(ownerBlockchainName || '').trim().toLowerCase();
|
||||
const rootNo = Number(channelRootBlockNumber);
|
||||
const rootHash = normalizeHash(channelRootBlockHash);
|
||||
const rows = Object.values(state.channelsIndex || {});
|
||||
return rows.find((row) => (
|
||||
String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
||||
&& Number(row?.channel?.channelRoot?.blockNumber) === rootNo
|
||||
&& normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash
|
||||
)) || null;
|
||||
}
|
||||
|
||||
function buildChannelLink(route) {
|
||||
if (!route) return '';
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = `/${String(route).replace(/^\/+/, '')}`;
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function statsText(value) {
|
||||
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim();
|
||||
const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber);
|
||||
const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash);
|
||||
const channelRoute = makeShineChannelRootRoute({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber,
|
||||
channelRootBlockHash,
|
||||
});
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = renderHeader({
|
||||
title: 'О канале',
|
||||
leftAction: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
if (window.history.length > 1) {
|
||||
navigateBack();
|
||||
return;
|
||||
}
|
||||
if (channelRoute) navigate(channelRoute);
|
||||
},
|
||||
ariaLabel: 'Назад',
|
||||
title: 'Назад',
|
||||
},
|
||||
});
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack channel-about-card';
|
||||
card.innerHTML = `
|
||||
<div class="stack" id="channel-about-content">
|
||||
<div class="meta-muted">Загрузка данных канала…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'meta-muted screen-footer';
|
||||
footer.textContent = 'О канале (channel-about-view)';
|
||||
|
||||
screen.append(card, footer);
|
||||
|
||||
const renderContent = (channel) => {
|
||||
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||
const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim();
|
||||
const description = String(channel?.channelDescription || channel?.description || '').trim();
|
||||
const subscribersCount = Number(channel?.subscribersCount || 0);
|
||||
const aboutRoute = makeShineChannelRootRoute({
|
||||
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
|
||||
channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber,
|
||||
channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash,
|
||||
});
|
||||
const channelLinkRoute = makeShineChannelShortRoute({
|
||||
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
|
||||
channelName: channel?.channelName || '',
|
||||
});
|
||||
const channelLink = buildChannelLink(channelLinkRoute);
|
||||
const changedAtMs = Number(channel?.metaUpdatedAtMs || 0);
|
||||
const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—';
|
||||
const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен';
|
||||
|
||||
const content = card.querySelector('#channel-about-content');
|
||||
if (!content) return;
|
||||
content.innerHTML = `
|
||||
<div class="channel-profile-modal-head">
|
||||
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
|
||||
</div>
|
||||
<div class="channel-meta-details-grid">
|
||||
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
||||
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
|
||||
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
|
||||
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
|
||||
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
|
||||
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
|
||||
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
||||
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
|
||||
</div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
|
||||
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||
if (!channelLinkRoute) return;
|
||||
navigate(channelLinkRoute);
|
||||
});
|
||||
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||||
if (!channelLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(channelLink);
|
||||
showToast('Ссылка скопирована');
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash);
|
||||
if (cached?.channel) {
|
||||
renderContent({
|
||||
...cached.channel,
|
||||
subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0,
|
||||
});
|
||||
return screen;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await authService.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber,
|
||||
channelRootBlockHash,
|
||||
}, 1, 'asc', String(state.session.login || '').trim());
|
||||
renderContent(payload?.channel || {});
|
||||
} catch (error) {
|
||||
const content = card.querySelector('#channel-about-content');
|
||||
if (content) {
|
||||
content.innerHTML = `
|
||||
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
||||
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function createThreadAvatar(login) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
@@ -77,7 +77,7 @@ function createThreadAvatar(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
extractLoginFromBlockchainName,
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
makeShineChannelAboutRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
@@ -85,7 +86,7 @@ function createMessageAvatar(login) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
@@ -100,7 +101,7 @@ function createMessageAvatar(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
@@ -576,13 +577,13 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
}
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
function createChannelAvatarElement(channel, size = 96) {
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: txId ? { ar: txId } : null,
|
||||
size: 'small',
|
||||
size: 'xl',
|
||||
className: 'channel-profile-avatar',
|
||||
title,
|
||||
alt: 'Аватар канала',
|
||||
@@ -625,7 +626,7 @@ function openChannelMetaDetailsModal({
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86));
|
||||
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 96));
|
||||
|
||||
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
@@ -2550,13 +2551,12 @@ export function render({ navigate, route, chrome }) {
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(apiData.channel, {
|
||||
canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
|
||||
onEdit: () => openEditChannelModal({
|
||||
channel: apiData.channel,
|
||||
onSave: onEditChannelMeta,
|
||||
}),
|
||||
const aboutRoute = makeShineChannelAboutRoute({
|
||||
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
|
||||
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
|
||||
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
|
||||
});
|
||||
if (aboutRoute) navigate(aboutRoute);
|
||||
};
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -30,6 +32,19 @@ const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||
|
||||
function channelMenuIcon(name) {
|
||||
const paths = {
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>',
|
||||
add: '<path d="M12 5v14M5 12h14"/>',
|
||||
all: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/>',
|
||||
mine: '<circle cx="12" cy="8" r="4"/><path d="M5 21a7 7 0 0 1 14 0"/>',
|
||||
following: '<path d="M12 21s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 11c0 5.6-7 10-7 10Z"/>',
|
||||
subscribe: '<path d="M12 3a6 6 0 0 0-6 6c0 7-3 7-3 9h18c0-2-3-2-3-9a6 6 0 0 0-6-6Z"/><path d="M10 21h4"/>',
|
||||
notifications: '<path d="M12 3a6 6 0 0 0-6 6c0 7-3 7-3 9h18c0-2-3-2-3-9a6 6 0 0 0-6-6Z"/><path d="M10 21h4"/>',
|
||||
};
|
||||
return `<svg class="channel-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function cleanChannelMessagePreview(text) {
|
||||
const parsed = parseMessageAttachments(text);
|
||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||
@@ -64,10 +79,9 @@ function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
||||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||
return makeShineChannelRoute({
|
||||
ownerLogin,
|
||||
return makeShineChannelShortRoute({
|
||||
ownerBlockchainName: ownerBch,
|
||||
channelName: channelName || fallbackId,
|
||||
channelName: channelName || fallbackId || ownerLogin,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,7 +108,7 @@ 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',
|
||||
size: 'lg',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
@@ -952,6 +966,7 @@ function openTopChannelsMenu({
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
@@ -961,7 +976,9 @@ function openTopChannelsMenu({
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 250;
|
||||
let top = rect.bottom + 8;
|
||||
const titleAnchor = document.querySelector('.channels-filter-title');
|
||||
const titleRect = titleAnchor?.getBoundingClientRect?.();
|
||||
let top = (titleRect?.bottom || rect.bottom) + 7;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
@@ -977,12 +994,8 @@ function openTopChannelsMenu({
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
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_OWNED)) },
|
||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
@@ -998,7 +1011,7 @@ function openTopChannelsMenu({
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-menu-item';
|
||||
btn.textContent = item.label;
|
||||
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
closeTopChannelsMenu(listState);
|
||||
item.action?.();
|
||||
@@ -1012,13 +1025,19 @@ function openTopChannelsMenu({
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (event?.detail?.owner === anchorEl) return;
|
||||
closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
listState.topMenuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1054,8 +1073,11 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||||
|
||||
const actionLabel = document.createElement('span');
|
||||
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
|
||||
|
||||
if (canToggleSubscription) {
|
||||
actionBtn.textContent = channel.pending
|
||||
actionLabel.textContent = channel.pending
|
||||
? 'Выполняется...'
|
||||
: channel.isSubscribed
|
||||
? 'Отписаться'
|
||||
@@ -1075,7 +1097,7 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
|
||||
channel.pending = true;
|
||||
actionBtn.disabled = true;
|
||||
actionBtn.textContent = 'Выполняется...';
|
||||
actionLabel.textContent = 'Выполняется...';
|
||||
|
||||
const nextSubscribed = !channel.isSubscribed;
|
||||
try {
|
||||
@@ -1097,13 +1119,13 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
} catch (error) {
|
||||
channel.pending = false;
|
||||
actionBtn.disabled = false;
|
||||
actionBtn.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||||
rerenderList();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionBtn.textContent = 'Собственный канал';
|
||||
actionLabel.textContent = 'Собственный канал';
|
||||
actionBtn.disabled = true;
|
||||
}
|
||||
|
||||
@@ -1111,7 +1133,8 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
toggleWrap.className = 'channel-menu-toggle';
|
||||
|
||||
const toggleLabel = document.createElement('span');
|
||||
toggleLabel.textContent = 'Уведомления';
|
||||
toggleLabel.className = 'channel-menu-toggle-label';
|
||||
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
@@ -1174,16 +1197,24 @@ function renderChannelMain(channel) {
|
||||
main.append(desc);
|
||||
}
|
||||
|
||||
const previewLine = document.createElement('div');
|
||||
previewLine.className = 'channel-row-preview-line';
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
previewLine.append(preview, time);
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title, technical);
|
||||
main.append(preview, meta);
|
||||
main.append(previewLine, meta);
|
||||
return main;
|
||||
}
|
||||
|
||||
@@ -1220,10 +1251,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channel-row-controls';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'unread channel-row-count';
|
||||
const unreadCount = Number(channel.unreadCount || 0);
|
||||
@@ -1231,7 +1258,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
controls.append(time, count);
|
||||
controls.append(count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => {
|
||||
@@ -1343,8 +1370,21 @@ export function render({ navigate, route, chrome }) {
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
anchorEl: topTitle,
|
||||
align: 'left',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
],
|
||||
});
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
@@ -1411,6 +1451,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
channelsFilterMenu.destroy();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
|
||||
+146
-27
@@ -30,25 +30,100 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function createChatHeaderParts(login) {
|
||||
function menuIconSvg(name) {
|
||||
const paths = {
|
||||
reply: '<path d="M9 7 4 12l5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/>',
|
||||
copy: '<rect x="8" y="8" width="10" height="10" rx="2"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/>',
|
||||
read: '<path d="M5 9v6h4l5 4V5L9 9H5Z"/><path d="M17 9a4 4 0 0 1 0 6"/>',
|
||||
edit: '<path d="m4 20 4.4-1 9.9-9.9a2.1 2.1 0 0 0-3-3L5.4 16 4 20Z"/><path d="m13.8 7.2 3 3"/>',
|
||||
delete: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="m7 7 1 13h8l1-13"/>',
|
||||
call: '<path d="M7.6 10.8a15 15 0 0 0 5.6 5.6l1.9-1.9a1 1 0 0 1 1-.2c1.1.4 2.3.6 3.5.6a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.6 21 3 13.4 3 4a1 1 0 0 1 1-1h3.4a1 1 0 0 1 1 1c0 1.2.2 2.4.6 3.5a1 1 0 0 1-.3 1l-1.1 1.3Z"/>',
|
||||
video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
|
||||
clear: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="m7 7 1 13h8l1-13"/>',
|
||||
};
|
||||
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
const cleanLogin = String(login || '').trim();
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
|
||||
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||
<span>Связи</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
||||
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Профиль</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const layer = root.querySelector('#chat-user-menu-layer');
|
||||
const menu = root.querySelector('.dm-user-identity-menu');
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!menu) return;
|
||||
const width = menu.offsetWidth || 190;
|
||||
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
});
|
||||
|
||||
layer?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === layer) close();
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileLinksRoute(cleanLogin));
|
||||
});
|
||||
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
}
|
||||
|
||||
function createChatHeaderParts(login, navigate) {
|
||||
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',
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
avatarSlot.append(initialAvatar);
|
||||
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'chat-header-login';
|
||||
const avatarButton = document.createElement('button');
|
||||
avatarButton.type = 'button';
|
||||
avatarButton.className = 'chat-header-avatar-btn';
|
||||
avatarButton.title = `Меню ${cleanLogin}`;
|
||||
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||
avatarButton.append(avatarSlot);
|
||||
|
||||
const loginEl = document.createElement('button');
|
||||
loginEl.type = 'button';
|
||||
loginEl.className = 'chat-header-login chat-header-login-btn';
|
||||
loginEl.setAttribute('role', 'heading');
|
||||
loginEl.setAttribute('aria-level', '1');
|
||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||
@@ -67,7 +142,7 @@ function createChatHeaderParts(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
@@ -75,7 +150,14 @@ function createChatHeaderParts(login) {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { centerNode: loginEl, avatarSlot };
|
||||
const openMenu = (event) => {
|
||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||
};
|
||||
avatarButton.addEventListener('click', openMenu);
|
||||
loginEl.addEventListener('click', openMenu);
|
||||
|
||||
return { centerNode: loginEl, avatarButton };
|
||||
}
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
@@ -207,11 +289,11 @@ function openMessageActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
||||
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
|
||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
||||
${canReply ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">${menuIconSvg('reply')}<span>Ответить</span></button>` : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">${menuIconSvg('copy')}<span>Скопировать как текст</span></button>
|
||||
${showReadAloud ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">${menuIconSvg('read')}<span>Прочесть</span></button>` : ''}
|
||||
${canEdit ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">${menuIconSvg('edit')}<span>Изменить</span></button>` : ''}
|
||||
${canDelete ? `<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">${menuIconSvg('delete')}<span>Удалить</span></button>` : ''}
|
||||
${String(infoText || '').trim() ? `<div class="meta-muted">${String(infoText || '').trim()}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +375,6 @@ function openChatActionsMenu({
|
||||
anchorY = 0,
|
||||
onCall,
|
||||
onVideoCall,
|
||||
onInstantVideoCall,
|
||||
onClearHistory,
|
||||
onDeleteChat,
|
||||
}) {
|
||||
@@ -304,11 +385,10 @@ function openChatActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -358,10 +438,6 @@ function openChatActionsMenu({
|
||||
close();
|
||||
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
|
||||
});
|
||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||
@@ -528,10 +604,28 @@ function resolveEffectiveReadState(messages, msg) {
|
||||
function resolveDeliveryStatus(messages, msg) {
|
||||
if (msg?.from !== 'out') return '';
|
||||
if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓';
|
||||
if (msg?.firstTick) return '✓';
|
||||
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
|
||||
if (deliveryState === 'delivered') return '✓';
|
||||
if (deliveryState === 'failed') return '!';
|
||||
if (deliveryState === 'accepted' || msg?.firstTick) return '✓';
|
||||
return '…';
|
||||
}
|
||||
|
||||
function resolveDeliveryTone(messages, msg) {
|
||||
if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
|
||||
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
|
||||
if (deliveryState === 'delivered') return 'delivered';
|
||||
if (deliveryState === 'failed') return 'failed';
|
||||
return 'accepted';
|
||||
}
|
||||
|
||||
function resolveDeliveryNote(msg) {
|
||||
if (msg?.from !== 'out') return '';
|
||||
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
|
||||
if (deliveryState === 'failed') return 'Сообщение не доставлено.';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveMessageEditedTimeMs(msg) {
|
||||
const revisionTimeMs = Number(msg?.revisionTimeMs || 0);
|
||||
if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0;
|
||||
@@ -744,13 +838,21 @@ function renderLog(
|
||||
const status = resolveDeliveryStatus(messages, msg);
|
||||
if (status) {
|
||||
const statusNode = document.createElement('span');
|
||||
statusNode.className = 'bubble-status';
|
||||
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
|
||||
statusNode.textContent = status;
|
||||
metaNode.append(statusNode);
|
||||
}
|
||||
|
||||
bubble.append(metaNode);
|
||||
|
||||
const deliveryNote = resolveDeliveryNote(msg);
|
||||
if (deliveryNote) {
|
||||
const deliveryNoteNode = document.createElement('div');
|
||||
deliveryNoteNode.className = `bubble-delivery-note${String(msg?.deliveryState || '') === 'failed' ? ' bubble-delivery-note--failed' : ''}`;
|
||||
deliveryNoteNode.textContent = deliveryNote;
|
||||
bubble.append(deliveryNoteNode);
|
||||
}
|
||||
|
||||
const editedAtMs = resolveMessageEditedTimeMs(msg);
|
||||
if (editedAtMs > 0) {
|
||||
const editedNode = document.createElement('div');
|
||||
@@ -831,6 +933,7 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
|
||||
readAtMs: Number(item?.readAtMs || 0),
|
||||
rawBlobB64: blobB64,
|
||||
revisionTimeMs: Number(item?.revisionTimeMs || parsed?.revisionTimeMs || 0),
|
||||
deliveryState: String(item?.deliveryState || ''),
|
||||
});
|
||||
} catch (error) {
|
||||
addAppLogEntry({
|
||||
@@ -966,7 +1069,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
const chatHeaderParts = createChatHeaderParts(chatId);
|
||||
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
||||
const chatHeader = renderHeader({
|
||||
centerNode: chatHeaderParts.centerNode,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
@@ -989,7 +1092,6 @@ export function render({ navigate, route, chrome }) {
|
||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||
onCall: () => handleStartCall('audio'),
|
||||
onVideoCall: () => handleStartCall('video'),
|
||||
onInstantVideoCall: () => handleStartCall('instant_video'),
|
||||
onClearHistory: async () => {
|
||||
openChatConfirmModal({
|
||||
title: 'Очистить историю?',
|
||||
@@ -1037,7 +1139,8 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
],
|
||||
});
|
||||
chatHeader.querySelector('.header-left')?.append(chatHeaderParts.avatarSlot);
|
||||
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
||||
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
@@ -1248,7 +1351,12 @@ export function render({ navigate, route, chrome }) {
|
||||
focusInputToEnd();
|
||||
};
|
||||
|
||||
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
const applyLocalRevision = async ({
|
||||
localOutgoingBlobB64,
|
||||
fallbackMessageKey = '',
|
||||
fallbackBaseKey = '',
|
||||
deliveryState = '',
|
||||
}) => {
|
||||
if (!localOutgoingBlobB64) return;
|
||||
try {
|
||||
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
|
||||
@@ -1271,6 +1379,7 @@ export function render({ navigate, route, chrome }) {
|
||||
unread: false,
|
||||
rawBlobB64: localOutgoingBlobB64,
|
||||
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
|
||||
deliveryState,
|
||||
deleted: Boolean(parsed?.deleted),
|
||||
});
|
||||
return true;
|
||||
@@ -1346,6 +1455,7 @@ export function render({ navigate, route, chrome }) {
|
||||
markOutgoingSent(tempId, {
|
||||
messageKey: result?.outgoingKey || '',
|
||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
deliveryState: result?.deliveryState || 'accepted',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1353,6 +1463,7 @@ export function render({ navigate, route, chrome }) {
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
deliveryState: result?.deliveryState || 'accepted',
|
||||
});
|
||||
|
||||
if (editing) {
|
||||
@@ -1580,7 +1691,14 @@ export function render({ navigate, route, chrome }) {
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
};
|
||||
|
||||
const handleDeliveryRefresh = () => {
|
||||
preserveComposerSelection(input, () => {
|
||||
renderChatLog({ scrollMode: 'preserve', markAsRead: false });
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.addEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||
|
||||
wrap.append(historyLoader, log);
|
||||
screen.append(wrap);
|
||||
@@ -1609,6 +1727,7 @@ export function render({ navigate, route, chrome }) {
|
||||
hideUnreadSeparator({ rerender: false });
|
||||
stopAllTwemojiAnimations();
|
||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||
clearUnreadSeparatorHideTimer();
|
||||
document.body.classList.remove('chat-topbar-overlay');
|
||||
|
||||
@@ -48,7 +48,7 @@ export function render({ navigate }) {
|
||||
state.deviceConnect.blockchain = blockchainToggle.checked;
|
||||
});
|
||||
deviceToggle.addEventListener('change', () => {
|
||||
state.deviceConnect.device = true;
|
||||
state.deviceConnect.client = true;
|
||||
deviceToggle.checked = true;
|
||||
});
|
||||
|
||||
@@ -100,22 +100,22 @@ export function render({ navigate }) {
|
||||
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||
const hasRoot = Boolean(savedKeys.rootKey);
|
||||
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
||||
const hasDevice = Boolean(savedKeys.clientKey);
|
||||
const hasClient = Boolean(savedKeys.clientKey);
|
||||
|
||||
rootToggle.disabled = !hasRoot;
|
||||
blockchainToggle.disabled = !hasBlockchain;
|
||||
deviceToggle.disabled = true;
|
||||
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
||||
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
||||
state.deviceConnect.device = hasDevice;
|
||||
state.deviceConnect.client = hasClient;
|
||||
rootToggle.checked = state.deviceConnect.root;
|
||||
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||
deviceToggle.checked = hasDevice;
|
||||
openQrBtn.disabled = !hasDevice;
|
||||
openPairBtn.disabled = !hasDevice;
|
||||
deviceToggle.checked = hasClient;
|
||||
openQrBtn.disabled = !hasClient;
|
||||
openPairBtn.disabled = !hasClient;
|
||||
|
||||
const available = [
|
||||
hasDevice ? 'device' : '',
|
||||
hasClient ? 'client' : '',
|
||||
hasBlockchain ? 'blockchain' : '',
|
||||
hasRoot ? 'root' : '',
|
||||
].filter(Boolean);
|
||||
@@ -128,7 +128,7 @@ export function render({ navigate }) {
|
||||
deviceToggle.checked = false;
|
||||
state.deviceConnect.root = false;
|
||||
state.deviceConnect.blockchain = false;
|
||||
state.deviceConnect.device = false;
|
||||
state.deviceConnect.client = false;
|
||||
openQrBtn.disabled = true;
|
||||
openPairBtn.disabled = true;
|
||||
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||
|
||||
@@ -34,7 +34,7 @@ function createSearchAvatar(login) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'avatar',
|
||||
title,
|
||||
});
|
||||
@@ -49,7 +49,7 @@ function createSearchAvatar(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'avatar',
|
||||
title,
|
||||
});
|
||||
|
||||
@@ -260,15 +260,15 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="text-btn" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
`;
|
||||
|
||||
const appLogBtn = card.querySelector('#settings-app-log');
|
||||
|
||||
@@ -36,22 +36,20 @@ function pairingSessionKindLabel(sessionType) {
|
||||
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
|
||||
}
|
||||
|
||||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
||||
function buildTransferKeys(savedKeys, selection = {}) {
|
||||
const keys = {
|
||||
clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||
clientKey: String(savedKeys?.clientKey || '').trim(),
|
||||
blockchainKey: '',
|
||||
rootKey: '',
|
||||
};
|
||||
if (!keys.clientKey) {
|
||||
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
||||
}
|
||||
if (withExtras) {
|
||||
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
|
||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||
}
|
||||
if (state.deviceConnect.root && savedKeys?.rootKey) {
|
||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||
}
|
||||
if (selection.blockchain && savedKeys?.blockchainKey) {
|
||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||
}
|
||||
if (selection.root && savedKeys?.rootKey) {
|
||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -73,10 +71,9 @@ function requestCardHtml(request) {
|
||||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="flex-wrap:wrap;">
|
||||
<button class="ghost-btn" type="button" data-action="approve-device">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
|
||||
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
|
||||
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
|
||||
<div class="row pairing-request-actions" style="flex-wrap:wrap;">
|
||||
<button class="primary-btn pairing-approve-btn" type="button" data-action="approve-device">Подключить</button>
|
||||
<button class="text-btn pairing-reject-btn" type="button" data-action="reject">Отклонить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -179,6 +176,7 @@ export function render({ navigate }) {
|
||||
let settingsBusy = false;
|
||||
let pairingPasswordConfigured = false;
|
||||
let dialogMode = '';
|
||||
let pendingTransferRequest = null;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
@@ -187,6 +185,38 @@ export function render({ navigate }) {
|
||||
}),
|
||||
);
|
||||
|
||||
const transferDialog = document.createElement('div');
|
||||
transferDialog.className = 'pairing-transfer-dialog';
|
||||
transferDialog.hidden = true;
|
||||
transferDialog.innerHTML = `
|
||||
<div class="pairing-transfer-dialog__backdrop" data-transfer-action="cancel"></div>
|
||||
<section class="card stack pairing-transfer-dialog__card" role="dialog" aria-modal="true" aria-labelledby="pairing-transfer-title">
|
||||
<div class="stack" style="gap:5px;">
|
||||
<h2 class="login-panel-title" id="pairing-transfer-title">Подключить и передать ключи</h2>
|
||||
<p class="meta-muted">Client key передаётся всегда. Дополнительные ключи можно передать только если они есть на этом устройстве.</p>
|
||||
</div>
|
||||
<div class="pairing-transfer-key-list">
|
||||
<label class="pairing-transfer-key is-required">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span><strong>Client key</strong><small>Обязателен для клиентского устройства</small></span>
|
||||
</label>
|
||||
<label class="pairing-transfer-key" data-transfer-key="root">
|
||||
<input type="checkbox" id="pairing-transfer-root" checked />
|
||||
<span><strong>Root key</strong><small>Передать на новое устройство</small></span>
|
||||
</label>
|
||||
<label class="pairing-transfer-key" data-transfer-key="blockchain">
|
||||
<input type="checkbox" id="pairing-transfer-blockchain" checked />
|
||||
<span><strong>Blockchain key</strong><small>Передать на новое устройство</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row pairing-transfer-dialog__actions">
|
||||
<button class="ghost-btn" type="button" data-transfer-action="cancel">Отмена</button>
|
||||
<button class="primary-btn pairing-approve-btn" type="button" data-transfer-action="confirm">Подключить</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
screen.append(transferDialog);
|
||||
|
||||
const settingsCard = document.createElement('div');
|
||||
settingsCard.className = 'card stack';
|
||||
const passwordIcons = makePasswordToggleIcons();
|
||||
@@ -432,11 +462,11 @@ export function render({ navigate }) {
|
||||
const loadSavedKeys = async () => {
|
||||
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||
const available = [];
|
||||
if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client');
|
||||
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
|
||||
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
|
||||
keySummaryEl.textContent = available.length
|
||||
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
|
||||
if (savedKeys?.clientKey) available.push('client');
|
||||
if (savedKeys?.blockchainKey) available.push('blockchain');
|
||||
if (savedKeys?.rootKey) available.push('root');
|
||||
keySummaryEl.textContent = available.length > 1
|
||||
? `Доступны для подключения: ${available.join(', ')}.`
|
||||
: 'На этом устройстве доступен только client key.';
|
||||
};
|
||||
|
||||
@@ -460,10 +490,10 @@ export function render({ navigate }) {
|
||||
refreshBtn.disabled = flag;
|
||||
};
|
||||
|
||||
const approveRequest = async (request, mode) => {
|
||||
const withExtras = mode === 'with-extras';
|
||||
const approveRequest = async (request, selection = {}) => {
|
||||
const withExtras = !!selection.root || !!selection.blockchain;
|
||||
let payload;
|
||||
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
||||
login: state.session.login,
|
||||
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||
@@ -477,7 +507,7 @@ export function render({ navigate }) {
|
||||
session: delegatedSession,
|
||||
});
|
||||
} else {
|
||||
const keys = buildTransferKeys(savedKeys, { withExtras });
|
||||
const keys = buildTransferKeys(savedKeys, selection);
|
||||
payload = buildSecretsPayload({
|
||||
login: state.session.login,
|
||||
keys,
|
||||
@@ -486,7 +516,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||||
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
||||
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
const sessionOnly = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
showToast(
|
||||
withExtras
|
||||
? 'Ключи переданы на новое устройство'
|
||||
@@ -505,6 +535,53 @@ export function render({ navigate }) {
|
||||
await reloadRequests({ silent: true });
|
||||
};
|
||||
|
||||
const closeTransferDialog = () => {
|
||||
pendingTransferRequest = null;
|
||||
transferDialog.hidden = true;
|
||||
};
|
||||
|
||||
const openTransferDialog = (request) => {
|
||||
pendingTransferRequest = request;
|
||||
const rootRow = transferDialog.querySelector('[data-transfer-key="root"]');
|
||||
const blockchainRow = transferDialog.querySelector('[data-transfer-key="blockchain"]');
|
||||
const rootInput = transferDialog.querySelector('#pairing-transfer-root');
|
||||
const blockchainInput = transferDialog.querySelector('#pairing-transfer-blockchain');
|
||||
const hasRoot = !!String(savedKeys?.rootKey || '').trim();
|
||||
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
|
||||
rootRow.hidden = !hasRoot;
|
||||
blockchainRow.hidden = !hasBlockchain;
|
||||
rootInput.checked = hasRoot;
|
||||
blockchainInput.checked = hasBlockchain;
|
||||
transferDialog.hidden = false;
|
||||
};
|
||||
|
||||
transferDialog.addEventListener('click', async (event) => {
|
||||
const target = event.target instanceof Element ? event.target.closest('[data-transfer-action]') : null;
|
||||
const action = String(target?.dataset?.transferAction || '');
|
||||
if (!action) return;
|
||||
if (action === 'cancel') {
|
||||
closeTransferDialog();
|
||||
return;
|
||||
}
|
||||
if (action !== 'confirm' || !pendingTransferRequest) return;
|
||||
const request = pendingTransferRequest;
|
||||
const confirmBtn = transferDialog.querySelector('[data-transfer-action="confirm"]');
|
||||
confirmBtn.disabled = true;
|
||||
try {
|
||||
await approveRequest(request, {
|
||||
root: !!transferDialog.querySelector('#pairing-transfer-root')?.checked,
|
||||
blockchain: !!transferDialog.querySelector('#pairing-transfer-blockchain')?.checked,
|
||||
});
|
||||
closeTransferDialog();
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось подключить устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
} finally {
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
passwordDialog.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
@@ -609,9 +686,14 @@ export function render({ navigate }) {
|
||||
buttons.forEach((btn) => { btn.disabled = true; });
|
||||
try {
|
||||
if (action === 'approve-device') {
|
||||
await approveRequest(request, 'device-only');
|
||||
} else if (action === 'approve-full') {
|
||||
await approveRequest(request, 'with-extras');
|
||||
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
|
||||
if (!isWallet && hasExtras) {
|
||||
openTransferDialog(request);
|
||||
buttons.forEach((btn) => { btn.disabled = false; });
|
||||
return;
|
||||
}
|
||||
await approveRequest(request, {});
|
||||
} else if (action === 'reject') {
|
||||
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
||||
showToast('Заявка отклонена', { kind: 'error' });
|
||||
|
||||
@@ -68,7 +68,7 @@ export function render({ navigate, route }) {
|
||||
`;
|
||||
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'text-btn';
|
||||
actionBtn.className = 'shine-btn shine-btn--settings';
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.textContent = 'Завершить сеанс';
|
||||
|
||||
|
||||
@@ -54,11 +54,13 @@ export function render({ navigate }) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="ghost-btn" type="button" id="connect-device-btn">Подключить устройство</button>
|
||||
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
|
||||
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
@@ -109,7 +111,7 @@ export function render({ navigate }) {
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'text-btn';
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
|
||||
@@ -33,24 +33,6 @@ export function render({ navigate }) {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card stack';
|
||||
|
||||
const languageLabel = document.createElement('label');
|
||||
languageLabel.className = 'stack';
|
||||
languageLabel.innerHTML = `<span class="field-label">Язык</span>`;
|
||||
|
||||
const languageSelect = document.createElement('select');
|
||||
languageSelect.className = 'select';
|
||||
languageSelect.innerHTML = `
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
`;
|
||||
languageSelect.value = draft.language;
|
||||
languageSelect.addEventListener('change', () => {
|
||||
draft.language = languageSelect.value;
|
||||
});
|
||||
languageLabel.append(languageSelect);
|
||||
|
||||
body.append(languageLabel);
|
||||
|
||||
SERVER_FIELDS.forEach((field) => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'stack';
|
||||
@@ -68,7 +50,7 @@ export function render({ navigate }) {
|
||||
controls.className = 'row wrap-row';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn server-check-btn';
|
||||
checkButton.className = 'shine-btn shine-btn--settings server-check-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить';
|
||||
|
||||
@@ -150,7 +132,7 @@ export function render({ navigate }) {
|
||||
|
||||
if (isLocalDemoAvailable()) {
|
||||
const localDemoButton = document.createElement('button');
|
||||
localDemoButton.className = 'ghost-btn preauth-local-demo-btn';
|
||||
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn';
|
||||
localDemoButton.type = 'button';
|
||||
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
||||
localDemoButton.addEventListener('click', () => {
|
||||
@@ -161,7 +143,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
const serverUiButton = document.createElement('button');
|
||||
serverUiButton.className = 'ghost-btn';
|
||||
serverUiButton.className = 'shine-btn shine-btn--settings';
|
||||
serverUiButton.type = 'button';
|
||||
serverUiButton.textContent = 'Настроить свой сервер';
|
||||
serverUiButton.addEventListener('click', () => {
|
||||
@@ -170,13 +152,13 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.className = 'shine-btn shine-btn--settings';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'primary-btn';
|
||||
saveButton.className = 'shine-btn shine-btn--primary';
|
||||
saveButton.type = 'button';
|
||||
saveButton.textContent = 'Сохранить';
|
||||
saveButton.addEventListener('click', async () => {
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { saveEntryLanguage, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
||||
|
||||
function resolveReturnPage() {
|
||||
const stored = String(sessionStorage.getItem('shine-language-return-page') || '').trim();
|
||||
return stored === 'start-view' ? 'start-view' : 'settings-view';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack language-screen';
|
||||
const returnPage = resolveReturnPage();
|
||||
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Язык',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
title: 'Язык / Language',
|
||||
leftAction: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'card stack language-choice-card';
|
||||
card.innerHTML = `
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
|
||||
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||
<div class="language-segmented-control" role="radiogroup" aria-label="Язык интерфейса">
|
||||
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button>
|
||||
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button>
|
||||
<span class="language-segmented-thumb" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="language-choice-actions">
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button>
|
||||
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="language-ok">ОК</button>
|
||||
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
|
||||
`;
|
||||
const syncSelection = () => {
|
||||
card.dataset.language = pendingLanguage;
|
||||
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||
const selected = item.dataset.language === pendingLanguage;
|
||||
item.classList.toggle('is-selected', selected);
|
||||
item.setAttribute('aria-checked', String(selected));
|
||||
});
|
||||
};
|
||||
syncSelection();
|
||||
|
||||
actions.querySelector('#language-ok').addEventListener('click', () => {
|
||||
const selected = card.querySelector('input[name="language"]:checked');
|
||||
if (selected) {
|
||||
state.entrySettings.language = selected.value;
|
||||
}
|
||||
navigate('settings-view');
|
||||
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
|
||||
syncSelection();
|
||||
});
|
||||
});
|
||||
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => {
|
||||
saveEntryLanguage(pendingLanguage);
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
|
||||
actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
screen.append(card, actions);
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -60,7 +59,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack auth-screen auth-screen--lower';
|
||||
screen.className = 'stack auth-screen auth-screen--other-device';
|
||||
let pollTimer = 0;
|
||||
let countdownTimer = 0;
|
||||
let activePairingId = '';
|
||||
@@ -70,34 +69,29 @@ export function render({ navigate }) {
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти через другое устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel login-panel--wide stack';
|
||||
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
|
||||
panel.innerHTML = `
|
||||
<button class="login-panel-inline-back" type="button" id="login-other-device-back" aria-label="Назад">
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Войти через другое устройство</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
panel.querySelector('#login-other-device-back')?.addEventListener('click', () => { void cancelActivePairingAndBack(); });
|
||||
|
||||
const formCard = document.createElement('div');
|
||||
formCard.className = 'card stack';
|
||||
formCard.className = 'card stack login-device-preparation';
|
||||
formCard.innerHTML = `
|
||||
<label class="stack">
|
||||
<span class="field-label">Введите логин</span>
|
||||
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="pair-use-password" />
|
||||
использовать доп. пароль
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span class="field-label">Пароль подключения</span>
|
||||
<p class="auth-copy" id="pair-login-label"></p>
|
||||
<input id="pair-login" type="hidden" value="" />
|
||||
<input id="pair-use-password" type="checkbox" hidden />
|
||||
<label class="stack" id="pair-password-wrap" style="display:none;">
|
||||
<span class="field-label">Дополнительный пароль подключения</span>
|
||||
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
||||
</label>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</p>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn" style="display:none;">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Создаём код для входа…</p>
|
||||
`;
|
||||
|
||||
const status = document.createElement('p');
|
||||
@@ -110,10 +104,14 @@ export function render({ navigate }) {
|
||||
resultWrap.innerHTML = codeCardHtml();
|
||||
|
||||
const loginInput = formCard.querySelector('#pair-login');
|
||||
const loginLabelEl = formCard.querySelector('#pair-login-label');
|
||||
const usePasswordInput = formCard.querySelector('#pair-use-password');
|
||||
const passwordInput = formCard.querySelector('#pair-password');
|
||||
const startBtn = formCard.querySelector('#pair-start-btn');
|
||||
const modeHintEl = formCard.querySelector('#pair-mode-hint');
|
||||
|
||||
loginInput.value = String(state.loginDraft.login || '').trim();
|
||||
loginLabelEl.textContent = loginInput.value ? `Вход для @${loginInput.value}` : '';
|
||||
const shortCodeEl = resultWrap.querySelector('#pairing-short-code');
|
||||
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
||||
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
||||
@@ -127,12 +125,11 @@ export function render({ navigate }) {
|
||||
const syncPasswordUi = () => {
|
||||
const usePassword = !!usePasswordInput.checked;
|
||||
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
||||
startBtn.style.display = usePassword ? '' : 'none';
|
||||
modeHintEl.textContent = usePassword
|
||||
? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.'
|
||||
: 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.';
|
||||
if (!usePassword) {
|
||||
passwordInput.value = '';
|
||||
}
|
||||
? 'На доверённом устройстве для входа по коду включён дополнительный пароль.'
|
||||
: 'Код можно подтвердить на уже подключённом устройстве. Если оно сейчас не в сети, заявка будет ждать до истечения срока.';
|
||||
if (!usePassword) passwordInput.value = '';
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -157,6 +154,8 @@ export function render({ navigate }) {
|
||||
activePairingId = '';
|
||||
activeExpiresAtMs = 0;
|
||||
startBtn.disabled = false;
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Получить новый код';
|
||||
cancelBtn.style.display = 'none';
|
||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||
@@ -183,7 +182,7 @@ export function render({ navigate }) {
|
||||
|
||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
@@ -216,7 +215,7 @@ export function render({ navigate }) {
|
||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||
};
|
||||
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
@@ -310,14 +309,16 @@ export function render({ navigate }) {
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
setAuthInfo('');
|
||||
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
|
||||
setStatus(status, 'Создаём код для входа…', 'info');
|
||||
clearActivePairing();
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const user = await authService.getUser(login);
|
||||
if (!user?.exists) {
|
||||
throw new Error('Пользователь не найден.');
|
||||
const resolved = await authService.resolveLoginForAuth(login);
|
||||
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
|
||||
requesterMaterial = await createRequesterPairingMaterial();
|
||||
@@ -337,61 +338,68 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
|
||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
||||
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
||||
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||
resultWrap.style.display = '';
|
||||
cancelBtn.style.display = '';
|
||||
startCountdown(payload?.expiresAtMs);
|
||||
state.loginDraft.login = login;
|
||||
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
startBtn.disabled = false;
|
||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
|
||||
usePasswordInput.checked = true;
|
||||
syncPasswordUi();
|
||||
modeHintEl.textContent = 'Для этого аккаунта включён дополнительный пароль подключения. Введите его, чтобы получить код.';
|
||||
setStatus(status, 'Введите дополнительный пароль подключения.', 'info');
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
} else {
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Повторить';
|
||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', async () => {
|
||||
if (!activePairingId || !requesterMaterial?.sessionKey) {
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
cancelBtn.style.display = 'none';
|
||||
return;
|
||||
const cancelActivePairingAndBack = async () => {
|
||||
const pairingId = activePairingId;
|
||||
const requesterSessionKey = requesterMaterial?.sessionKey;
|
||||
isDisposed = true;
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
if (pairingId && requesterSessionKey) {
|
||||
try {
|
||||
await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey);
|
||||
} catch {
|
||||
// Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки.
|
||||
}
|
||||
}
|
||||
cancelBtn.disabled = true;
|
||||
try {
|
||||
await authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey);
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
setStatus(status, 'Ожидание подключения отменено.', 'info');
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
} finally {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.style.display = activePairingId ? '' : 'none';
|
||||
}
|
||||
});
|
||||
navigate('login-view');
|
||||
};
|
||||
|
||||
screen.cleanup = () => {
|
||||
isDisposed = true;
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
if (activePairingId && requesterMaterial?.sessionKey) {
|
||||
void authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const resultActions = document.createElement('div');
|
||||
resultActions.className = 'row';
|
||||
resultActions.append(cancelBtn);
|
||||
resultWrap.append(resultActions);
|
||||
|
||||
panel.append(formCard, status, resultWrap);
|
||||
screen.append(panel);
|
||||
|
||||
if (!String(loginInput.value || '').trim()) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
} else {
|
||||
// После проверки логина на предыдущем экране код создаётся сразу.
|
||||
window.setTimeout(() => startBtn.click(), 0);
|
||||
}
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -7,256 +7,177 @@ import {
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
composePasswordFromWords,
|
||||
emptyPasswordWords,
|
||||
normalizePasswordWords,
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_WORDS_COUNT,
|
||||
} from '../services/password-words.js';
|
||||
import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
|
||||
|
||||
function createWordsLayout({ words, onInput }) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'registration-words-block';
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'registration-words-grid';
|
||||
|
||||
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'registration-word-row';
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.className = 'registration-word-number';
|
||||
number.textContent = `${index + 1}.`;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input registration-word-input';
|
||||
input.type = 'text';
|
||||
input.autocomplete = 'off';
|
||||
input.autocapitalize = 'off';
|
||||
input.spellcheck = false;
|
||||
input.maxLength = 32;
|
||||
input.value = words[index];
|
||||
input.addEventListener('input', () => onInput(index, input.value));
|
||||
|
||||
row.append(number, input);
|
||||
grid.append(row);
|
||||
return input;
|
||||
});
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent =
|
||||
'Можно вводить любые слова на любых языках. Можно заполнить не все 12 полей. В конце они просто склеиваются в один пароль длиной до 256 символов.';
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'status-line';
|
||||
|
||||
section.append(grid, hint);
|
||||
return { section, inputs, preview };
|
||||
function setStatus(statusEl, message) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = message ? '' : 'none';
|
||||
}
|
||||
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||
function createSecretOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'secret-generation-overlay';
|
||||
overlay.style.display = 'none';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'secret-generation-card stack';
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'secret-generation-spinner';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'secret-generation-title';
|
||||
title.textContent = 'Генерируем секрет…';
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'secret-generation-progress';
|
||||
progress.textContent = '';
|
||||
|
||||
card.append(spinner, title, progress);
|
||||
overlay.append(card);
|
||||
return { overlay, progress };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack auth-screen auth-screen--lower';
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'card stack';
|
||||
const login = String(state.loginDraft.login || '').trim();
|
||||
if (!login) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
}
|
||||
|
||||
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
||||
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.autocomplete = 'off';
|
||||
loginInput.autocapitalize = 'off';
|
||||
loginInput.spellcheck = false;
|
||||
loginInput.value = state.loginDraft.login;
|
||||
loginInput.placeholder = 'Введите логин';
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'login-panel-title';
|
||||
title.textContent = 'Введите пароль';
|
||||
|
||||
const passwordField = document.createElement('label');
|
||||
passwordField.className = 'stack';
|
||||
|
||||
const passwordInput = document.createElement('input');
|
||||
passwordInput.className = 'input';
|
||||
passwordInput.type = 'password';
|
||||
passwordInput.name = 'shine-login-password';
|
||||
passwordInput.autocomplete = 'new-password';
|
||||
passwordInput.autocomplete = 'current-password';
|
||||
passwordInput.autocapitalize = 'off';
|
||||
passwordInput.spellcheck = false;
|
||||
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
|
||||
passwordInput.placeholder = 'Введите пароль';
|
||||
passwordInput.placeholder = 'Пароль';
|
||||
passwordInput.value = '';
|
||||
|
||||
const {
|
||||
section: wordsSection,
|
||||
inputs: wordInputs,
|
||||
preview: wordsPreview,
|
||||
} = createWordsLayout({
|
||||
words: passwordWords,
|
||||
onInput: (index, value) => {
|
||||
passwordWords[index] = value;
|
||||
syncDraftState();
|
||||
updateWordsPreview();
|
||||
},
|
||||
});
|
||||
|
||||
const passwordModeToggle = document.createElement('label');
|
||||
passwordModeToggle.className = 'registration-toggle';
|
||||
|
||||
const passwordModeCheckbox = document.createElement('input');
|
||||
passwordModeCheckbox.type = 'checkbox';
|
||||
passwordModeCheckbox.checked = passwordMode === 'words';
|
||||
|
||||
const passwordModeLabel = document.createElement('span');
|
||||
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
|
||||
|
||||
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
||||
passwordField.append(passwordInput);
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
let passwordField = null;
|
||||
const passwordLengthText = document.createElement('p');
|
||||
passwordLengthText.className = 'status-line';
|
||||
|
||||
function getCurrentPassword() {
|
||||
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||
}
|
||||
|
||||
function syncDraftState() {
|
||||
state.loginDraft.login = loginInput.value.trim();
|
||||
state.loginDraft.passwordMode = passwordMode;
|
||||
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||
state.loginDraft.password = getCurrentPassword();
|
||||
}
|
||||
|
||||
function updateWordsPreview() {
|
||||
const password = getCurrentPassword();
|
||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
||||
wordsPreview.textContent = text;
|
||||
passwordLengthText.textContent = text;
|
||||
}
|
||||
|
||||
function updatePasswordModeVisibility() {
|
||||
const wordsMode = passwordMode === 'words';
|
||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
||||
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
|
||||
passwordInput.style.display = wordsMode ? 'none' : '';
|
||||
updateWordsPreview();
|
||||
}
|
||||
|
||||
form.innerHTML = `
|
||||
<label class="stack"><span class="field-label">Логин</span></label>
|
||||
<label class="stack"><span class="field-label">Пароль</span></label>
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
passwordField = form.children[1];
|
||||
passwordField.append(passwordInput);
|
||||
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
|
||||
updatePasswordModeVisibility();
|
||||
syncDraftState();
|
||||
|
||||
loginInput.addEventListener('input', syncDraftState);
|
||||
passwordInput.addEventListener('input', () => {
|
||||
syncDraftState();
|
||||
updateWordsPreview();
|
||||
});
|
||||
|
||||
passwordModeCheckbox.addEventListener('change', () => {
|
||||
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
|
||||
if (nextMode === passwordMode) return;
|
||||
if (nextMode === 'words') {
|
||||
passwordWords = emptyPasswordWords();
|
||||
wordInputs.forEach((input) => {
|
||||
input.value = '';
|
||||
});
|
||||
passwordInput.value = '';
|
||||
} else {
|
||||
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||
}
|
||||
passwordMode = nextMode;
|
||||
updatePasswordModeVisibility();
|
||||
updateWordsPreview();
|
||||
syncDraftState();
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'ghost-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const enterButton = document.createElement('button');
|
||||
enterButton.className = 'primary-btn';
|
||||
enterButton.type = 'button';
|
||||
enterButton.textContent = 'Войти';
|
||||
enterButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
syncDraftState();
|
||||
|
||||
if (!state.loginDraft.login) {
|
||||
status.textContent = 'Введите логин.';
|
||||
status.style.display = '';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.append(enterButton);
|
||||
|
||||
const { overlay, progress } = createSecretOverlay();
|
||||
|
||||
const submit = async () => {
|
||||
const currentLogin = String(state.loginDraft.login || '').trim();
|
||||
const password = String(passwordInput.value || '');
|
||||
setStatus(status, '');
|
||||
|
||||
if (!currentLogin) {
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
|
||||
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||
status.style.display = '';
|
||||
if (password.length > PASSWORD_MAX_LENGTH) {
|
||||
setStatus(status, `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.loginDraft.password = password;
|
||||
state.loginDraft.passwordMode = 'single';
|
||||
state.loginDraft.passwordWords = emptyPasswordWords();
|
||||
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
enterButton.disabled = true;
|
||||
enterButton.textContent = 'Входим...';
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
|
||||
|
||||
// Повторная проверка защищает UI от смены access server между первым экраном и входом.
|
||||
const resolved = await authService.resolveLoginForAuth(currentLogin);
|
||||
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
|
||||
state.loginDraft.login = String(resolved?.login || currentLogin).trim();
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.style.display = 'grid';
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(currentLogin, password, {
|
||||
onProgress: ({ percent }) => {
|
||||
const value = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
progress.textContent = value > 0 && value < 100 ? `${Math.round(value)}%` : '';
|
||||
},
|
||||
});
|
||||
overlay.style.display = 'none';
|
||||
|
||||
enterButton.textContent = 'Проверяем пароль…';
|
||||
const result = await authService.createAuthSession(currentLogin, keyBundle);
|
||||
|
||||
// Существующую дальнейшую логику после успешного пароля сохраняем без изменений.
|
||||
state.registrationDraft.flowType = 'login';
|
||||
state.registrationDraft.login = result.login;
|
||||
state.registrationDraft.password = state.loginDraft.password;
|
||||
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
|
||||
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||
state.registrationDraft.password = password;
|
||||
state.registrationDraft.passwordMode = 'single';
|
||||
state.registrationDraft.passwordWords = emptyPasswordWords();
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingKeyBundle = keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
overlay.style.display = 'none';
|
||||
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
setStatus(status, message);
|
||||
passwordInput.select();
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
enterButton.disabled = false;
|
||||
enterButton.textContent = 'Войти';
|
||||
}
|
||||
};
|
||||
|
||||
enterButton.addEventListener('click', submit);
|
||||
passwordInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(backButton, enterButton);
|
||||
panel.append(title, passwordField, status, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти по логину',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
form,
|
||||
actions,
|
||||
panel,
|
||||
overlay,
|
||||
);
|
||||
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
return screen;
|
||||
}
|
||||
|
||||
+143
-17
@@ -1,31 +1,156 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
clearAuthMessages,
|
||||
setAuthBusy,
|
||||
setAuthError,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { buildShineHttpUrlFromAddress } from '../services/shine-server-resolver.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false };
|
||||
|
||||
function setStatus(statusEl, message, kind = 'error') {
|
||||
statusEl.classList.toggle('is-unavailable', kind === 'error');
|
||||
statusEl.classList.toggle('is-available', kind !== 'error');
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = message ? '' : 'none';
|
||||
}
|
||||
|
||||
function setRemoteServer(remoteWrap, serverLoginEl, serverLinkEl, payload) {
|
||||
const serverLogin = String(payload?.accessServerLogin || '').trim();
|
||||
const serverUrl = String(payload?.accessServerUrl || '').trim();
|
||||
serverLoginEl.textContent = serverLogin ? `@${serverLogin}` : 'другой сервер доступа';
|
||||
serverLinkEl.textContent = serverUrl || 'Открыть сервер';
|
||||
serverLinkEl.href = buildShineHttpUrlFromAddress(serverUrl);
|
||||
remoteWrap.style.display = '';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
|
||||
|
||||
const loginButton = document.createElement('button');
|
||||
loginButton.className = 'ghost-btn';
|
||||
loginButton.type = 'button';
|
||||
loginButton.textContent = 'Войти по паролю';
|
||||
loginButton.addEventListener('click', () => navigate('login-password-view'));
|
||||
|
||||
const otherDeviceButton = document.createElement('button');
|
||||
otherDeviceButton.className = 'ghost-btn';
|
||||
otherDeviceButton.type = 'button';
|
||||
otherDeviceButton.textContent = 'Войти через другое устройство';
|
||||
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-actions login-actions-wide';
|
||||
actions.append(loginButton, otherDeviceButton);
|
||||
clearAuthMessages();
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel stack';
|
||||
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
|
||||
panel.append(actions);
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'login-panel-title';
|
||||
title.textContent = 'Введите логин';
|
||||
|
||||
const loginField = document.createElement('label');
|
||||
loginField.className = 'stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.autocomplete = 'username';
|
||||
loginInput.autocapitalize = 'off';
|
||||
loginInput.spellcheck = false;
|
||||
loginInput.placeholder = 'Логин';
|
||||
loginInput.value = String(state.loginDraft.login || '');
|
||||
|
||||
loginField.append(loginInput);
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const remoteWrap = document.createElement('div');
|
||||
remoteWrap.className = 'login-remote-server stack';
|
||||
remoteWrap.style.display = 'none';
|
||||
|
||||
const remoteText = document.createElement('p');
|
||||
remoteText.className = 'auth-copy';
|
||||
const serverLoginEl = document.createElement('strong');
|
||||
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
|
||||
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
|
||||
|
||||
const serverLinkEl = document.createElement('a');
|
||||
serverLinkEl.className = 'primary-btn login-server-link';
|
||||
serverLinkEl.target = '_self';
|
||||
serverLinkEl.rel = 'noopener';
|
||||
|
||||
remoteWrap.append(remoteText, serverLinkEl);
|
||||
|
||||
const passwordButton = document.createElement('button');
|
||||
passwordButton.className = 'primary-btn';
|
||||
passwordButton.type = 'button';
|
||||
passwordButton.textContent = 'Войти по паролю';
|
||||
|
||||
const deviceButton = document.createElement('button');
|
||||
deviceButton.className = 'ghost-btn';
|
||||
deviceButton.type = 'button';
|
||||
deviceButton.textContent = 'Войти через другое устройство';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-actions login-actions-wide';
|
||||
actions.append(passwordButton, deviceButton);
|
||||
|
||||
const resolveAndContinue = async (targetPage) => {
|
||||
const login = String(loginInput.value || '').trim();
|
||||
setStatus(status, '');
|
||||
remoteWrap.style.display = 'none';
|
||||
if (!login) {
|
||||
setStatus(status, 'Введите логин.');
|
||||
loginInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
passwordButton.disabled = true;
|
||||
deviceButton.disabled = true;
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const resolved = await authService.resolveLoginForAuth(login);
|
||||
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
|
||||
|
||||
if (resolution === 'NOT_FOUND') {
|
||||
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'NO_ACCESS_SERVER') {
|
||||
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'REMOTE') {
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
setRemoteServer(remoteWrap, serverLoginEl, serverLinkEl, resolved);
|
||||
return;
|
||||
}
|
||||
if (resolution !== 'LOCAL') {
|
||||
setStatus(status, 'Сервер вернул неизвестный статус проверки логина.');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
state.loginDraft.password = '';
|
||||
navigate(targetPage);
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось проверить логин.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message);
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
passwordButton.disabled = false;
|
||||
deviceButton.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
passwordButton.addEventListener('click', () => resolveAndContinue('login-password-view'));
|
||||
deviceButton.addEventListener('click', () => resolveAndContinue('login-other-device-view'));
|
||||
loginInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void resolveAndContinue('login-password-view');
|
||||
}
|
||||
});
|
||||
|
||||
panel.append(title, loginField, status, remoteWrap, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
@@ -35,5 +160,6 @@ export function render({ navigate }) {
|
||||
panel,
|
||||
);
|
||||
|
||||
window.setTimeout(() => loginInput.focus(), 0);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
authService,
|
||||
getChatMessages,
|
||||
isSessionInvalidError,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
@@ -8,8 +9,11 @@ import {
|
||||
} from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
@@ -22,6 +26,7 @@ const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
const dmAvatarPendingByLogin = new Map();
|
||||
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>';
|
||||
|
||||
const RELATION_ORDER = new Map([
|
||||
['close_friend', 0],
|
||||
@@ -55,7 +60,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'lg',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
@@ -70,7 +75,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'lg',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
@@ -109,6 +114,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
||||
}
|
||||
|
||||
async function resolveDialogPreview(dialog) {
|
||||
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
|
||||
if (localText) return localText;
|
||||
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
|
||||
if (!blobB64) return 'Диалог пока пуст.';
|
||||
|
||||
@@ -148,15 +155,31 @@ async function resolveDialogPreview(dialog) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
function resolveLocalMessageTimeMs(message) {
|
||||
const keys = [message?.baseKey, message?.messageKey];
|
||||
for (const key of keys) {
|
||||
const parts = String(key || '').split('|');
|
||||
const timeMs = Number(parts[2] || 0);
|
||||
if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs;
|
||||
}
|
||||
const tempParts = String(message?.tempId || '').split('-');
|
||||
const tempTimeMs = Number(tempParts[1] || 0);
|
||||
if (tempParts[0] === 'tmp' && Number.isFinite(tempTimeMs) && tempTimeMs > 0) return tempTimeMs;
|
||||
const createdAtMs = Number(message?.createdAtMs || message?.ts || 0);
|
||||
return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : 0;
|
||||
}
|
||||
|
||||
function latestLocalDialogMessage(peerLogin) {
|
||||
const messages = getChatMessages(peerLogin);
|
||||
const latest = [...messages].sort((a, b) => resolveLocalMessageTimeMs(b) - resolveLocalMessageTimeMs(a))[0];
|
||||
if (!latest) return null;
|
||||
const parsed = parseDmTechBlocks(String(latest?.text || ''));
|
||||
const text = clipPreviewText(String(parsed.displayText || parsed.visibleText || '').trim()) || 'Сообщение';
|
||||
return { text, timeMs: resolveLocalMessageTimeMs(latest) };
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
const value = Number(ts || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
return formatRelativeTime(ts);
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
@@ -176,12 +199,9 @@ export function render({ navigate, chrome }) {
|
||||
head.className = 'dm-head';
|
||||
head.innerHTML = `
|
||||
<div class="dm-head-brand">
|
||||
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||
<div class="dm-head-id">
|
||||
<span class="dm-head-name"></span>
|
||||
</div>
|
||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||
</div>
|
||||
<h1 class="dm-head-title">Чаты</h1>
|
||||
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
|
||||
<div class="dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
<div class="dm-head-menu" role="menu" hidden>
|
||||
@@ -195,9 +215,34 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
head.querySelector('.dm-head-logo-wrap')?.append(
|
||||
createShineConnectionsLogo({ className: 'dm-head-logo' }),
|
||||
);
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
|
||||
let currentChatFilter = 'all';
|
||||
const filterTitle = head.querySelector('#dm-chat-filter-title');
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
contact: 'Контакты',
|
||||
none: 'Новые',
|
||||
};
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
anchorEl: filterTitle,
|
||||
align: 'left',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
|
||||
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
||||
],
|
||||
});
|
||||
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
@@ -221,11 +266,13 @@ export function render({ navigate, chrome }) {
|
||||
const menuWidth = menuPortal.offsetWidth || 206;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
const titleRect = filterTitle?.getBoundingClientRect?.();
|
||||
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
|
||||
};
|
||||
|
||||
const openHeadMenu = () => {
|
||||
if (!menuButton || menuPortal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal';
|
||||
portal.setAttribute('role', 'menu');
|
||||
@@ -266,6 +313,10 @@ export function render({ navigate, chrome }) {
|
||||
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!menuPortal || event?.detail?.owner === menuButton) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !menuPortal) return;
|
||||
closeHeadMenu();
|
||||
@@ -274,6 +325,7 @@ export function render({ navigate, chrome }) {
|
||||
const onMenuViewportChange = () => positionHeadMenu();
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onMenuKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
@@ -354,24 +406,33 @@ function renderRow(item) {
|
||||
unreadCount: Number(dialog?.unreadCount || 0),
|
||||
hasDialog: Boolean(dialog?.hasDialog),
|
||||
};
|
||||
const localLatest = latestLocalDialogMessage(peerLogin);
|
||||
if (localLatest && localLatest.timeMs >= next.lastMessageTimeMs) {
|
||||
next.lastMessageText = localLatest.text;
|
||||
next.lastMessageTimeMs = localLatest.timeMs;
|
||||
}
|
||||
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 newest = next.lastMessageTimeMs > current.lastMessageTimeMs ? next : current;
|
||||
newest.relationFlag = relationOrder(relationFlag) < relationOrder(current.relationFlag)
|
||||
? relationFlag
|
||||
: current.relationFlag;
|
||||
newest.unreadCount = Math.max(Number(current.unreadCount || 0), Number(next.unreadCount || 0));
|
||||
newest.hasDialog = Boolean(current.hasDialog || next.hasDialog);
|
||||
byPeer.set(key, newest);
|
||||
});
|
||||
|
||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
const rows = Array.from(byPeer.values())
|
||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||
.sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
@@ -429,14 +490,18 @@ function renderRow(item) {
|
||||
}
|
||||
}
|
||||
|
||||
reloadForFilter = () => { void loadList(); };
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeHeadMenu();
|
||||
chatFilterMenu.destroy();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onMenuKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', onMenuViewportChange);
|
||||
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
import { createForceGraph } from './network/force-graph.js';
|
||||
import { engineModelFromGraphModel } from './network/adapter.js';
|
||||
import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
|
||||
import { openNodeMenu } from './network/node-menu.js';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
@@ -237,7 +237,6 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||
let engine = null;
|
||||
let sheetEl = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
||||
@@ -400,41 +399,6 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
}
|
||||
|
||||
// Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту.
|
||||
function showNodeSheet(node) {
|
||||
if (!sheetEl) {
|
||||
sheetEl = document.createElement('div');
|
||||
sheetEl.className = 'fg-sheet';
|
||||
stage.append(sheetEl);
|
||||
}
|
||||
const login = normalizeLogin(node.login);
|
||||
const shineBadge = node.shining ? '<span class="fg-sheet-badge">сияющий</span>' : '';
|
||||
sheetEl.innerHTML = `
|
||||
<button class="fg-sheet-close" type="button" data-act="close" aria-label="Закрыть">✕</button>
|
||||
<div class="fg-sheet-body">
|
||||
<div class="fg-sheet-title">${escapeHtml(login)} ${shineBadge}</div>
|
||||
<div class="fg-sheet-rel">${escapeHtml(relationLabelRu(node.relationType))}</div>
|
||||
</div>
|
||||
<div class="fg-sheet-actions">
|
||||
<button class="ghost-btn" type="button" data-act="profile">Профиль</button>
|
||||
<button class="primary-btn" type="button" data-act="write">Написать</button>
|
||||
</div>
|
||||
`;
|
||||
sheetEl.classList.add('is-open');
|
||||
sheetEl.onclick = (e) => {
|
||||
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null;
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const act = btn.dataset.act;
|
||||
if (act === 'close') hideNodeSheet();
|
||||
else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); }
|
||||
else if (act === 'write') navigate(`chat/${encodeURIComponent(login)}`);
|
||||
};
|
||||
}
|
||||
|
||||
function hideNodeSheet() {
|
||||
if (sheetEl) sheetEl.classList.remove('is-open');
|
||||
}
|
||||
|
||||
function ensureEngine(model) {
|
||||
if (engine) {
|
||||
engine.setModel(model);
|
||||
@@ -443,8 +407,8 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
engine = createForceGraph({
|
||||
stage: board,
|
||||
model,
|
||||
// тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет
|
||||
onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); },
|
||||
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
|
||||
onNodeTap: (node) => { void load(node.login, { pushHistory: true }); },
|
||||
// тап по центру — полноценный профиль
|
||||
onCenterTap: (node) => {
|
||||
const routeTo = profileInfoRoute(node.login);
|
||||
|
||||
@@ -21,8 +21,9 @@ import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// --- Параметры физики и анимации ---------------------------------------------
|
||||
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
|
||||
const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px
|
||||
const ORBIT_FIRST_R = 100; // первый круг связей: был ~150px, теперь на треть ближе к центру
|
||||
const ORBIT_GAP = 87; // между соседними кругами >= 1.5 диаметра аватарки (58px * 1.5)
|
||||
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
|
||||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||
@@ -146,11 +147,57 @@ function ensureShineFilter() {
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
|
||||
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
|
||||
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
|
||||
function spreadAngle(index, total) {
|
||||
// Равномерный угол по кругу. Небольшой постоянный сдвиг, чтобы первый узел
|
||||
// не «прилипал» к горизонтали.
|
||||
function spreadAngle(index, total, phase = 0.52) {
|
||||
if (total <= 0) return 0;
|
||||
return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2);
|
||||
return ((index / total) * Math.PI * 2 + phase) % (Math.PI * 2);
|
||||
}
|
||||
|
||||
// Сколько аватарок гарантированно помещается на круге радиуса r, если расстояние
|
||||
// между ЦЕНТРАМИ соседних аватарок должно быть не меньше ORBIT_NODE_GAP.
|
||||
// Используем хорду, а не длину дуги — поэтому условие выполняется геометрически точно.
|
||||
function orbitCapacity(radius) {
|
||||
const r = Math.max(radius, ORBIT_NODE_GAP / 2 + 1);
|
||||
const minAngle = 2 * Math.asin(Math.min(1, ORBIT_NODE_GAP / (2 * r)));
|
||||
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
|
||||
}
|
||||
|
||||
// Детерминированная раскладка первого уровня по концентрическим кругам.
|
||||
// Сначала максимально заполняем внутренний круг, затем второй, третий и т.д.
|
||||
// Соседние кольца разнесены минимум на 87px, а каждое следующее чуть повёрнуто,
|
||||
// чтобы линии второго/третьего круга естественно проходили под аватарками внутренних кругов.
|
||||
function buildOrbitPlacements(total) {
|
||||
const count = Math.max(0, Number(total) || 0);
|
||||
const out = new Array(count);
|
||||
let start = 0;
|
||||
let ring = 0;
|
||||
while (start < count) {
|
||||
const radius = ORBIT_FIRST_R + ring * ORBIT_GAP;
|
||||
const capacity = orbitCapacity(radius);
|
||||
const ringCount = Math.min(capacity, count - start);
|
||||
const phase = 0.52 + (ring % 2 ? Math.PI / Math.max(6, ringCount) : 0);
|
||||
for (let i = 0; i < ringCount; i += 1) {
|
||||
const angle = spreadAngle(i, ringCount, phase);
|
||||
out[start + i] = { radius, angle, ring };
|
||||
}
|
||||
start += ringCount;
|
||||
ring += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyOrbitTargets(peers) {
|
||||
const list = Array.isArray(peers) ? peers : [];
|
||||
const placements = buildOrbitPlacements(list.length);
|
||||
list.forEach((n, i) => {
|
||||
const p = placements[i] || { radius: ORBIT_FIRST_R, angle: spreadAngle(i, list.length), ring: 0 };
|
||||
n.targetR = p.radius;
|
||||
n.angle = p.angle;
|
||||
n.orbitRing = p.ring;
|
||||
n.tx = Math.cos(n.angle) * n.targetR;
|
||||
n.ty = Math.sin(n.angle) * n.targetR;
|
||||
});
|
||||
}
|
||||
|
||||
// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
|
||||
@@ -360,8 +407,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
}
|
||||
const specs = [];
|
||||
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
||||
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
|
||||
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES }));
|
||||
const tier1Orbit = buildOrbitPlacements(tier1.length);
|
||||
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null });
|
||||
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] }));
|
||||
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
|
||||
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
|
||||
return { focusId: fId, specs };
|
||||
@@ -370,17 +418,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
function buildNodes(srcModel) {
|
||||
const { focusId: fId, specs } = computeSpecs(srcModel);
|
||||
focusId = fId;
|
||||
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly));
|
||||
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly, s.orbit));
|
||||
}
|
||||
|
||||
function makeNodeState(src, isFocus, index, total, dotOnly = false) {
|
||||
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
|
||||
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||
const tier = Number(src.tier) || 1;
|
||||
// органическая неровность: детерминированный джиттер радиуса (±9px) и угла (±0.2 рад)
|
||||
const jr = (hash01(src.id) - 0.5) * 18;
|
||||
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
||||
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
||||
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
|
||||
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера:
|
||||
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками.
|
||||
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R);
|
||||
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
|
||||
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
|
||||
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
||||
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
|
||||
@@ -404,6 +451,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
strength,
|
||||
targetR,
|
||||
angle,
|
||||
orbitRing: orbit?.ring || 0,
|
||||
tx,
|
||||
ty,
|
||||
x: tx * INTRO_FACTOR,
|
||||
@@ -567,10 +615,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node.strength = strength;
|
||||
node.relationType = src.relationType;
|
||||
node.shining = Boolean(src.shining);
|
||||
const jr = (hash01(src.id) - 0.5) * 18;
|
||||
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
||||
node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
||||
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
|
||||
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
|
||||
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
|
||||
node.orbitRing = spec.orbit?.ring || 0;
|
||||
node.tx = Math.cos(node.angle) * node.targetR;
|
||||
node.ty = Math.sin(node.angle) * node.targetR;
|
||||
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
|
||||
@@ -990,17 +1037,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
target.vy = 0;
|
||||
to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });
|
||||
|
||||
peers.forEach((n, i) => {
|
||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
||||
n.angle = spreadAngle(i, peers.length);
|
||||
applyOrbitTargets(peers);
|
||||
peers.forEach((n) => {
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
const tx = Math.cos(n.angle) * n.targetR;
|
||||
const ty = Math.sin(n.angle) * n.targetR;
|
||||
n.tx = tx; // обновляем целевую точку, иначе физика после твина утянет узел назад
|
||||
n.ty = ty;
|
||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||
to.set(n.id, { x: tx, y: ty, scale: sc });
|
||||
to.set(n.id, { x: n.tx, y: n.ty, scale: sc });
|
||||
});
|
||||
|
||||
tween = {
|
||||
@@ -1091,11 +1133,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
const focus = nodes.find((n) => n.isFocus);
|
||||
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
|
||||
|
||||
visiblePeers.forEach((n, i) => {
|
||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
||||
n.angle = spreadAngle(i, visiblePeers.length);
|
||||
n.tx = Math.cos(n.angle) * n.targetR;
|
||||
n.ty = Math.sin(n.angle) * n.targetR;
|
||||
applyOrbitTargets(visiblePeers);
|
||||
visiblePeers.forEach((n) => {
|
||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||
apply(n, n.tx, n.ty, sc, 1);
|
||||
});
|
||||
@@ -1598,7 +1637,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
node = oldNode; isNew = false;
|
||||
} else {
|
||||
if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
|
||||
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
|
||||
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly, spec.orbit);
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ function renderIdentity(item) {
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'notification-avatar',
|
||||
}));
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export function render({ navigate, chrome }) {
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'large',
|
||||
size: 'xl',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
PROFILE_GENDER_MALE,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -110,84 +111,19 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionProfileMenu = () => {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
};
|
||||
|
||||
const openProfileMenu = () => {
|
||||
if (!profileMenuButton || profileMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const goTo = (route) => {
|
||||
closeProfileMenu();
|
||||
navigate(route);
|
||||
};
|
||||
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
||||
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
positionProfileMenu();
|
||||
};
|
||||
|
||||
profileMenuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (profileMenuPortal) closeProfileMenu();
|
||||
else openProfileMenu();
|
||||
const profileMenu = createDropdownMenu({
|
||||
anchorEl: profileMenuButton,
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 230,
|
||||
items: [
|
||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||
],
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileMenuPortal) return;
|
||||
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
||||
closeProfileMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
||||
closeProfileMenu();
|
||||
profileMenuButton?.focus();
|
||||
});
|
||||
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
||||
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
||||
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
@@ -223,6 +159,12 @@ export function render({ navigate, chrome }) {
|
||||
let currentToggles = [];
|
||||
let currentGender = 'unknown';
|
||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
let currentStats = {
|
||||
ownedPublicChannelsCount: 0,
|
||||
followingUsersCount: 0,
|
||||
followingChannelsCount: 0,
|
||||
closeFriendsCount: 0,
|
||||
};
|
||||
|
||||
function syncIdentity() {
|
||||
if (!identityEl) return;
|
||||
@@ -246,7 +188,7 @@ export function render({ navigate, chrome }) {
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'large',
|
||||
size: 'xl',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
}
|
||||
@@ -269,6 +211,21 @@ export function render({ navigate, chrome }) {
|
||||
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const stats = [
|
||||
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
|
||||
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
|
||||
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
|
||||
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
|
||||
];
|
||||
stats.forEach((stat) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card profile-param-item row';
|
||||
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
|
||||
listWrap.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
officialBtn?.classList.add('profile-badge-trigger');
|
||||
shineBtn?.classList.add('profile-badge-trigger');
|
||||
officialBtn?.addEventListener('click', () => {
|
||||
@@ -286,6 +243,7 @@ export function render({ navigate, chrome }) {
|
||||
|
||||
function renderFields(fields) {
|
||||
listWrap.innerHTML = '';
|
||||
renderStats();
|
||||
fields.forEach((field) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card profile-param-item row';
|
||||
@@ -317,6 +275,12 @@ export function render({ navigate, chrome }) {
|
||||
];
|
||||
currentGender = 'unknown';
|
||||
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
currentStats = {
|
||||
ownedPublicChannelsCount: 0,
|
||||
followingUsersCount: 0,
|
||||
followingChannelsCount: 0,
|
||||
closeFriendsCount: 0,
|
||||
};
|
||||
syncIdentity();
|
||||
updateAvatarUi();
|
||||
updateTogglesUi();
|
||||
@@ -325,11 +289,20 @@ export function render({ navigate, chrome }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await loadProfileSnapshot(login);
|
||||
const [snapshot, user] = await Promise.all([
|
||||
loadProfileSnapshot(login),
|
||||
authService.getUser(login).catch(() => ({})),
|
||||
]);
|
||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
||||
currentGender = snapshot.gender || 'unknown';
|
||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
currentStats = {
|
||||
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||
followingUsersCount: Number(user?.followingUsersCount || 0),
|
||||
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||
};
|
||||
syncIdentity();
|
||||
updateAvatarUi();
|
||||
updateTogglesUi();
|
||||
@@ -345,5 +318,6 @@ export function render({ navigate, chrome }) {
|
||||
updateAvatarUi();
|
||||
refreshProfileSnapshot();
|
||||
|
||||
screen.cleanup = () => profileMenu.destroy();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'connect-device',
|
||||
shortTitle: 'Подключить по коду',
|
||||
title: 'Как подключить другое устройство?',
|
||||
paragraphs: [
|
||||
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
|
||||
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
|
||||
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hardware-device',
|
||||
shortTitle: 'ESP32',
|
||||
|
||||
@@ -86,7 +86,7 @@ export function render({ navigate }) {
|
||||
|
||||
const deviceRow = createKeyInfo(
|
||||
deviceToggle,
|
||||
'Ключ device (всегда)',
|
||||
'Client key (всегда)',
|
||||
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
|
||||
);
|
||||
|
||||
|
||||
@@ -40,28 +40,28 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="text-btn" type="button" id="settings-access-servers">
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Серверы доступа</strong>
|
||||
<strong>Сервер доступа</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" type="button" id="settings-blockchain-servers">
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Серверы блокчейнов</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" type="button" id="settings-arweave-uploads">
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Загрузить файлы в блокчейн</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
@@ -69,7 +69,10 @@ export function render({ navigate }) {
|
||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => {
|
||||
sessionStorage.setItem('shine-language-return-page', 'settings-view');
|
||||
navigate('language-view');
|
||||
});
|
||||
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
@@ -117,7 +120,7 @@ export function render({ navigate }) {
|
||||
const developerCard = document.createElement('div');
|
||||
developerCard.className = 'card stack';
|
||||
developerCard.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-developer">Настройки разработчика</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-developer">Настройки разработчика</button>
|
||||
`;
|
||||
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
|
||||
|
||||
|
||||
@@ -36,11 +36,14 @@ export function render({ navigate }) {
|
||||
registerButton.textContent = 'Зарегистрироваться';
|
||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||
|
||||
const guestViewButton = document.createElement('button');
|
||||
guestViewButton.className = 'shine-btn shine-btn--view';
|
||||
guestViewButton.type = 'button';
|
||||
guestViewButton.textContent = 'Только просмотр';
|
||||
guestViewButton.addEventListener('click', () => navigate('network-view'));
|
||||
const languageButton = document.createElement('button');
|
||||
languageButton.className = 'shine-btn shine-btn--view';
|
||||
languageButton.type = 'button';
|
||||
languageButton.textContent = 'Язык / Language';
|
||||
languageButton.addEventListener('click', () => {
|
||||
sessionStorage.setItem('shine-language-return-page', 'start-view');
|
||||
navigate('language-view');
|
||||
});
|
||||
|
||||
const settingsButton = document.createElement('button');
|
||||
settingsButton.className = 'shine-btn shine-btn--settings';
|
||||
@@ -48,7 +51,7 @@ export function render({ navigate }) {
|
||||
settingsButton.textContent = 'Настройки';
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
||||
actions.append(loginButton, registerButton, languageButton, settingsButton);
|
||||
screen.append(logoWrap, title, actions);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ function renderIdentity(card) {
|
||||
firstName: card.firstName,
|
||||
lastName: card.lastName,
|
||||
avatar: card.avatar,
|
||||
size: 'large',
|
||||
size: 'xl',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
|
||||
|
||||
+45
-1
@@ -26,6 +26,7 @@ const PRETTY_PATHS = new Map([
|
||||
['add-channel-view', 'channels/new'],
|
||||
['add-personal-public-chat-view', 'channels/new-public-chat'],
|
||||
['channel-view', 'channel'],
|
||||
['channel-about-view', 'channel/about'],
|
||||
['channel-thread-view', 'thread'],
|
||||
['network-view', 'network'],
|
||||
['notifications-view', 'notifications'],
|
||||
@@ -52,6 +53,10 @@ const PRETTY_PATHS = new Map([
|
||||
['remote-addblock-session-view', 'remote-addblock-session'],
|
||||
]);
|
||||
|
||||
function looksLikeBlockchainName(value) {
|
||||
return /^.+-\d+$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export const PRE_AUTH_PAGES = [
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
@@ -162,6 +167,34 @@ export function parseRouteFromPath(pathname = '') {
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length >= 2 && looksLikeBlockchainName(segments[0])) {
|
||||
const ownerBlockchainName = decodePart(segments[0]);
|
||||
const channelName = decodePart(segments[1] || '');
|
||||
const sub = decodePart(segments[2] || '').toLowerCase();
|
||||
if (ownerBlockchainName && channelName) {
|
||||
if (sub === 'about') {
|
||||
return {
|
||||
pageId: 'channel-about-view',
|
||||
params: {
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: '',
|
||||
channelRootBlockHash: '',
|
||||
channelId: '',
|
||||
channelName,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
pageId: 'channel-view',
|
||||
params: {
|
||||
ownerBlockchainName,
|
||||
channelName,
|
||||
channelId: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (pageId === 'chat' || pageId === 'chat-view') {
|
||||
return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||
}
|
||||
@@ -225,6 +258,17 @@ export function parseRouteFromPath(pathname = '') {
|
||||
}
|
||||
|
||||
if (pageId === 'channel') {
|
||||
if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') {
|
||||
return {
|
||||
pageId: 'channel-about-view',
|
||||
params: {
|
||||
ownerBlockchainName: decodePart(segments[1]),
|
||||
channelRootBlockNumber: segments[2] || '',
|
||||
channelRootBlockHash: segments[3] || '',
|
||||
channelId: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (segments.length >= 4) {
|
||||
return {
|
||||
pageId: 'channel-view',
|
||||
@@ -408,7 +452,7 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'solana-users-init-view'
|
||||
) return 'profile-view';
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||
if (pageId === 'user') return 'messages-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
@@ -1042,6 +1042,14 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async resolveLoginForAuth(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Введите логин');
|
||||
const response = await this.ws.request('ResolveLoginForAuth', { login: cleanLogin });
|
||||
if (response.status !== 200) throw opError('ResolveLoginForAuth', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async resolveCanonicalDisplayLogin(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return '';
|
||||
@@ -1158,6 +1166,7 @@ export class AuthService {
|
||||
|
||||
const sessionId = createResp?.payload?.sessionId;
|
||||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||||
const connectionScope = String(createResp?.payload?.connectionScope || '').trim().toUpperCase();
|
||||
|
||||
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
@@ -1165,6 +1174,7 @@ export class AuthService {
|
||||
login: canonicalLogin,
|
||||
sessionId,
|
||||
storagePwd,
|
||||
connectionScope,
|
||||
sessionMaterial: {
|
||||
sessionId,
|
||||
sessionKey,
|
||||
@@ -1322,6 +1332,7 @@ export class AuthService {
|
||||
|
||||
const storagePwd = loginResp?.payload?.storagePwd;
|
||||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||||
const connectionScope = String(loginResp?.payload?.connectionScope || '').trim().toUpperCase();
|
||||
|
||||
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
|
||||
|
||||
@@ -1329,6 +1340,7 @@ export class AuthService {
|
||||
login: canonicalLogin,
|
||||
sessionId: targetSessionId,
|
||||
storagePwd,
|
||||
connectionScope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2611,14 +2623,8 @@ export class AuthService {
|
||||
|
||||
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
|
||||
const body = { incomingBlobB64, outgoingBlobB64 };
|
||||
const primaryOp = 'ReceiveOutcomingMessage';
|
||||
let response = await this.ws.request(primaryOp, body);
|
||||
if (response.status === 404) {
|
||||
response = await this.ws.request('SendMessagePair', body);
|
||||
if (response.status !== 200) throw opError('SendMessagePair', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
if (response.status !== 200) throw opError(primaryOp, response);
|
||||
const response = await this.ws.request('SendMessagePair', body);
|
||||
if (response.status !== 200) throw opError('SendMessagePair', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
@@ -2938,7 +2944,6 @@ export class AuthService {
|
||||
valueText = '',
|
||||
valueNum = 0,
|
||||
storagePwd,
|
||||
syncDelivery = false,
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanSettingKey = String(settingKey || '').trim();
|
||||
@@ -2977,7 +2982,6 @@ export class AuthService {
|
||||
value_num: Math.trunc(cleanValueNum),
|
||||
client_key: clientKey,
|
||||
signature,
|
||||
sync_delivery: !!syncDelivery,
|
||||
});
|
||||
if (response.status !== 200) throw opError('UpsertUserSetting', response);
|
||||
return response.payload || {};
|
||||
|
||||
@@ -153,7 +153,7 @@ function getCallTitleText(mode) {
|
||||
return 'Видеозвонок';
|
||||
}
|
||||
if (normalizeCallMode(mode) === CALL_MODE_VIDEO) {
|
||||
return 'Звонок с поддержкой видео';
|
||||
return 'Видеозвонок';
|
||||
}
|
||||
return 'Звонок';
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function getIncomingCallStatusText(peerLogin, mode) {
|
||||
return `Входящий видеозвонок от ${name}`;
|
||||
}
|
||||
if (isVideoCallMode(mode)) {
|
||||
return `Вам звонит ${name} (звонок с поддержкой видео)`;
|
||||
return `Входящий видеозвонок от ${name}`;
|
||||
}
|
||||
return `Вам звонит ${name}`;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ function renderPeerAvatar(login, avatar = null) {
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: String(login || '').trim() || 'unknown',
|
||||
avatar,
|
||||
size: 'large',
|
||||
size: 'xl',
|
||||
className: 'call-peer-avatar',
|
||||
title: login ? `Профиль ${login}` : 'Профиль собеседника',
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const rtf = (() => {
|
||||
try {
|
||||
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
|
||||
return new Intl.RelativeTimeFormat('ru', { numeric: 'always' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -33,41 +33,30 @@ export function formatRelativeTime(timestampMs) {
|
||||
if (!ts) return '—';
|
||||
|
||||
const now = Date.now();
|
||||
const dt = new Date(ts);
|
||||
const nowDt = new Date(now);
|
||||
const diffSeconds = (ts - now) / 1000;
|
||||
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
|
||||
const ageHours = ageSeconds / 3600;
|
||||
const ageSeconds = Math.max(0, (now - ts) / 1000);
|
||||
const ageDays = ageSeconds / 86400;
|
||||
|
||||
if (ageHours <= 10) {
|
||||
if (ageDays < 7) {
|
||||
const [unit, value] = pickUnit(diffSeconds);
|
||||
if (rtf) return rtf.format(value, unit);
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const suffix = value <= 0 ? 'назад' : 'через';
|
||||
const labels = {
|
||||
second: 'сек',
|
||||
minute: 'мин',
|
||||
hour: 'ч',
|
||||
day: 'д',
|
||||
month: 'мес',
|
||||
year: 'г',
|
||||
};
|
||||
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
|
||||
const labels = { second: 'сек', minute: 'мин', hour: 'ч', day: 'д' };
|
||||
return `${absValue} ${labels[unit] || ''} ${suffix}`.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const dt = new Date(ts);
|
||||
const nowDt = new Date(now);
|
||||
const formatter = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return formatter.format(dt);
|
||||
} catch {
|
||||
return new Date(ts).toLocaleString();
|
||||
if (dt.getFullYear() === nowDt.getFullYear()) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
}).format(dt).replace(',', ',');
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
}).format(dt);
|
||||
}
|
||||
|
||||
function ensureToastHost() {
|
||||
|
||||
@@ -51,6 +51,26 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
|
||||
return `${SHINE_ROUTE_SEGMENT}/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||
}
|
||||
|
||||
export function makeShineChannelRootRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
|
||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||
const rootNo = String(channelRootBlockNumber || '').trim();
|
||||
const rootHash = String(channelRootBlockHash || '').trim();
|
||||
if (!ownerBch || !rootNo || !rootHash) return '';
|
||||
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(rootNo)}/${encodeRoutePart(rootHash)}`;
|
||||
}
|
||||
|
||||
export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
|
||||
const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash });
|
||||
return base ? `${base}/about` : '';
|
||||
}
|
||||
|
||||
export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) {
|
||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||
const chName = String(channelName || '').trim();
|
||||
if (!ownerBch || !chName) return '';
|
||||
return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||
}
|
||||
|
||||
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
||||
const msgBch = String(messageBlockchainName || '').trim();
|
||||
const msgNo = String(messageBlockNumber || '').trim();
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function resolveShineServerForUserLogin({ login, solanaEndpoint, fa
|
||||
solanaEndpoint,
|
||||
});
|
||||
const accessServers = Array.isArray(parsedUser?.accessServers)
|
||||
? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean).slice(0, 2)
|
||||
? parsedUser.accessServers.map((value) => normalizeShineServerLogin(value)).filter(Boolean).slice(0, 1)
|
||||
: [];
|
||||
const pickedServerLogin = accessServers[0] || normalizeShineServerLogin(fallbackServerLogin);
|
||||
if (!pickedServerLogin) {
|
||||
|
||||
@@ -30,7 +30,7 @@ const BLOCK_TYPE_TRUSTED_STATE = 70;
|
||||
const SESSIONS_MODE_MIXED = 1;
|
||||
const SESSION_TYPE_USER = 1;
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
const MAX_EFFECTIVE_ACCESS_SERVERS = 2;
|
||||
const MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||
|
||||
let solanaLibPromise = null;
|
||||
function loadSolanaLib() {
|
||||
|
||||
@@ -50,6 +50,14 @@ export function toUserMessage(error, fallback = 'Действие не выпо
|
||||
return 'Пользователь не найден. Проверьте логин.';
|
||||
}
|
||||
|
||||
if (code === 'DEVICE_KEY_NOT_ACTUAL') {
|
||||
return 'Неверный пароль.';
|
||||
}
|
||||
|
||||
if (code === 'USER_NOT_LOCAL') {
|
||||
return 'Этот пользователь относится к другому серверу доступа.';
|
||||
}
|
||||
|
||||
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
|
||||
return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
|
||||
}
|
||||
|
||||
+47
-6
@@ -300,15 +300,15 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
keyStorage: {
|
||||
rootKey: 'Ключ root хранится в зашифрованном виде',
|
||||
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
||||
clientKey: 'Ключ device хранится в зашифрованном виде',
|
||||
clientKey: 'Client key хранится в зашифрованном виде',
|
||||
saveRoot: true,
|
||||
saveBlockchain: true,
|
||||
saveDevice: true,
|
||||
saveClient: true,
|
||||
},
|
||||
deviceConnect: {
|
||||
root: true,
|
||||
blockchain: true,
|
||||
device: true,
|
||||
client: true,
|
||||
},
|
||||
authUi: {
|
||||
busy: false,
|
||||
@@ -414,6 +414,7 @@ function persistMessageRecord(chatId, row) {
|
||||
readAtMs: Number(row.readAtMs || 0),
|
||||
readReceiptSent: Boolean(row.readReceiptSent),
|
||||
refBaseKey: String(row.refBaseKey || ''),
|
||||
deliveryState: String(row.deliveryState || ''),
|
||||
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -450,6 +451,7 @@ export async function hydrateMessagesFromStore() {
|
||||
readAtMs: Number(row.readAtMs || 0),
|
||||
readReceiptSent: Boolean(row.readReceiptSent),
|
||||
refBaseKey: String(row.refBaseKey || ''),
|
||||
deliveryState: String(row.deliveryState || ''),
|
||||
createdAtMs: Number(row.ts || 0),
|
||||
});
|
||||
});
|
||||
@@ -531,7 +533,11 @@ export function addOutgoingPendingMessage(chatId, text) {
|
||||
return tempId;
|
||||
}
|
||||
|
||||
export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) {
|
||||
export function markOutgoingSent(tempId, {
|
||||
messageKey = '',
|
||||
baseKey = '',
|
||||
deliveryState = 'accepted',
|
||||
} = {}) {
|
||||
if (!tempId) return;
|
||||
const keys = Object.keys(state.chats || {});
|
||||
keys.forEach((chatId) => {
|
||||
@@ -541,6 +547,7 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
|
||||
row.firstTick = true;
|
||||
row.messageKey = messageKey || row.messageKey || '';
|
||||
row.baseKey = baseKey || row.baseKey || '';
|
||||
row.deliveryState = String(deliveryState || row.deliveryState || 'accepted');
|
||||
if (messageKey) {
|
||||
state.knownMessageKeys[messageKey] = true;
|
||||
persistMessageRecord(chatId, row);
|
||||
@@ -549,6 +556,33 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
|
||||
});
|
||||
}
|
||||
|
||||
export function markOutgoingDeliveryState({
|
||||
outgoingKey = '',
|
||||
baseKey = '',
|
||||
deliveryState = '',
|
||||
} = {}) {
|
||||
const normalizedOutgoingKey = String(outgoingKey || '').trim();
|
||||
const normalizedBaseKey = String(baseKey || '').trim();
|
||||
let changed = false;
|
||||
Object.keys(state.chats || {}).forEach((chatId) => {
|
||||
getChatMessages(chatId).forEach((row) => {
|
||||
if (row?.from !== 'out') return;
|
||||
const matches = (
|
||||
(normalizedOutgoingKey && String(row.messageKey || '') === normalizedOutgoingKey)
|
||||
|| (normalizedBaseKey && String(row.baseKey || '') === normalizedBaseKey)
|
||||
);
|
||||
if (!matches) return;
|
||||
row.deliveryState = String(deliveryState || row.deliveryState || 'accepted');
|
||||
if (row.deliveryState === 'delivered') {
|
||||
row.firstTick = true;
|
||||
}
|
||||
persistMessageRecord(chatId, row);
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function markOutgoingReadByBaseKey(baseKey, readAtMs = 0) {
|
||||
if (!baseKey) return;
|
||||
const normalizedReadAtMs = Number(readAtMs || 0);
|
||||
@@ -629,6 +663,7 @@ export function addSignedMessageToChat({
|
||||
rawBlobB64 = '',
|
||||
refBaseKey = '',
|
||||
revisionTimeMs = 0,
|
||||
deliveryState = '',
|
||||
deleted = false,
|
||||
} = {}) {
|
||||
const normalizedChatId = normalizeDmChatId(chatId);
|
||||
@@ -663,6 +698,7 @@ export function addSignedMessageToChat({
|
||||
row.messageType = Number(messageType || 0);
|
||||
row.rawBlobB64 = String(rawBlobB64 || '');
|
||||
row.revisionTimeMs = nextRevision;
|
||||
row.deliveryState = String(deliveryState || existing?.deliveryState || (row.from === 'out' ? 'accepted' : ''));
|
||||
row.unread = row.from === 'in' ? Boolean(unread) : false;
|
||||
row.refBaseKey = String(refBaseKey || '');
|
||||
row.firstTick = row.from === 'out';
|
||||
@@ -812,6 +848,11 @@ export function checkServerAvailability(address) {
|
||||
return /^(https?:\/\/|wss?:\/\/)/i.test(normalized) ? 'available' : 'unavailable';
|
||||
}
|
||||
|
||||
export function saveEntryLanguage(language) {
|
||||
state.entrySettings.language = String(language || 'ru');
|
||||
persistEntrySettings(state.entrySettings);
|
||||
}
|
||||
|
||||
export async function saveEntrySettings(nextSettings) {
|
||||
const nextSolanaServer = normalizeStoredSolanaServer(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER);
|
||||
const nextShineServerLogin = String(nextSettings?.shineServerLogin || state.entrySettings.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE).trim().toLowerCase()
|
||||
@@ -977,7 +1018,7 @@ async function tryCloseCurrentSessionOnServer() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false } = {}) {
|
||||
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
|
||||
if (closeServerSession) {
|
||||
await tryCloseCurrentSessionOnServer();
|
||||
}
|
||||
@@ -995,7 +1036,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
|
||||
} catch {
|
||||
// ignore reconnect errors on sign out
|
||||
}
|
||||
if (onSessionReset) {
|
||||
if (notifySessionReset && onSessionReset) {
|
||||
onSessionReset();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user