Merge origin/main into ui-artem

This commit is contained in:
2026-09-01 12:39:02 +03:00
177 changed files with 9796 additions and 4148 deletions
+57 -5
View File
@@ -5,8 +5,13 @@ import {
syncTrackedRouteHistory,
} from './router.js';
import { renderToolbar } from './components/toolbar.js';
import {
captureClientError,
getLastClientErrorPayload,
setClientErrorSentNotifier,
setClientErrorTransport,
} from './services/client-error-reporter.js';
import { attachScrollToBottomButton } from './components/scroll-to-bottom-button.js';
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js';
@@ -38,6 +43,7 @@ import {
deleteConversationMessagesBefore,
deleteSignedMessageByBaseKey,
markIncomingReadByBaseKey,
markOutgoingDeliveryState,
markOutgoingReadByBaseKey,
normalizeDmChatId,
setContacts,
@@ -87,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';
@@ -149,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,
@@ -192,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',
@@ -218,6 +251,10 @@ setClientErrorSentNotifier((payload) => {
const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString();
showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`);
});
window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ = (payload) => authService.reportClientError(payload);
window.__SHINE_SEND_CLIENT_ERROR__ = window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__;
window.__SHINE_GET_LAST_CLIENT_ERROR__ = () => getLastClientErrorPayload();
window.__SHINE_SHOW_TOAST__ = (message, kind = 'success') => showToast(message, { kind });
initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
@@ -1162,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;
@@ -1191,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) {
@@ -1208,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) {
@@ -1381,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();
+7 -4
View File
@@ -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,
+139
View File
@@ -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);
},
};
}
+10
View File
@@ -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;
}
+6 -1
View File
@@ -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>`;
}
+28 -91
View File
@@ -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,
+2 -2
View File
@@ -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);
}
+3 -3
View File
@@ -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());
+182
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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;
}
+2 -2
View File
@@ -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,
});
+237 -35
View File
@@ -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,
});
@@ -236,9 +237,200 @@ function buildThreadRoute(messageRef, selector) {
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
const ownerBch = String(ownerBlockchainName || '').trim();
const name = String(channelName || '').trim();
if (!ownerBch || !name) return '';
return `${ownerBch}/${name}`;
}
function getChannelScrollRoot() {
return document.getElementById('app-screen');
}
function scrollRootBy(delta, smooth = false) {
const root = getChannelScrollRoot();
const behavior = smooth ? 'smooth' : 'auto';
if (root && typeof root.scrollBy === 'function') {
root.scrollBy({ top: delta, behavior });
return;
}
window.scrollBy({ top: delta, behavior });
}
function getUnreadAnchorViewportFraction(unreadCount = 0) {
const count = Math.max(0, Number(unreadCount || 0));
if (count <= 1) return 0.68;
if (count <= 3) return 0.56;
if (count <= 7) return 0.48;
return 0.42;
}
function scrollElementToViewportFraction(element, fraction = 1 / 3, smooth = false) {
if (!element) return false;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const targetTop = Math.max(0, Math.round(viewportHeight * fraction));
const rect = element.getBoundingClientRect();
const delta = rect.top - targetTop;
if (Math.abs(delta) < 2) return true;
scrollRootBy(delta, smooth);
return true;
}
function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
return scrollElementToViewportFraction(
screen.querySelector('.channel-unread-line'),
getUnreadAnchorViewportFraction(unreadCount),
smooth,
);
}
function createChannelReadTracker({
screen,
routeKey,
settingKey,
unreadCount,
messagesCount,
initialSeenCount,
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
const canWrite = !!(settingKey && login && storagePwd);
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount;
let inFlight = false;
let disposed = false;
let rafId = 0;
let timerId = 0;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = 0;
}
};
const queueFlush = (delayMs = 180) => {
if (disposed || !canWrite) return;
clearTimer();
timerId = setTimeout(() => {
timerId = 0;
void flush();
}, Math.max(0, Number(delayMs) || 0));
};
const flush = async () => {
if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount) return;
if (inFlight) {
queueFlush(120);
return;
}
inFlight = true;
try {
await authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: next,
storagePwd,
});
persistedSeenCount = next;
} catch {
queueFlush(800);
} finally {
inFlight = false;
}
};
const collectSeenCount = () => {
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
if (!cards.length) return safeInitialSeenCount;
if (!unreadLine) return safeMessagesCount;
const root = getChannelScrollRoot();
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
const thresholdTop = Math.max(0, Math.round(viewportHeight * unreadAnchorFraction));
let seen = safeInitialSeenCount;
for (const card of cards) {
const localNumber = Number(card.dataset.localNumber || 0);
if (!Number.isFinite(localNumber) || localNumber <= 0) continue;
const rect = card.getBoundingClientRect();
if (rect.top > thresholdTop + 1) break;
seen = Math.max(seen, localNumber);
}
return Math.max(safeInitialSeenCount, Math.min(seen, safeMessagesCount));
};
const measure = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
const next = collectSeenCount();
if (next > desiredSeenCount) {
desiredSeenCount = next;
queueFlush(180);
}
});
};
const scrollRoot = getChannelScrollRoot();
const onScroll = () => measure();
const onResize = () => measure();
if (scrollRoot && typeof scrollRoot.addEventListener === 'function') {
scrollRoot.addEventListener('scroll', onScroll, { passive: true });
} else {
window.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
const initialSyncRequired = canWrite && unreadCount <= 0 && safeMessagesCount >= 0;
if (initialSyncRequired) {
void authService.upsertUserSetting({
login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: safeMessagesCount,
storagePwd,
}).catch(() => {});
persistedSeenCount = safeMessagesCount;
desiredSeenCount = safeMessagesCount;
} else {
window.setTimeout(() => measure(), 120);
}
const cleanup = () => {
disposed = true;
clearTimer();
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollRoot && typeof scrollRoot.removeEventListener === 'function') {
scrollRoot.removeEventListener('scroll', onScroll);
} else {
window.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
};
return {
cleanup,
measure,
};
}
function firstNonEmptyText(...candidates) {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
@@ -385,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: 'Аватар канала',
@@ -434,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 = '';
@@ -1445,6 +1637,7 @@ async function loadFromApi(route, channelId) {
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
ownerBlockchainName: String(payload.channel?.ownerBlockchainName || '').trim(),
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: String(payload.channel?.channelDescription || '').trim(),
@@ -1747,6 +1940,9 @@ function renderPostCard(post, {
if (refKey) {
card.dataset.messageKey = refKey;
}
if (Number.isFinite(Number(post.localNumber)) && Number(post.localNumber) > 0) {
card.dataset.localNumber = String(Number(post.localNumber));
}
card.classList.add('is-counters-visible');
if (!post.messageRef || !selector) return card;
@@ -1912,6 +2108,10 @@ function renderPostCard(post, {
}
function renderBody(screen, navigate, routeKey, channelData, handlers) {
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
const readCount = Math.max(0, messagesCount - unreadCount);
if (channelData.reverseChannelMissingWarning) {
const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta';
@@ -1919,13 +2119,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(reverseWarning);
}
if (Number(channelData.unreadCount || 0) > 0) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`;
screen.append(unreadLine);
}
const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал';
@@ -1944,6 +2137,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const postsByKey = new Map();
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
.map((event) => mapChannelMetaEvent(event, channelData.channel));
let unreadLineInserted = unreadCount === 0;
const feedItems = [
...metaEvents.map((event) => ({
type: 'meta',
@@ -1965,6 +2159,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
if (feedItems.length) {
feedItems.forEach((item) => {
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
unreadLine.textContent = 'Не прочитанные сообщения';
feed.append(unreadLine);
unreadLineInserted = true;
}
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
return;
@@ -2016,10 +2217,25 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0);
return () => {
// noop
};
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
}
const tracker = createChannelReadTracker({
screen,
routeKey,
settingKey: buildChannelSettingsKey(
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.name || channelData.channel?.channelName,
),
unreadCount,
messagesCount,
initialSeenCount: readCount,
});
return tracker.cleanup;
}
function renderSkeleton(screen) {
@@ -2317,19 +2533,6 @@ export function render({ navigate, route, chrome }) {
try {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0);
const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name);
if (settingKey && state.session.login && state.session.storagePwdInMemory) {
void authService.upsertUserSetting({
login: state.session.login,
settingType: 1,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: lastSeenCount,
storagePwd: state.session.storagePwdInMemory,
}).catch(() => {});
}
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => {
@@ -2348,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) {
+67 -54
View File
@@ -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: 'Аватар канала',
});
@@ -955,6 +969,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;
@@ -964,7 +979,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);
}
@@ -980,12 +997,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?.();
@@ -1015,13 +1028,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);
};
}
@@ -1057,8 +1076,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
? 'Отписаться'
@@ -1078,7 +1100,7 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
channel.pending = true;
actionBtn.disabled = true;
actionBtn.textContent = 'Выполняется...';
actionLabel.textContent = 'Выполняется...';
const nextSubscribed = !channel.isSubscribed;
try {
@@ -1100,13 +1122,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;
}
@@ -1114,7 +1136,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';
@@ -1177,16 +1200,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,49 +1251,17 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const main = renderChannelMain(channel);
const isGuest = !state.session.isAuthorized;
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);
count.hidden = unreadCount <= 0;
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
if (!isGuest) {
const menuButton = document.createElement('button');
menuButton.type = 'button';
menuButton.className = 'channel-menu-trigger';
menuButton.append(createOverflowDots());
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(menuButton);
listState.revealedCounters.add(channel.id);
if (listState.openMenuId === channel.id) {
closeChannelMenu(listState);
rerenderList();
return;
}
listState.openMenuId = channel.id;
openChannelMenu({
listState,
channel,
anchorEl: menuButton,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
rerenderList,
});
rerenderList();
});
controls.append(menuButton);
}
controls.append(time, count);
controls.append(count);
row.append(avatar, main, controls);
row.addEventListener('click', () => {
@@ -1375,8 +1374,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';
@@ -1448,6 +1460,7 @@ export function render({ navigate, route, chrome }) {
screen.cleanup = () => {
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
channelsFilterMenu.destroy();
appScreen?.classList.remove('channels-scroll-clean');
};
+146 -27
View File
@@ -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');
+8 -8
View File
@@ -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 = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
+2 -2
View File
@@ -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,
});
+9 -9
View File
@@ -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');
+108 -26
View File
@@ -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' });
+1 -1
View File
@@ -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 = 'Завершить сеанс';
+6 -4
View File
@@ -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 () => {
+5 -23
View File
@@ -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 () => {
+49 -22
View File
@@ -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;
}
+75 -67
View File
@@ -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;
}
+108 -187
View File
@@ -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
View File
@@ -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;
}
+102 -43
View File
@@ -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) {
@@ -169,13 +192,16 @@ function compareChatRows(a, b) {
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const head = document.createElement('header');
head.className = 'dm-head';
head.innerHTML = `
<div class="dm-head-brand" aria-hidden="true"></div>
<h1 class="dm-head-title">Чаты</h1>
<div class="dm-head-brand">
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
</div>
<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>
@@ -189,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
@@ -200,15 +251,11 @@ export function render({ navigate, chrome }) {
menuTemplate?.remove();
let menuPortal = null;
let menuBackdrop = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuBackdrop?.remove();
menuPortal = null;
menuBackdrop = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuButton?.classList.remove('menu-open-pressed');
menuWrap?.classList.remove('is-open');
};
@@ -216,25 +263,25 @@ export function render({ navigate, chrome }) {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuRect = menuPortal.getBoundingClientRect();
const menuWidth = menuRect.width || menuPortal.offsetWidth || 190;
const menuHeight = menuRect.height || menuPortal.offsetHeight || 56;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
const below = rect.bottom + 7;
const top = below + menuHeight <= window.innerHeight - margin
? below
: Math.max(margin, rect.top - menuHeight - 7);
menuPortal.style.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(top)}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');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
`;
@@ -247,17 +294,11 @@ export function render({ navigate, chrome }) {
});
portal.addEventListener('click', (event) => event.stopPropagation());
const backdrop = document.createElement('div');
backdrop.className = 'ui-menu-dim-layer';
backdrop.addEventListener('click', closeHeadMenu);
document.body.append(backdrop, portal);
menuBackdrop = backdrop;
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuButton.classList.add('menu-open-pressed');
menuWrap?.classList.add('is-open');
positionHeadMenu();
requestAnimationFrame(positionHeadMenu);
};
menuButton?.addEventListener('click', (event) => {
@@ -272,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();
@@ -280,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 });
@@ -360,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');
@@ -435,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);
};
+3 -39
View File
@@ -5,7 +5,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: 'Связи' };
@@ -238,7 +238,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). Фокус всегда виден; предикат применяется к периферийным узлам.
@@ -401,41 +400,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);
@@ -444,8 +408,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);
+72 -33
View File
@@ -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;
}
+1 -1
View File
@@ -165,7 +165,7 @@ function renderIdentity(item) {
firstName,
lastName,
avatar,
size: 'small',
size: 'md',
className: 'notification-avatar',
}));
+1 -1
View File
@@ -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',
}));
}
+51 -90
View File
@@ -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,97 +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;
let profileMenuBackdrop = null;
const closeProfileMenu = () => {
profileMenuPortal?.remove();
profileMenuBackdrop?.remove();
profileMenuPortal = null;
profileMenuBackdrop = null;
profileMenuButton?.setAttribute('aria-expanded', 'false');
profileMenuButton?.classList.remove('menu-open-pressed');
profileMenuWrap?.classList.remove('is-open');
};
const positionProfileMenu = () => {
if (!profileMenuPortal || !profileMenuButton) return;
const rect = profileMenuButton.getBoundingClientRect();
const margin = 10;
const menuRect = profileMenuPortal.getBoundingClientRect();
const menuWidth = menuRect.width || profileMenuPortal.offsetWidth || 210;
const menuHeight = menuRect.height || profileMenuPortal.offsetHeight || 144;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
const below = rect.bottom + 7;
const top = below + menuHeight <= window.innerHeight - margin
? below
: Math.max(margin, rect.top - menuHeight - 7);
profileMenuPortal.style.left = `${Math.round(left)}px`;
profileMenuPortal.style.top = `${Math.round(top)}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">
<span>Редактировать профиль</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
<span>Кошелёк</span>
</button>
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
<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());
const backdrop = document.createElement('div');
backdrop.className = 'ui-menu-dim-layer';
backdrop.addEventListener('click', closeProfileMenu);
document.body.append(backdrop, portal);
profileMenuBackdrop = backdrop;
profileMenuPortal = portal;
profileMenuButton.setAttribute('aria-expanded', 'true');
profileMenuButton.classList.add('menu-open-pressed');
profileMenuWrap?.classList.add('is-open');
positionProfileMenu();
requestAnimationFrame(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');
@@ -236,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;
@@ -259,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',
}));
}
@@ -282,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', () => {
@@ -299,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';
@@ -330,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();
@@ -338,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();
@@ -358,5 +318,6 @@ export function render({ navigate, chrome }) {
updateAvatarUi();
refreshProfileSnapshot();
screen.cleanup = () => profileMenu.destroy();
return screen;
}
+8 -2
View File
@@ -1,5 +1,5 @@
import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
checkLoginExistsOnSolana,
@@ -426,7 +426,13 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Зарегистрироваться',
leftAction: { label: '←', onClick: () => navigate('start-view') },
leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}),
form,
actions,
@@ -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',
+13 -3
View File
@@ -3,6 +3,7 @@ import {
authService,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
@@ -85,7 +86,7 @@ export function render({ navigate }) {
const deviceRow = createKeyInfo(
deviceToggle,
'Ключ device (всегда)',
'Client key (всегда)',
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
);
@@ -102,7 +103,10 @@ export function render({ navigate }) {
cancelButton.className = 'ghost-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
cancelButton.addEventListener('click', () => {
resetRegistrationFlow();
navigate('start-view');
});
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
@@ -190,7 +194,13 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Сохранение ключей',
leftAction: { label: '←', onClick: () => navigate('start-view') },
leftAction: {
label: '←',
onClick: () => {
resetRegistrationFlow();
navigate('start-view');
},
},
}),
card,
actions,
@@ -3,6 +3,7 @@ import {
authService,
authorizeSession,
refreshSessions,
resetRegistrationFlow,
setAuthError,
setAuthInfo,
state,
@@ -553,6 +554,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
replacement.addEventListener('click', () => {
stageClosed = true;
stopTimers();
resetRegistrationFlow();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);
@@ -657,6 +659,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
replacement.addEventListener('click', () => {
loginCompleted = true;
stopAutoLogin();
resetRegistrationFlow();
navigate('start-view');
});
headerBackButton.replaceWith(replacement);
+13 -10
View File
@@ -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'));
+9 -6
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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';
}
+35 -11
View File
@@ -509,6 +509,22 @@ function parseSignedMessageBlockBytes(bytes) {
};
}
function extractContactsFromDialogs(dialogs) {
const seen = new Set();
const out = [];
(Array.isArray(dialogs) ? dialogs : []).forEach((dialog) => {
const relationFlag = String(dialog?.relationFlag || '').trim().toLowerCase();
if (relationFlag !== 'contact' && relationFlag !== 'close_friend') return;
const login = String(dialog?.peerLogin || '').trim();
if (!login) return;
const key = login.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(login);
});
return out;
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@@ -1026,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 '';
@@ -1142,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);
@@ -1149,6 +1174,7 @@ export class AuthService {
login: canonicalLogin,
sessionId,
storagePwd,
connectionScope,
sessionMaterial: {
sessionId,
sessionKey,
@@ -1306,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);
@@ -1313,6 +1340,7 @@ export class AuthService {
login: canonicalLogin,
sessionId: targetSessionId,
storagePwd,
connectionScope,
};
}
@@ -2595,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 || {};
}
@@ -2852,7 +2874,11 @@ export class AuthService {
async listContacts() {
const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response);
return response.payload || {};
const payload = response.payload || {};
if (Array.isArray(payload.dialogs) && !Array.isArray(payload.contacts)) {
payload.contacts = extractContactsFromDialogs(payload.dialogs);
}
return payload;
}
@@ -2918,7 +2944,6 @@ export class AuthService {
valueText = '',
valueNum = 0,
storagePwd,
syncDelivery = false,
}) {
const cleanLogin = String(login || '').trim();
const cleanSettingKey = String(settingKey || '').trim();
@@ -2957,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 || {};
+2 -2
View File
@@ -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}`;
}
+1 -1
View File
@@ -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}` : 'Профиль собеседника',
});
+16 -27
View File
@@ -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() {
@@ -6,6 +6,7 @@ let transport = null;
let transportDepth = 0;
const recentFingerprints = new Map();
let notifySent = null;
let lastCapturedPayload = null;
function nowTs() {
return Date.now();
@@ -85,6 +86,11 @@ export function setClientErrorSentNotifier(fn) {
notifySent = typeof fn === 'function' ? fn : null;
}
export function getLastClientErrorPayload() {
if (!lastCapturedPayload) return null;
return { ...lastCapturedPayload };
}
export function isClientErrorReportingEnabled() {
try {
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
@@ -104,6 +110,7 @@ export function setClientErrorReportingEnabled(enabled) {
export async function captureClientError(details = {}) {
const payload = buildPayload(details);
if (!payload.message) return false;
lastCapturedPayload = payload;
const fingerprint = details.dedupeKey || makeFingerprint(payload);
if (isDuplicate(fingerprint)) return false;
+20
View File
@@ -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() {
+8
View File
@@ -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 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
}
+55 -6
View File
@@ -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()
@@ -925,6 +966,14 @@ export async function refreshSessions() {
return state.sessions;
}
export function resetRegistrationFlow() {
const next = createInitialState();
state.registrationDraft = next.registrationDraft;
state.registrationHelp = next.registrationHelp;
state.registrationPayment = next.registrationPayment;
state.keyStorage = next.keyStorage;
}
function resetStateForSignedOut() {
const next = createInitialState({ withStoredSession: false });
state.chats = next.chats;
@@ -969,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();
}
@@ -987,7 +1036,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
} catch {
// ignore reconnect errors on sign out
}
if (onSessionReset) {
if (notifySessionReset && onSessionReset) {
onSessionReset();
}
}