SHA256
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов, цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли. - Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие — редактор цветов с экспортом/импортом. - Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале», создание канала с адресом из названия; лента открывается на свежих постах. - Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке. - Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка; настройки, кошелёк, сеансы — меню-списки. - Нижняя панель: иконки без подписей, бейджи на иконках. - confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу. - docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
8.0 KiB
JavaScript
181 lines
8.0 KiB
JavaScript
import { showToast } from '../services/channels-ux.js';
|
|
import { createTopBar } from '../components/topbar.js';
|
|
import {
|
|
isSessionInvalidError,
|
|
refreshSessions,
|
|
setAuthError,
|
|
setAuthInfo,
|
|
state,
|
|
terminateCurrentSession,
|
|
} from '../state.js';
|
|
|
|
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
|
|
|
function formatSessionType(sessionType) {
|
|
if (Number(sessionType) === 100) return 'Домашний сервер';
|
|
if (Number(sessionType) === 50) return 'Кошелёк';
|
|
if (Number(sessionType) === 1) return 'Приложение';
|
|
return `Тип ${Number(sessionType) || 0}`;
|
|
}
|
|
|
|
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
|
|
function describeClient(userAgent) {
|
|
const ua = String(userAgent || '');
|
|
if (!ua) return 'Неизвестное устройство';
|
|
const browser = /Edg\//.test(ua) ? 'Edge'
|
|
: /OPR\/|Opera/.test(ua) ? 'Opera'
|
|
: /Firefox\//.test(ua) ? 'Firefox'
|
|
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
|
|
: /Chrome\//.test(ua) ? 'Chrome'
|
|
: /Safari\//.test(ua) ? 'Safari'
|
|
: '';
|
|
const os = /iPhone/.test(ua) ? 'iPhone'
|
|
: /iPad/.test(ua) ? 'iPad'
|
|
: /Android/.test(ua) ? 'Android'
|
|
: /Windows/.test(ua) ? 'Windows'
|
|
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
|
|
: /Linux|X11/.test(ua) ? 'Linux'
|
|
: '';
|
|
const parts = [browser, os].filter(Boolean);
|
|
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
|
|
}
|
|
|
|
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
|
|
function formatSessionTime(ms) {
|
|
const date = new Date(ms);
|
|
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
|
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
|
|
if (days === 0) return `сегодня, ${time}`;
|
|
if (days === 1) return `вчера, ${time}`;
|
|
const sameYear = date.getFullYear() === new Date().getFullYear();
|
|
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
|
|
return `${day}, ${time}`;
|
|
}
|
|
|
|
function formatOnlineStatus(onlineOnThisServer) {
|
|
return onlineOnThisServer ? 'в сети' : 'не в сети';
|
|
}
|
|
|
|
function sortSessionsByOnline(sessions = []) {
|
|
return [...sessions].sort((a, b) => {
|
|
const aOnline = a?.onlineOnThisServer ? 1 : 0;
|
|
const bOnline = b?.onlineOnThisServer ? 1 : 0;
|
|
if (aOnline !== bOnline) return bOnline - aOnline;
|
|
return Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0);
|
|
});
|
|
}
|
|
|
|
export function render({navigate, chrome}) {
|
|
const screen = document.createElement('section');
|
|
screen.className = 'stack';
|
|
|
|
chrome?.setTopbar(createTopBar({
|
|
title: 'Устройства',
|
|
back: { label: '←', onClick: () => navigate('settings-view') },
|
|
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
|
|
}));
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'nav-list';
|
|
actions.innerHTML = `
|
|
<button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
|
|
<button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
|
|
<button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></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'));
|
|
|
|
const sessionsBlock = document.createElement('div');
|
|
sessionsBlock.className = 'stack device-sessions';
|
|
|
|
const buildList = () => {
|
|
sessionsBlock.innerHTML = '';
|
|
const sessions = sortSessionsByOnline(state.sessions || []);
|
|
const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0];
|
|
const others = sortSessionsByOnline(sessions.filter((s) => s.sessionId !== current?.sessionId));
|
|
|
|
const createSessionItem = (session, isCurrent) => {
|
|
const item = document.createElement('button');
|
|
item.className = 'ui-button session-item';
|
|
item.type = 'button';
|
|
const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
|
|
const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
|
|
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
|
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
|
|
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
|
|
item.innerHTML = `
|
|
<span class="session-item__main">
|
|
<strong class="session-item__title"></strong>
|
|
<span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
|
|
${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
|
|
</span>
|
|
<span class="session-item__side">
|
|
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
|
</span>
|
|
`;
|
|
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
|
|
item.title = String(session.clientInfoFromClient || '');
|
|
const geoEl = item.querySelector('.session-item__geo');
|
|
if (geoEl) geoEl.textContent = session.geo;
|
|
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
|
return item;
|
|
};
|
|
|
|
if (!current) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'meta-muted';
|
|
empty.textContent = 'Активные сессии не найдены.';
|
|
sessionsBlock.append(empty);
|
|
return;
|
|
}
|
|
|
|
const groupTitle = (text) => {
|
|
const title = document.createElement('p');
|
|
title.className = 'nav-list__title';
|
|
title.textContent = text;
|
|
return title;
|
|
};
|
|
const currentList = document.createElement('div');
|
|
currentList.className = 'nav-list';
|
|
currentList.append(createSessionItem(current, true));
|
|
sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
|
|
|
|
if (others.length === 0) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'empty-note';
|
|
empty.textContent = 'Других сеансов нет.';
|
|
sessionsBlock.append(empty);
|
|
} else {
|
|
const othersList = document.createElement('div');
|
|
othersList.className = 'nav-list';
|
|
others.forEach((session) => othersList.append(createSessionItem(session, false)));
|
|
sessionsBlock.append(othersList);
|
|
}
|
|
};
|
|
|
|
async function reloadSessions() {
|
|
try {
|
|
await refreshSessions();
|
|
buildList();
|
|
setAuthInfo('Список сеансов обновлён.');
|
|
showToast('Список сеансов обновлён');
|
|
} catch (error) {
|
|
if (isSessionInvalidError(error)) {
|
|
await terminateCurrentSession({
|
|
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
|
});
|
|
return;
|
|
}
|
|
setAuthError(error.message);
|
|
showToast(error.message, { kind: 'error' });
|
|
}
|
|
}
|
|
|
|
buildList();
|
|
screen.append(actions, sessionsBlock);
|
|
return screen;
|
|
}
|