UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра

- 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>
This commit is contained in:
q
2026-09-26 10:05:26 +03:00
co-authored by Claude Opus 5.5
parent 6e5b57fd7c
commit f8900e531a
94 changed files with 3337 additions and 2432 deletions
+19 -25
View File
@@ -177,11 +177,11 @@ function createPasswordModal() {
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать root key только сейчас</span>
<span>Использовать главный ключ только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить root key на этом устройстве</span>
<span>Сохранить главный ключ на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
@@ -277,15 +277,9 @@ export function render({navigate, chrome}) {
const passwordModal = createPasswordModal();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.className = 'settings-intro';
introCard.innerHTML = `
<p class="field-label">Где хранятся личные данные</p>
<p class="meta-muted">
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
а настройки и приватная переписка хранятся только на выбранном сервере.
При смене сервера прежняя переписка и настройки автоматически не переносятся.
</p>
<p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</p>
`;
const listCard = document.createElement('div');
@@ -302,7 +296,7 @@ export function render({navigate, chrome}) {
listBody.className = 'stack';
const listStatus = document.createElement('p');
listStatus.className = 'meta-muted';
listStatus.textContent = 'Загрузка данных из PDA...';
listStatus.textContent = 'Загрузка данных аккаунта...';
listCard.append(listTitle, listHint, listBody, listStatus);
const addCard = document.createElement('div');
@@ -323,7 +317,7 @@ export function render({navigate, chrome}) {
suggestEl.hidden = true;
const addStatus = document.createElement('p');
addStatus.className = 'meta-muted';
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
addStatus.textContent = 'Для изменения списка понадобится подтверждение главным ключом.';
const addButton = document.createElement('button');
addButton.className = 'primary-btn';
addButton.type = 'button';
@@ -352,7 +346,7 @@ export function render({navigate, chrome}) {
addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else {
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
addStatus.textContent = 'Для смены сервера понадобится подтверждение главным ключом.';
}
};
@@ -392,7 +386,7 @@ export function render({navigate, chrome}) {
return;
}
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
listStatus.textContent = 'Читаем серверы доступа из записи аккаунта в Solana...';
try {
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
const logins = uniqueLogins(parsed?.accessServers);
@@ -409,8 +403,8 @@ export function render({navigate, chrome}) {
renderServerList();
refreshAddButton();
listStatus.textContent = rows.length
? 'Сервер доступа загружен из PDA.'
: 'В PDA пользователя пока нет серверов доступа.';
? 'Сервер доступа загружен из записи аккаунта.'
: 'В записи аккаунта пока нет серверов доступа.';
} catch (error) {
currentAccessServers = [];
renderServerList();
@@ -439,7 +433,7 @@ export function render({navigate, chrome}) {
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
target.innerHTML = `
<span style="display:block; margin-bottom:0.55rem;">
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
Не хватает SOL на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
</span>
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
@@ -500,10 +494,10 @@ export function render({navigate, chrome}) {
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
@@ -513,10 +507,10 @@ export function render({navigate, chrome}) {
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
}
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
}
if (passwordResult.saveRoot) {
@@ -613,15 +607,15 @@ export function render({navigate, chrome}) {
title: 'Сменить сервер доступа?',
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
note: resolved.httpBase
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.`
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
onConfirm: async () => {
const nextList = [resolved.serverLogin];
try {
await updateAccessServers(nextList, {
statusTarget: addStatus,
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
inFlightText: `Обновляем запись аккаунта и меняем сервер на @${resolved.serverLogin}...`,
});
} catch {
// Сообщение уже показано в статусе.
+49 -23
View File
@@ -17,7 +17,7 @@ export const pageMeta = { id: 'add-channel-view', title: 'Создание ка
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
const CHANNEL_LOGIN_HINT = 'Латиница, цифры, _ и -, от 3 до 32 символов, не только цифры.';
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) {
@@ -78,6 +78,17 @@ function shortAvatarBlockchainAddress(value) {
return raw.slice(-24);
}
const TRANSLIT = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
};
function suggestChannelLogin(title) {
const latin = Array.from(String(title || '').toLowerCase()).map((ch) => TRANSLIT[ch] ?? ch).join('');
return latin.replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 32);
}
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
@@ -101,21 +112,31 @@ export function render({navigate, chrome}) {
</div>
</div>
<label for="channel-name">Технический логин канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
<div class="meta-muted channel-link-preview" id="channel-link-preview">&nbsp;</div>
<div id="channel-name-error" class="meta-muted inline-error"></div>
<div class="form-field">
<label for="channel-title">Название канала</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой канал" autocomplete="off" />
<div class="form-field__meta">
<div id="channel-title-error" class="inline-error"></div>
<div class="form-field__counter" id="channel-title-counter">0 / 50</div>
</div>
</div>
<label for="channel-title">Как канал будет виден пользователям</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div>
<div class="form-field">
<label for="channel-name">Адрес канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="my-channel" required autocomplete="off" autocapitalize="off" spellcheck="false" />
<div class="form-field__hint channel-create-login-hint">${CHANNEL_LOGIN_HINT} Заполняется из названия, можно поправить.</div>
<div class="form-field__hint channel-link-preview" id="channel-link-preview" hidden></div>
<div id="channel-name-error" class="inline-error"></div>
</div>
<label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
<div id="channel-description-error" class="meta-muted inline-error"></div>
<div class="form-field">
<label for="channel-description">Описание <span class="form-field__optional">необязательно</span></label>
<textarea id="channel-description" class="input" rows="3" maxlength="250" placeholder="Коротко о канале"></textarea>
<div class="form-field__meta">
<div id="channel-description-error" class="inline-error"></div>
<div class="form-field__counter" id="channel-description-counter">0 / 250</div>
</div>
</div>
<div id="channel-create-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
@@ -166,9 +187,11 @@ export function render({navigate, chrome}) {
const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.innerHTML = '&nbsp;';
linkPreviewEl.hidden = true;
linkPreviewEl.textContent = '';
return;
}
linkPreviewEl.hidden = false;
const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName,
channelName: normalizedChannelName,
@@ -216,8 +239,8 @@ export function render({navigate, chrome}) {
titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250`;
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok;
@@ -232,16 +255,19 @@ export function render({navigate, chrome}) {
};
};
let nameEditedByHand = false;
nameEl.addEventListener('input', () => {
nameEditedByHand = nameEl.value.trim() !== '';
const raw = String(nameEl.value || '');
const sanitized = sanitizeChannelLoginInput(raw);
if (raw !== sanitized) {
nameEl.value = sanitized;
window.alert(CHANNEL_LOGIN_HINT);
}
if (raw !== sanitized) nameEl.value = sanitized;
updateValidation();
if (raw !== sanitized) nameErrorEl.textContent = 'Недопустимые символы убраны: можно латиницу, цифры, _ и -.';
});
titleEl.addEventListener('input', () => {
if (!nameEditedByHand) nameEl.value = suggestChannelLogin(titleEl.value);
updateValidation();
});
titleEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => {
try {
@@ -285,7 +311,7 @@ export function render({navigate, chrome}) {
const check = updateValidation();
if (check.name && /^[0-9]+$/.test(check.name)) {
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
nameErrorEl.textContent = CHANNEL_LOGIN_DIGITS_ONLY_HINT;
nameEl.focus();
return;
}
@@ -3,7 +3,7 @@ import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' };
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый публичный чат' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PERSONAL = 100;
@@ -52,7 +52,7 @@ export function render({navigate, chrome}) {
screen.className = 'stack channels-screen channels-screen--add';
chrome?.setTopbar(createTopBar({
title: 'Новый персональный публичный чат',
title: 'Новый публичный чат',
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}));
+2 -3
View File
@@ -32,10 +32,9 @@ export function render({ navigate, chrome }) {
}));
const intro = document.createElement('div');
intro.className = 'card stack advanced-settings-intro';
intro.className = 'settings-intro';
intro.innerHTML = `
<p class="field-label">Скрытые возможности SHiNE</p>
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
`;
const developerRow = createToggleRow({
+5 -9
View File
@@ -18,17 +18,14 @@ export function render({navigate, chrome}) {
chrome?.setTopbar(createTopBar({
title: 'Лог приложения',
back: { label: '←', onClick: () => navigate('developer-settings-view') },
actions: [{ icon: 'refresh', title: 'Обновить лог', ariaLabel: 'Обновить лог', onClick: () => renderEntries() }],
}));
const controls = document.createElement('div');
controls.className = 'card row';
controls.style.justifyContent = 'flex-start';
controls.style.gap = '8px';
controls.style.flexWrap = 'wrap';
controls.className = 'action-pair';
controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
<button class="secondary-btn" type="button" data-action="copy-all">Скопировать всё</button>
<button class="destructive-btn" type="button" data-action="clear">Очистить</button>
`;
const status = document.createElement('div');
@@ -77,7 +74,7 @@ export function render({navigate, chrome}) {
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Лог пока пуст.';
list.append(empty);
status.className = 'status-line is-available';
@@ -117,7 +114,6 @@ export function render({navigate, chrome}) {
status.textContent = `Записей: ${entries.length}`;
}
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
const entries = getAppLogEntries();
if (!entries.length) {
+4 -4
View File
@@ -48,7 +48,7 @@ function renderTile(item, index) {
if (item.pendingPlacement) {
const flag = document.createElement('span');
flag.className = 'ar-attachment-placement-flag';
flag.textContent = 'Не добавлен в SHiNE';
flag.textContent = 'Ещё не прикреплён к записи';
meta.append(flag);
}
@@ -92,8 +92,8 @@ export function render({navigate, chrome}) {
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
empty.className = 'empty-note';
empty.textContent = 'Файлов пока нет. Нажмите «+», чтобы загрузить файл заранее и прикрепить его к записи позже.';
list.append(empty);
return;
}
@@ -115,7 +115,7 @@ export function render({navigate, chrome}) {
}
const topbar = createTopBar({
title: 'Загрузка файлов в блокчейн',
title: 'Файлы в блокчейне',
back: { onClick: () => navigate('settings-view') },
actions: [
{
+63 -47
View File
@@ -1,7 +1,8 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js';
import { renderAvatar } from '../components/avatar-image.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { navigateBack } from '../router.js';
import { navigateBack, getPreviousTrackedPath } from '../router.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
@@ -251,7 +252,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet;
if (keyId === 'root-key') {
@@ -410,12 +411,21 @@ function confirmUnsubscribeModal({ channelTitle }) {
});
}
function subscribersWord(count) {
const n = Math.abs(Number(count) || 0) % 100;
const last = n % 10;
if (n > 10 && n < 20) return 'подписчиков';
if (last === 1) return 'подписчик';
if (last >= 2 && last <= 4) return 'подписчика';
return 'подписчиков';
}
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = createTopBar({
title: '',
title: 'О канале',
back: {
label: '←',
onClick: () => {
@@ -471,61 +481,66 @@ export function render({ navigate, route, chrome }) {
channelRootBlockHash: selector?.channelRootBlockHash,
});
const serverLink = buildChannelLink(shortRoute);
const shortLink = String(serverLink || '').replace(/^https?:\/\//, '');
const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, '');
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
const subscriptionTile = isOwnChannel ? '' : `
<button class="channel-about-tile${isSubscribed ? ' is-active' : ''}" id="channel-about-subscription" type="button">
${iconHtml(isSubscribed ? 'check' : 'plus')}<span>${isSubscribed ? 'Вы подписаны' : 'Подписаться'}</span>
</button>`;
content.innerHTML = `
<div class="channel-about-hero">
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
<div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
</div>
<section class="channel-about-section">
<h3>О канале</h3>
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
</section>
<div class="channel-about-tiles">
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
${subscriptionTile}
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
</div>
<section class="channel-about-section channel-about-owner-section">
<h3>Владелец канала</h3>
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
<strong>${escapeHtml(ownerName)}</strong>
<span>@${escapeHtml(ownerLogin)}</span>
</button>
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
</section>
<section class="channel-about-section">
<h3>Ссылка на этом сервере</h3>
<div class="channel-about-link-box">
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
<div class="channel-about-list">
<div class="channel-about-row channel-about-row--static">
<span class="channel-about-row-label">Описание</span>
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
</div>
</section>
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
${isOwnChannel ? '' : `
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
<button class="channel-about-row" id="channel-about-owner" type="button">
<span class="channel-about-owner-avatar" id="channel-about-owner-avatar"></span>
<span class="channel-about-row-main">
<strong>${escapeHtml(ownerName)}</strong>
<span class="channel-about-row-label">Владелец · @${escapeHtml(ownerLogin)}</span>
</span>
<span class="channel-about-row-chevron">${iconHtml('chevron')}</span>
</button>
`}
<button class="channel-about-row" id="channel-about-copy-row" type="button">
<span class="channel-about-row-main">
<span class="channel-about-row-label">Ссылка</span>
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
</span>
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
</button>
</div>
`;
if (String(channel?.avaAr || '').trim()) {
const avatar = renderAvatar({
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
avatar: { ar: String(channel.avaAr || '').trim() },
size: 'xl',
className: 'channel-about-avatar channel-profile-avatar',
title: cleanName,
alt: 'Аватар канала',
});
avatar.style.setProperty('--channel-avatar-size', '112px');
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
} else {
content.querySelector('#channel-about-avatar-slot')?.remove();
}
const channelAvatar = renderAvatar({
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'xl',
className: 'channel-about-avatar channel-profile-avatar',
title: cleanName,
alt: 'Аватар канала',
});
content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar);
content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({
initials: ownerName.slice(0, 1).toUpperCase() || '?',
size: 'md',
className: 'channel-about-owner-avatar-img',
alt: '',
}));
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
const profileRoute = makeProfileRoute(ownerLogin);
@@ -537,7 +552,7 @@ export function render({ navigate, route, chrome }) {
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
if (shortRoute) navigate(shortRoute);
});
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
const copyLink = async () => {
if (!serverLink) return;
try {
await navigator.clipboard.writeText(serverLink);
@@ -545,7 +560,8 @@ export function render({ navigate, route, chrome }) {
} catch (error) {
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
}
});
};
content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink);
const subscriptionButton = content.querySelector('#channel-about-subscription');
subscriptionButton?.addEventListener('click', async () => {
+1 -1
View File
@@ -238,7 +238,7 @@ export function render({ navigate, route, chrome }) {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet;
if (keyId === 'root-key') {
+48 -33
View File
@@ -1,3 +1,4 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { attachMessageMenu } from '../components/message-menu.js';
@@ -8,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
showToast,
@@ -25,7 +27,7 @@ import {
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -200,6 +202,13 @@ function allFeedSummaries() {
});
}
const channelTitleByLabel = new Map();
function rememberChannelTitle(label, channel) {
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
if (label && title) channelTitleByLabel.set(label, title);
}
function resolveChannelDisplayName(channelSelector) {
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
@@ -214,7 +223,9 @@ function resolveChannelDisplayName(channelSelector) {
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
));
if (!found) return '';
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
rememberChannelTitle(label, found.channel);
return label;
}
function resolveChannelHeadingFromNode(node) {
@@ -299,7 +310,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
if (!row?.channel?.channelName) return '';
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
rememberChannelTitle(label, row.channel);
return label;
} catch {
return '';
}
@@ -446,7 +459,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
@@ -702,7 +715,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
headRow.append(authorTile);
@@ -786,7 +800,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
<span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
`;
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
@@ -817,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
<span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
@@ -856,7 +870,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item';
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
actions.append(likeButton, discussionButton, shareButton, replyButton);
@@ -871,8 +885,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
if (!ownerLogin) return;
handlers.navigate(makeShineMessageRoute({
@@ -927,7 +939,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
@@ -1026,21 +1043,18 @@ export function render({ navigate, route, chrome }) {
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...';
const threadHeaderTitle = document.createElement('span');
threadHeaderTitle.className = 'channel-header-title';
threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'channel-header-owner';
threadHeaderChannel.textContent = '…';
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.disabled = true;
const header = createTopBar({
center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
});
header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header);
@@ -1050,7 +1064,7 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.');
if (disposed) throw new Error('Экран обсуждения уже закрыт.');
};
const showStatus = (message) => {
@@ -1175,10 +1189,10 @@ export function render({ navigate, route, chrome }) {
onShare: async (target) => {
try {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Тред',
text: 'Сообщение из треда SHiNE',
title: 'Сияние · Обсуждение',
text: 'Сообщение из обсуждения в Сиянии',
url: buildAbsoluteRouteUrl(routePath),
});
if (disposed) return;
@@ -1192,7 +1206,7 @@ export function render({ navigate, route, chrome }) {
onOpenThread: (target) => {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) {
showStatus('Не удалось определить путь до треда.');
showStatus('Не удалось определить ссылку на обсуждение.');
return;
}
navigate(routePath);
@@ -1264,14 +1278,14 @@ export function render({ navigate, route, chrome }) {
showStatus('');
selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderChannel.textContent = '…';
threadHeaderButton.disabled = true;
threadHeaderButton.onclick = null;
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
screen.append(invalid);
return;
}
@@ -1382,7 +1396,8 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
event.preventDefault();
@@ -1422,8 +1437,8 @@ export function render({ navigate, route, chrome }) {
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'primary-btn';
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
reply.className = 'channel-compose-bar';
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
@@ -1448,7 +1463,7 @@ export function render({ navigate, route, chrome }) {
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty);
}
@@ -1478,7 +1493,7 @@ export function render({ navigate, route, chrome }) {
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
+73 -48
View File
@@ -1,5 +1,7 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { getPreviousTrackedPath } from '../router.js';
import { attachMessageMenu } from '../components/message-menu.js';
import { createTopBar } from '../components/topbar.js';
import {
@@ -13,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
dayKey,
formatClockTime,
formatDayLabel,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
@@ -39,7 +44,7 @@ import {
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -828,7 +833,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
@@ -874,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
const ok = window.confirm('Отменить вложение?');
button.addEventListener('click', async () => {
const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
if (!ok) return;
attachments.splice(index, 1);
renderDraftAttachments(container, attachments);
@@ -1063,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
if (list) {
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
list.append(empty);
} else {
@@ -1169,18 +1174,19 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const typeWrap = document.createElement('label');
typeWrap.className = 'channel-editor__type';
typeWrap.textContent = 'Тип сообщения';
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
const typeSelect = document.createElement('select');
typeSelect.className = 'select';
typeSelect.className = 'select channel-editor__type-select';
typeSelect.title = 'Тип сообщения';
typeSelect.innerHTML = `
<option value="10">Публикация</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
`;
typeWrap.append(typeSelect);
return openChannelEditor({
id: 'channel-message-modal',
title: 'Новое сообщение',
title: 'Новая запись',
submitLabel: 'Опубликовать',
placeholder: 'Напишите сообщение',
key: `channel-post:${channelName}`,
@@ -1611,14 +1617,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
};
}
function renderChannelMetaEventCard(event) {
function renderChannelMetaEventCard(event, dayLabel = '') {
const card = document.createElement('article');
card.className = 'card channel-system-event-card';
const label = event.kind === 'created'
? 'Создан канал'
? 'Канал создан'
: 'Изменено описание канала';
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
card.innerHTML = `
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
<span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
`;
card.addEventListener('click', () => {
openChannelMetaDetailsModal({
@@ -1856,6 +1863,7 @@ function renderPostCard(post, {
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
if (versionsTotal > 1) {
const editedMarker = document.createElement('span');
editedMarker.type = 'button';
@@ -1906,6 +1914,15 @@ function renderPostCard(post, {
navigate(makeProfileRoute(cleanLogin));
});
const isOwnerPost = !!selector
&& !isDiarySelector(selector)
&& !!post.messageRef?.blockchainName
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
if (isOwnerPost) {
card.classList.add('channel-message-card--owner');
authorTile.hidden = true;
}
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const parsedBody = parseMessageAttachments(post.body);
@@ -1990,7 +2007,7 @@ function renderPostCard(post, {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
<span class="channel-action-counter">${post.likesCount || ''}</span>
`;
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
@@ -2015,7 +2032,7 @@ function renderPostCard(post, {
discussionButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Обсуждение</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
<span class="channel-action-counter">${post.repliesCount || ''}</span>
`;
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
discussionButton.addEventListener('click', (event) => {
@@ -2030,7 +2047,7 @@ function renderPostCard(post, {
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
<span class="channel-action-counter">${post.repliesCount || ''}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
@@ -2067,7 +2084,8 @@ function renderPostCard(post, {
await onShare(route);
});
actions.append(shareButton, replyButton);
actions.append(shareButton);
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
@@ -2081,8 +2099,6 @@ function renderPostCard(post, {
event.stopPropagation();
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
if (!ownerLogin) return;
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
navigate(makeShineMessageRoute({
ownerLogin,
messageBlockchainName: post.targetRef.blockchainName,
@@ -2135,13 +2151,28 @@ function renderPostCard(post, {
});
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await onEdit(post.messageRef, '', { isDelete: true }); }
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
}
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems);
if (isOwnerPost) {
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
const meta = document.createElement('div');
meta.className = 'channel-message-meta';
const moreButton = headRow.querySelector('.channel-message-more');
meta.append(timestamp);
if (moreButton) meta.append(moreButton);
actions.append(meta);
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
}
card.append(actions);
return card;
}
@@ -2166,8 +2197,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const addMessageButton = document.createElement('button');
addMessageButton.type = 'button';
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose';
addMessageButton.textContent = 'Добавить сообщение';
addMessageButton.className = 'channel-compose-bar';
addMessageButton.setAttribute('aria-label', 'Написать в канал');
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
addMessageButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
handlers.onAddMessage();
@@ -2199,7 +2231,17 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
if (feedItems.length) {
let lastDayKey = '';
feedItems.forEach((item) => {
const itemDayKey = dayKey(item.timestampMs);
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
if (startsNewDay) lastDayKey = itemDayKey;
if (startsNewDay && item.type !== 'meta') {
const separator = document.createElement('div');
separator.className = 'channel-day-separator';
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
feed.append(separator);
}
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
const unreadLine = document.createElement('div');
unreadLine.className = 'card channel-unread-line';
@@ -2208,7 +2250,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
unreadLineInserted = true;
}
if (item.type === 'meta') {
feed.append(renderChannelMetaEventCard(item.event));
feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
return;
}
const row = renderPostCard(item.post, {
@@ -2232,7 +2274,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний';
@@ -2400,26 +2442,6 @@ export function render({ navigate, route, chrome }) {
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = [];
if (apiData?.isOwnChannel && !apiData?.isDiary) {
items.push({
label: 'Добавить сообщение',
action: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
});
},
});
}
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
items.push({
label: 'Оглавление',
@@ -2479,9 +2501,6 @@ export function render({ navigate, route, chrome }) {
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({
login,
@@ -2694,7 +2713,7 @@ export function render({ navigate, route, chrome }) {
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Каналы',
text: 'Тред из канала SHiNE',
text: 'Обсуждение в Сиянии',
url: buildAbsoluteRouteUrl(routeToShare),
});
if (disposed) return;
@@ -2806,7 +2825,13 @@ export function render({ navigate, route, chrome }) {
if (disposed) return;
const seq = ++refreshSeq;
const hadContent = !!screen.querySelector('.channel-feed');
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
const previousPath = getPreviousTrackedPath() || '';
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
const restorePosition = hadContent
? getChannelScrollRoot()?.scrollTop
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
if (!hadContent) clearContent();
activeChannelData = null;
activeOpenEntrypointHistory = null;
+7 -4
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@@ -5,7 +6,7 @@ import { parseMessageAttachments } from '../services/attachment-format.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
formatListTime,
readChannelNotificationsState,
showToast,
softHaptic,
@@ -755,7 +756,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
messagePreview: cleanChannelMessagePreview(lastMessage.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(lastMessage.createdAtMs || 0),
lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 0),
isOwnChannel: isOwn,
isSubscribed: !isOwn,
notificationsEnabled: notificationsState[rowId] === true,
@@ -964,12 +965,13 @@ function renderChannelMain(channel) {
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
titleLine.append(title, time);
const technical = document.createElement('p');
technical.className = 'channel-row-technical';
technical.textContent = `@${channel.ownerName || 'автор'}`;
technical.hidden = !!channel.isOwnChannel;
if (channel.channelDescription) {
const desc = document.createElement('p');
@@ -984,6 +986,7 @@ function renderChannelMain(channel) {
const preview = document.createElement('p');
preview.className = 'channel-row-message';
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
if (!channel.messagePreview) preview.classList.add('is-empty');
previewLine.append(preview);
@@ -1173,7 +1176,7 @@ export function render({ navigate, route, chrome }) {
controls.className = 'channels-list-controls';
const searchWrap = document.createElement('div');
searchWrap.className = 'channels-inline-search';
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.value = listState.query;
+21 -5
View File
@@ -28,7 +28,7 @@ import {
stopAllTwemojiAnimations,
} from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { dayKey, formatClockTime, formatDayLabel, showToast } from '../services/channels-ux.js';
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
import {
@@ -44,6 +44,7 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
export const pageMeta = {
id: 'chat-view',
title: 'Чат',
hideToolbar: true,
shellMode: {
topFade: true,
bottomFade: true,
@@ -81,7 +82,7 @@ function chatRelationLabel(value) {
case 'close_friend': return 'Близкий друг';
case 'friend': return 'Друг';
case 'contact': return 'Контакт';
default: return 'Не в контактах';
default: return '';
}
}
@@ -117,7 +118,9 @@ function createChatHeaderParts(login, navigate) {
const lastName = String(currentPeer?.lastName || '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin;
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
const relation = chatRelationLabel(currentPeer?.relationType);
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
metaEl.hidden = !metaEl.textContent;
const avatar = renderUserAvatar({
login: cleanLogin,
firstName,
@@ -1058,7 +1061,19 @@ function renderLog(
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
let lastDayKey = '';
messages.forEach((msg) => {
const msgTimeMs = resolveMessageTimeMs(msg);
const msgDayKey = dayKey(msgTimeMs);
if (msgDayKey && msgDayKey !== lastDayKey) {
lastDayKey = msgDayKey;
const daySep = document.createElement('div');
daySep.className = 'dm-day-separator';
const dayLabel = document.createElement('span');
dayLabel.textContent = formatDayLabel(msgTimeMs);
daySep.append(dayLabel);
list.append(daySep);
}
const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted
&& separatorMessageKey
@@ -1176,7 +1191,8 @@ function renderLog(
const timeNode = document.createElement('span');
timeNode.className = 'bubble-time';
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
timeNode.textContent = formatClockTime(resolveMessageTimeMs(msg));
timeNode.title = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode);
const status = resolveDeliveryStatus(messages, msg);
@@ -1812,7 +1828,7 @@ export function render({ navigate, route, chrome }) {
updateSendButtonMode();
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
showToast(
denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
denied ? 'Нужно разрешить Сиянию доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
{ kind: 'error', timeoutMs: 2600 },
);
}
+19 -14
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
@@ -65,15 +66,17 @@ export function render({navigate, chrome}) {
let searchSeq = 0;
const input = document.createElement('input');
input.className = 'input dm-input contact-search-input';
input.className = 'search-field__input contact-search-input';
input.type = 'text';
input.name = 'contact';
input.placeholder = 'Введите начало логина';
input.placeholder = 'Логин или его начало';
input.setAttribute('aria-label', 'Логин для поиска');
input.enterKeyHint = 'search';
input.autocomplete = 'off';
input.maxLength = 80;
const resultsCard = document.createElement('section');
resultsCard.className = 'card stack contact-search-results-card';
resultsCard.className = 'stack contact-search-results';
resultsCard.hidden = true;
const status = document.createElement('p');
@@ -143,13 +146,13 @@ export function render({navigate, chrome}) {
searchTimer = window.setTimeout(() => {
searchTimer = 0;
void runSearch();
}, 2000);
}, 500);
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn dm-send-btn';
searchButton.className = 'search-field__action';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.textContent = 'Найти';
searchButton.addEventListener('click', async () => {
if (searchTimer) {
window.clearTimeout(searchTimer);
@@ -161,19 +164,21 @@ export function render({navigate, chrome}) {
input.addEventListener('input', () => {
scheduleSearch();
});
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
searchButton.click();
});
const controls = document.createElement('div');
controls.className = 'contact-search-actions';
controls.append(searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack contact-search-form-card';
formCard.append(input, controls);
const formCard = document.createElement('label');
formCard.className = 'search-field contact-search-field';
formCard.innerHTML = `<span class="search-field__icon" aria-hidden="true">${iconHtml('search')}</span>`;
formCard.append(input, searchButton);
resultsCard.append(status, resultsList);
chrome?.setTopbar(createTopBar({
title: 'Поиск контактов',
title: 'Найти человека',
back: { label: '←', onClick: () => navigate('messages-list') },
}));
screen.append(
+20 -14
View File
@@ -207,7 +207,7 @@ async function forceUiUpdateNow() {
function showClientUpdateHelp() {
window.alert(
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n'
+ '1) Закройте вкладки с Сиянием.\n'
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
@@ -256,17 +256,17 @@ export function render({navigate, chrome}) {
}));
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
card.className = 'nav-list settings-developer-card';
card.innerHTML = `
<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>
<button class="nav-row" type="button" id="settings-force-ui-update"><span class="nav-row__label">Принудительно обновить UI</span></button>
<button class="nav-row" type="button" id="settings-force-update-help"><span class="nav-row__label">Клиент не обновляется?</span></button>
<button class="nav-row" type="button" id="settings-ui-error-reporting"><span class="nav-row__label">Отправлять ошибки на сервер</span></button>
<button class="nav-row" type="button" id="settings-solana-users-init"><span class="nav-row__label">Solana: первичная настройка регистрации</span></button>
<button class="nav-row" type="button" id="settings-solana-rpc-check"><span class="nav-row__label">Solana: проверить публичный узел</span></button>
<button class="nav-row" type="button" id="settings-app-log"><span class="nav-row__label">Лог приложения</span></button>
<button class="nav-row" type="button" id="settings-pwa-diagnostics"><span class="nav-row__label">Диагностика PWA / Push</span></button>
<button class="nav-row" type="button" id="settings-pwa-install"><span class="nav-row__label">Установка на главный экран</span></button>
<button class="nav-row" type="button" id="settings-upload-avatar"><span class="nav-row__label">Загрузить аватар</span></button>
`;
const appLogBtn = card.querySelector('#settings-app-log');
@@ -307,16 +307,22 @@ export function render({navigate, chrome}) {
}
});
// Меняем только подпись строки: textContent на самой кнопке стирал .nav-row__label, и строка выбивалась по кеглю.
const setPwaLabel = (text) => {
const labelEl = pwaInstallBtn.querySelector('.nav-row__label');
if (labelEl) labelEl.textContent = text;
else pwaInstallBtn.textContent = text;
};
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
setPwaLabel('Приложение установлено — проверить уведомления');
return;
}
if (canInstallPwa()) {
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
setPwaLabel('Установить как приложение');
return;
}
pwaInstallBtn.textContent = 'Как установить PWA';
setPwaLabel('Установка на главный экран');
};
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
+2 -2
View File
@@ -250,7 +250,7 @@ export function render({navigate, chrome}) {
passwordDialog.style.inset = '0';
passwordDialog.style.zIndex = '30';
passwordDialog.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
<div style="position:absolute; inset:0; background:var(--scrim); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
<div class="stack" style="gap:6px;">
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
@@ -292,7 +292,7 @@ export function render({navigate, chrome}) {
dialogOverlay.style.inset = '0';
dialogOverlay.style.zIndex = '2';
dialogOverlay.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div>
<div style="position:absolute; inset:0; background:var(--scrim);"></div>
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
<p class="meta-muted" id="pairing-dialog-overlay-text"></p>
+79 -74
View File
@@ -1,3 +1,4 @@
import { showToast } from '../services/channels-ux.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
@@ -11,24 +12,49 @@ import {
export const pageMeta = { id: 'device-view', title: 'Устройства' };
function formatSessionType(sessionType) {
if (Number(sessionType) === 100) return 'Homeserver';
if (Number(sessionType) === 50) return 'Wallet';
if (Number(sessionType) === 1) return 'Client';
return `Type ${Number(sessionType) || 0}`;
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) {
return new Date(ms).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
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 ? 'Online now' : 'Offline';
return onlineOnThisServer ? 'в сети' : 'не в сети';
}
function sortSessionsByOnline(sessions = []) {
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
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 = 'card stack';
actions.className = 'nav-list';
actions.innerHTML = `
<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>
<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'));
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div');
sessionsBlock.className = 'card stack';
sessionsBlock.className = 'stack device-sessions';
const buildList = () => {
sessionsBlock.innerHTML = '';
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
const item = document.createElement('button');
item.className = 'ui-button session-item';
item.type = 'button';
const sessionTypeText = formatSessionType(session.sessionType);
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
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 = `
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px; text-align:left;">
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
<span class="meta-muted">${session.geo || 'unknown'}</span>
</div>
<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>
</div>
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
</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;
};
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
return;
}
const currentMenu = document.createElement('div');
currentMenu.className = 'stack';
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
currentMenu.append(createSessionItem(current, true));
const endCurrentSessionBtn = document.createElement('button');
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
endCurrentSessionBtn.type = 'button';
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
endCurrentSessionBtn.addEventListener('click', async () => {
const confirmed = window.confirm('Хотите завершить текущую сессию?');
if (!confirmed) return;
try {
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
closeServerSession: true,
});
} catch (error) {
if (!isSessionInvalidError(error)) {
setAuthError(error.message);
window.alert(error.message);
return;
}
await terminateCurrentSession({
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
});
}
});
currentMenu.append(endCurrentSessionBtn);
const othersMenu = document.createElement('div');
othersMenu.className = 'stack';
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
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 = 'meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Других сеансов нет.';
othersMenu.append(empty);
sessionsBlock.append(empty);
} else {
others.forEach((session) => {
othersMenu.append(createSessionItem(session, false));
});
const othersList = document.createElement('div');
othersList.className = 'nav-list';
others.forEach((session) => othersList.append(createSessionItem(session, false)));
sessionsBlock.append(othersList);
}
sessionsBlock.append(currentMenu, othersMenu);
};
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
async function reloadSessions() {
try {
await refreshSessions();
buildList();
setAuthInfo('Список сессий обновлён.');
setAuthInfo('Список сеансов обновлён.');
showToast('Список сеансов обновлён');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
return;
}
setAuthError(error.message);
window.alert(error.message);
showToast(error.message, { kind: 'error' });
}
});
}
buildList();
screen.append(actions, sessionsBlock);
+4 -4
View File
@@ -58,7 +58,7 @@ export function render() {
amountHint.style.fontSize = '22px';
amountHint.style.fontWeight = '800';
amountHint.style.lineHeight = '1.25';
amountHint.style.color = '#1fc97a';
amountHint.style.color = 'var(--success)';
amountHint.style.textAlign = 'center';
amountHint.textContent = '';
@@ -69,7 +69,7 @@ export function render() {
balanceHint.style.margin = '0';
balanceHint.style.textAlign = 'center';
balanceHint.style.fontSize = '14px';
balanceHint.style.color = 'rgba(255,255,255,0.9)';
balanceHint.style.color = 'var(--text-primary)';
balanceHint.textContent = '';
const closeHint = document.createElement('p');
@@ -79,7 +79,7 @@ export function render() {
closeHint.style.fontSize = '26px';
closeHint.style.fontWeight = '800';
closeHint.style.lineHeight = '1.25';
closeHint.style.color = '#1fc97a';
closeHint.style.color = 'var(--success)';
closeHint.style.textAlign = 'center';
closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.';
@@ -140,7 +140,7 @@ export function render() {
status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`;
status.style.fontSize = '13px';
status.style.fontWeight = '500';
status.style.color = 'rgba(255,255,255,0.88)';
status.style.color = 'var(--text-secondary)';
fillBtn.style.display = 'none';
actions.style.display = 'none';
amountHint.style.display = '';
+6 -6
View File
@@ -11,7 +11,7 @@ export const pageMeta = { id: 'entry-settings-view', title: 'Настройки
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
@@ -50,7 +50,7 @@ export function render({ navigate }) {
controls.className = 'row wrap-row';
const checkButton = document.createElement('button');
checkButton.className = 'shine-btn shine-btn--settings server-check-btn';
checkButton.className = 'secondary-btn server-check-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить';
@@ -132,7 +132,7 @@ export function render({ navigate }) {
if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button');
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn';
localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => {
@@ -143,7 +143,7 @@ export function render({ navigate }) {
}
const serverUiButton = document.createElement('button');
serverUiButton.className = 'shine-btn shine-btn--settings';
serverUiButton.className = 'secondary-btn';
serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.addEventListener('click', () => {
@@ -152,13 +152,13 @@ export function render({ navigate }) {
});
const cancelButton = document.createElement('button');
cancelButton.className = 'shine-btn shine-btn--settings';
cancelButton.className = 'secondary-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button');
saveButton.className = 'shine-btn shine-btn--primary';
saveButton.className = 'primary-btn';
saveButton.type = 'button';
saveButton.textContent = 'Сохранить';
saveButton.addEventListener('click', async () => {
+1 -1
View File
@@ -40,7 +40,7 @@ export function render({ navigate }) {
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
+22 -39
View File
@@ -14,55 +14,38 @@ export function render({navigate, chrome}) {
const returnPage = resolveReturnPage();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
chrome?.setTopbar(createTopBar({
const topbar = createTopBar({
title: 'Язык / Language',
back: { label: '←', onClick: () => {
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
} },
}));
});
const embedTopbar = !state.session.isAuthorized;
if (!embedTopbar) chrome?.setTopbar(topbar);
const card = document.createElement('div');
card.className = 'card stack language-choice-card';
card.innerHTML = `
<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 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));
card.className = 'nav-list language-list';
card.setAttribute('role', 'radiogroup');
card.setAttribute('aria-label', 'Язык интерфейса / Interface language');
[['ru', 'Русский'], ['en', 'English']].forEach(([code, label]) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'nav-row language-row';
row.setAttribute('role', 'radio');
row.setAttribute('aria-checked', String(pendingLanguage === code));
row.innerHTML = `<span class="nav-row__label"></span>`;
row.querySelector('.nav-row__label').textContent = label;
row.addEventListener('click', () => {
pendingLanguage = code;
saveEntryLanguage(code);
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
});
};
syncSelection();
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);
card.append(row);
});
if (embedTopbar) screen.append(topbar);
screen.append(card);
return screen;
}
+4 -4
View File
@@ -66,7 +66,7 @@ export function render({ navigate }) {
const remoteText = document.createElement('p');
remoteText.className = 'auth-copy';
const serverLoginEl = document.createElement('strong');
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
const textBefore = document.createTextNode('Этот пользователь Сияния зарегистрирован на другом сервере доступа: ');
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
const serverLinkEl = document.createElement('a');
@@ -111,11 +111,11 @@ export function render({ navigate }) {
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
if (resolution === 'NOT_FOUND') {
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
setStatus(status, 'Пользователь с таким логином не зарегистрирован в Сиянии.');
return;
}
if (resolution === 'NO_ACCESS_SERVER') {
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
setStatus(status, 'Пользователь зарегистрирован в Сиянии, но для него не найден действующий сервер доступа.');
return;
}
if (resolution === 'REMOTE') {
@@ -155,7 +155,7 @@ export function render({ navigate }) {
screen.append(
createTopBar({
title: '',
title: 'Вход',
back: {
label: '←',
onClick: async () => {
+48 -79
View File
@@ -8,21 +8,13 @@ import {
terminateCurrentSession,
} 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 { createTopBar } from '../components/topbar.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';
import { formatListTime } from '../services/channels-ux.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const SVG_CHEVRON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6"></path>
</svg>
`;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
@@ -119,6 +111,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
return `${normalized.slice(0, maxLen - 1)}…`;
}
const DM_PREVIEW_ENCRYPTED = 'Зашифрованное сообщение';
async function resolveDialogPreview(dialog) {
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
if (localText) return localText;
@@ -131,6 +125,9 @@ async function resolveDialogPreview(dialog) {
String(state.session.storagePwdInMemory || '').trim(),
].join('|');
// После перезагрузки пароля хранилища в памяти нет — расшифровать нечем. Не кэшируем,
// чтобы превью появилось, когда пароль снова будет введён.
if (!String(state.session.storagePwdInMemory || '').trim()) return DM_PREVIEW_ENCRYPTED;
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
@@ -149,7 +146,7 @@ async function resolveDialogPreview(dialog) {
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
return result;
} catch {
const fallback = 'Сообщение недоступно';
const fallback = DM_PREVIEW_ENCRYPTED;
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
return fallback;
} finally {
@@ -185,7 +182,7 @@ function latestLocalDialogMessage(peerLogin) {
}
function formatChatRowTime(ts) {
return formatRelativeTime(ts);
return formatListTime(ts);
}
function compareChatRows(a, b) {
@@ -201,63 +198,45 @@ 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 brand = document.createElement('div');
brand.className = 'dm-head-brand';
const logoWrap = document.createElement('span');
logoWrap.className = 'dm-head-logo-wrap';
logoWrap.setAttribute('aria-hidden', 'true');
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
brand.append(logoWrap);
let currentChatFilter = 'all';
const filterTitle = document.createElement('button');
filterTitle.type = 'button';
filterTitle.className = 'dm-head-filter-title';
filterTitle.textContent = 'Чаты';
const filterLabels = {
all: 'Чаты',
close_friend: 'Близкие друзья',
friend: 'Друзья',
contact: 'Контакты',
none: 'Новые',
};
const CHAT_FILTERS = [
['all', 'Все'],
['close_friend', 'Близкие'],
['friend', 'Друзья'],
['contact', 'Контакты'],
['none', 'Новые'],
];
let reloadForFilter = () => {};
const chatFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: filterTitle,
placement: 'bottom-start',
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 = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
],
const filterRow = document.createElement('div');
filterRow.className = 'chip-row chip-row--bleed dm-filter-row';
filterRow.setAttribute('role', 'toolbar');
filterRow.setAttribute('aria-label', 'Фильтр чатов');
CHAT_FILTERS.forEach(([value, label]) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'chip';
chip.dataset.filter = value;
chip.textContent = label;
chip.setAttribute('aria-pressed', String(value === currentChatFilter));
chip.addEventListener('click', () => {
if (currentChatFilter === value) return;
currentChatFilter = value;
filterRow.querySelectorAll('.chip').forEach((el) => el.setAttribute('aria-pressed', String(el.dataset.filter === value)));
reloadForFilter();
});
filterRow.append(chip);
});
const searchIconHtml = `
<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>
`;
const head = createTopBar({
left: brand,
center: filterTitle,
title: 'Чаты',
className: 'topbar--root',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню чатов',
ariaLabel: 'Меню чатов',
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
menu: {
minWidth: 210,
items: [
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
],
},
icon: 'search',
title: 'Найти человека',
ariaLabel: 'Найти человека',
className: 'messages-search-btn',
onClick: () => navigate('contact-search-view'),
},
],
});
@@ -269,9 +248,7 @@ function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item dm-dialog-card';
const relationFlag = normalizeRelationFlag(item.relationFlag);
const relationBadge = relationFlag === 'none'
? 'не в контактах'
: relationLabel(relationFlag);
const relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin, {
avatar: item.avatar,
firstName: item.firstName,
@@ -280,19 +257,19 @@ function renderRow(item) {
avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
if (relationFlag !== 'none') row.classList.add(`dm-card--${relationFlag === 'close_friend' ? 'family' : 'contact'}`);
avatarWrap.append(avatarEl);
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title"></strong>
<span class="dm-contact-note">${relationBadge}</span>
${relationBadge ? `<span class="dm-contact-note dm-contact-note--${relationFlag}">${relationBadge}</span>` : ''}
</div>
<p class="dm-row-last-message"></p>
</div>
<div class="dm-row-meta-col">
<div class="dm-row-meta-line">
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
</div>
@@ -310,6 +287,7 @@ function renderRow(item) {
void resolveDialogPreview(item).then((text) => {
if (!previewEl?.isConnected) return;
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
previewEl.classList.toggle('is-encrypted', text === DM_PREVIEW_ENCRYPTED);
});
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
return row;
@@ -387,20 +365,13 @@ function renderRow(item) {
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет диалогов';
empty.className = 'empty-note';
empty.textContent = 'Пока нет переписок. Найдите человека через поиск вверху.';
list.append(empty);
return;
}
let dividerInserted = false;
rows.forEach((item) => {
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
const divider = document.createElement('div');
divider.className = 'dm-divider';
list.append(divider);
dividerInserted = true;
}
list.append(renderRow(item));
});
} catch (error) {
@@ -444,12 +415,10 @@ function renderRow(item) {
reloadForFilter = () => { void loadList(); };
chrome?.setTopbar(head);
screen.append(list);
screen.append(filterRow, list);
loadList();
screen.cleanup = () => {
chatFilterMenu.destroy();
};
screen.cleanup = () => {};
return screen;
}
+13 -24
View File
@@ -1,5 +1,4 @@
import { createTopBar } from '../components/topbar.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
@@ -358,7 +357,7 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) {
.filter(Boolean);
if (failedParents.length) {
const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', ');
throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
throw new Error(`Друзья друзей: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
}
// ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра.
@@ -930,16 +929,16 @@ export function render({ navigate, route, chrome } = {}) {
function updateHistoryChip() {
if (!(historyChip instanceof HTMLButtonElement)) return;
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История';
historyChip.textContent = historyDepth > 0 ? `Недавние · ${historyDepth}` : 'Недавние';
historyChip.classList.toggle('is-active', historyDepth > 0);
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
historyChip.disabled = x2Enabled;
historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false');
historyChip.title = x2Enabled
? 'X2 показывает отдельную карту и временно не использует историю.'
? '«Друзья друзей» показывают отдельную карту — недавние временно не используются.'
: (historyDepth > 0
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.`
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.');
? `На карте остаются последние ${historyDepth} человека, через которых вы переходили. Нажмите, чтобы изменить.`
: 'Недавние выключены. Нажмите, чтобы на карте оставался предыдущий человек.');
}
function cycleHistoryDepth() {
@@ -956,7 +955,7 @@ export function render({ navigate, route, chrome } = {}) {
if (!(x2Chip instanceof HTMLButtonElement)) return;
x2Chip.classList.toggle('is-active', x2Enabled);
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.';
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы скрыть.' : 'Показать друзей друзей.';
updateHistoryChip();
}
@@ -1289,26 +1288,16 @@ export function render({ navigate, route, chrome } = {}) {
}
const searchIconHtml = `
<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>
`;
const header = createTopBar({
title: 'Связи',
className: 'topbar--root',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
},
icon: 'search',
title: 'Найти пользователя',
ariaLabel: 'Найти пользователя',
className: 'network-search-btn',
onClick: openSearchModal,
},
],
});
@@ -1365,7 +1354,7 @@ export function render({ navigate, route, chrome } = {}) {
x2Chip = document.createElement('button');
x2Chip.type = 'button';
x2Chip.className = 'fg-filter-chip fg-x2-chip';
x2Chip.textContent = 'X2';
x2Chip.textContent = 'Друзья друзей';
x2Chip.addEventListener('click', () => { void toggleX2(); });
filterBar.append(x2Chip);
updateX2Chip();
+26 -5
View File
@@ -106,8 +106,27 @@ function easeOutCubic(t) {
return 1 - x * x * x;
}
const RELATION_COLORS_LIGHT = {
family: 'rgba(184, 92, 20, 0.9)',
friend: 'rgba(38, 96, 196, 0.9)',
close_friend: 'rgba(38, 96, 196, 0.98)',
business: 'rgba(108, 62, 200, 0.9)',
contact: 'rgba(92, 98, 121, 0.75)',
};
function isLightTheme() {
return document.documentElement.dataset.theme === 'light';
}
function shineStroke() {
return isLightTheme()
? { halo: '#0a7fb3', core: '#06567a', blend: 'normal' }
: { halo: '#00e5ff', core: '#dffaff', blend: 'screen' };
}
function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
const palette = isLightTheme() ? RELATION_COLORS_LIGHT : RELATION_COLORS;
return palette[relationType] || palette.contact;
}
function resolveAvatarPhotoSrc(src) {
@@ -1057,8 +1076,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
const ss = shineStroke();
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
}
@@ -1067,8 +1087,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
const ss = shineStroke();
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
}
+17 -7
View File
@@ -158,7 +158,7 @@ function renderEmpty(activeTab) {
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
const text = document.createElement('p');
text.className = 'meta-muted';
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или обсуждении, ответ появится здесь.';
card.append(title, text);
return card;
}
@@ -322,12 +322,15 @@ export function render({ navigate, chrome } = {}) {
screen.className = 'stack notifications-screen';
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
tabs.className = 'tabs tabs--three notification-feed-tabs';
tabs.setAttribute('role', 'radiogroup');
tabs.setAttribute('aria-label', 'Категория уведомлений');
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
chrome?.setTopbar(createTopBar({
center: tabs,
className: 'notifications-topbar',
title: 'Уведомления',
className: 'topbar--root notifications-topbar',
}));
screen.append(tabs);
const list = document.createElement('div');
list.className = 'stack notifications-list';
@@ -352,15 +355,22 @@ export function render({ navigate, chrome } = {}) {
if (!btn) return;
let badge = btn.querySelector('.notification-toolbar-badge');
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; (btn.querySelector('.toolbar-icon') || btn).append(badge); }
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
}
function renderTabs(payload) {
const counts = countsFromPayload(payload);
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
const b=document.createElement('button'); b.type='button'; b.className='tab-btn notification-tab-btn'; b.dataset.tab=key; b.setAttribute('role','radio'); b.setAttribute('aria-checked',state.notificationsTab===key?'true':'false');
b.textContent = label;
if (counts[key] > 0) {
const count = document.createElement('span');
count.className = 'tab-count';
count.textContent = counts[key] > 99 ? '99+' : String(counts[key]);
count.setAttribute('aria-label', `новых: ${counts[key]}`);
b.append(count);
}
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
return b;
}));
+19 -22
View File
@@ -114,7 +114,7 @@ export function render({ navigate, chrome }) {
);
const card = document.createElement('div');
card.className = 'card stack';
card.className = 'stack profile-edit-card';
const topRow = document.createElement('div');
topRow.className = 'row';
@@ -127,14 +127,14 @@ export function render({ navigate, chrome }) {
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
</div>
<button class="primary-btn" type="button" data-reload="true">Обновить</button>
<button class="secondary-btn profile-edit-reload" type="button" data-reload="true" hidden>Обновить</button>
`;
const badgesRow = document.createElement('div');
badgesRow.className = 'row';
badgesRow.className = 'chip-row profile-edit-statuses';
badgesRow.innerHTML = `
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button>
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
<button class="chip profile-toggle-btn is-no" type="button" data-status="account_role">Тип аккаунта: Не указано</button>
<button class="chip profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
`;
const status = document.createElement('div');
@@ -142,16 +142,13 @@ export function render({ navigate, chrome }) {
status.textContent = 'Загрузка параметров...';
const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list';
listWrap.className = 'nav-list profile-param-list';
const relativesCard = document.createElement('div');
relativesCard.className = 'card stack';
relativesCard.innerHTML = `
<div class="profile-param-value"><b>Друзья</b></div>
<div class="meta-muted">
Добавьте пользователя в друзья или в близкие друзья.
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
</div>
<div class="meta-muted">Добавьте человека в друзья или в близкие друзья.</div>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
`;
@@ -250,7 +247,7 @@ export function render({ navigate, chrome }) {
function updateStatusesUi() {
if (accountRoleBtn) {
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.textContent = `Тип аккаунта: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
}
@@ -582,22 +579,22 @@ export function render({ navigate, chrome }) {
function renderFields(fields) {
listWrap.innerHTML = '';
fields.forEach((field) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
const row = document.createElement('button');
row.type = 'button';
row.className = 'nav-row profile-param-item';
row.dataset.editField = field.key;
row.style.cursor = 'pointer';
const value = String(field.value || '').trim() || 'не заполнено';
const isNameField = field.key === 'first_name' || field.key === 'last_name';
const valueClass = isNameField ? 'profile-param-value profile-param-value-small' : 'profile-param-value';
row.innerHTML = `<div class="${valueClass}"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
const value = String(field.value || '').trim();
row.innerHTML = `<span class="nav-row__hint">${escapeHtml(field.label)}</span><span class="nav-row__label${value ? '' : ' is-empty'}">${escapeHtml(value || 'Не заполнено')}</span>`;
listWrap.append(row);
if (field.key === 'last_name') {
const genderRow = document.createElement('div');
genderRow.className = 'card profile-param-item row';
const genderRow = document.createElement('button');
genderRow.type = 'button';
genderRow.className = 'nav-row profile-param-item';
genderRow.dataset.editGender = 'true';
genderRow.style.cursor = 'pointer';
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: <span data-gender-value>${escapeHtml(genderLabel(currentGender))}</span></div>`;
const genderText = genderLabel(currentGender);
const genderEmpty = genderText === 'Не указан';
genderRow.innerHTML = `<span class="nav-row__hint">Пол</span><span class="nav-row__label${genderEmpty ? ' is-empty' : ''}" data-gender-value>${escapeHtml(genderEmpty ? 'Не указан' : genderText)}</span>`;
listWrap.append(genderRow);
}
});
+17 -158
View File
@@ -1,104 +1,20 @@
import { profile } from '../mock-data.js';
import { state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createTopBar } from '../components/topbar.js';
import { loadUserProfileCard } from '../services/user-connections.js';
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const topbar = createTopBar({
title: login || 'Профиль',
className: 'topbar--profile user-profile-header',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню профиля',
ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn topbar-overflow-action--raised',
menu: {
className: 'profile-head-menu',
minWidth: 250,
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') },
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
],
},
},
],
title: 'Профиль',
className: 'topbar--root user-profile-header',
});
chrome?.setTopbar(topbar);
@@ -114,57 +30,21 @@ export function render({ navigate, chrome }) {
function renderProfile() {
if (!card) return;
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль';
body.innerHTML = `
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
</div>
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
<div class="user-profile-actions-wrap">
<div class="user-profile-actions" aria-label="Действия со своим профилем">
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
</div>
body.innerHTML = profileCardHtml({
card,
login,
tilesHtml: [
profileTileHtml({ icon: 'edit', label: 'Редактировать', attrs: 'data-self-profile-action="edit"' }),
profileTileHtml({ icon: 'wallet', label: 'Кошелёк', attrs: 'data-self-profile-action="wallet"' }),
profileTileHtml({ icon: 'settings', label: 'Настройки', attrs: 'data-self-profile-action="settings"' }),
].join(''),
}) + `
<div class="nav-list profile-more-list">
<button class="nav-row" type="button" data-profile-list="primary_given"><span class="nav-row__label">Подтверждённые мной аккаунты</span><span class="nav-row__hint">Аккаунты, за которые вы поручились</span></button>
<button class="nav-row" type="button" data-profile-list="shine_given"><span class="nav-row__label">Отмеченные мной как сияющие</span></button>
<button class="nav-row" type="button" data-self-profile-action="profiles"><span class="nav-row__label">Сменить профиль</span><span class="nav-row__hint">Другие аккаунты на этом устройстве</span></button>
</div>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
@@ -198,31 +78,10 @@ export function render({ navigate, chrome }) {
if (action === 'edit') navigate('profile-edit-view');
if (action === 'wallet') navigate('wallet-view');
if (action === 'settings') navigate('settings-view');
if (action === 'profiles') navigate('profiles-view');
return;
}
const detailButton = event.target.closest('[data-profile-detail]');
if (!detailButton) return;
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#profile-view-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
});
async function refresh() {
+14 -15
View File
@@ -1,10 +1,10 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { createTopBar } from '../components/topbar.js';
import {
closeAllSavedProfiles,
closeSavedProfile,
getSavedProfiles,
prepareAddProfileLogin,
state,
switchToSavedProfile,
} from '../state.js';
@@ -58,12 +58,12 @@ export function render({navigate, chrome}) {
const closeAllButton = document.createElement('button');
closeAllButton.type = 'button';
closeAllButton.className = 'secondary-btn profiles-close-all';
closeAllButton.textContent = 'Закрыть все профили';
closeAllButton.className = 'destructive-btn profiles-close-all';
closeAllButton.textContent = 'Выйти из всех профилей';
closeAllButton.addEventListener('click', async () => {
const profiles = getSavedProfiles();
if (!profiles.length) return;
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
const confirmed = await confirmDialog({ title: 'Выйти из всех профилей?', text: 'Все профили на этом устройстве будут закрыты, откроется экран входа.', confirmLabel: 'Выйти', danger: true });
if (!confirmed) return;
closeAllButton.disabled = true;
status.hidden = false;
@@ -83,10 +83,8 @@ export function render({navigate, chrome}) {
const renderList = () => {
const profiles = getSavedProfiles();
const active = profiles.find((item) => item.isActive);
intro.textContent = profiles.length
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
: 'На устройстве нет сохранённых профилей.';
intro.textContent = profiles.length ? '' : 'На устройстве нет сохранённых профилей.';
intro.hidden = profiles.length > 0;
closeAllButton.disabled = profiles.length === 0;
list.innerHTML = '';
@@ -97,11 +95,11 @@ export function render({navigate, chrome}) {
const select = document.createElement('button');
select.type = 'button';
select.className = 'profiles-select';
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="profiles-active-badge">Сейчас открыт</span>' : ''}`;
select.disabled = profile.isActive;
select.addEventListener('click', async () => {
if (profile.isActive) return;
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
const confirmed = await confirmDialog({ title: 'Переключить профиль?', text: `Откроется профиль «${profile.login}».`, confirmLabel: 'Переключить' });
if (!confirmed) return;
status.hidden = false;
status.className = 'status-line';
@@ -118,16 +116,17 @@ export function render({navigate, chrome}) {
const close = document.createElement('button');
close.type = 'button';
close.className = 'profiles-close';
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
close.setAttribute('aria-label', `Выйти из профиля ${profile.login}`);
close.title = 'Выйти из профиля';
close.textContent = '×';
close.addEventListener('click', async () => {
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
const message = profile.isActive
? (others.length
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
if (!window.confirm(message)) return;
? 'Приложение переключится на следующий сохранённый профиль.'
: 'После выхода откроется экран входа.')
: 'Профиль будет закрыт на этом устройстве.';
if (!await confirmDialog({ title: `Выйти из профиля «${profile.login}»?`, text: message, confirmLabel: 'Выйти', danger: true })) return;
status.hidden = false;
status.className = 'status-line';
@@ -247,10 +247,10 @@ function queueSeedFor(queueId) {
function styleInputField(field) {
if (!field) return;
field.style.color = '#111111';
field.style.webkitTextFillColor = '#111111';
field.style.caretColor = '#111111';
field.style.backgroundColor = '#ffffff';
field.style.color = 'var(--text-primary)';
field.style.webkitTextFillColor = 'var(--text-primary)';
field.style.caretColor = 'var(--accent)';
field.style.backgroundColor = 'var(--surface)';
}
function readTicketFromUrl() {
+3 -3
View File
@@ -13,7 +13,7 @@ import {
} from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
export const pageMeta = { id: 'register-view', title: 'Регистрация', showAppChrome: false };
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
function normalizeLoginForTemporaryUiGuard(login) {
@@ -132,7 +132,7 @@ export function render({ navigate }) {
}
function updatePasswordLength() {
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
passwordLengthText.textContent = `Символов в пароле: ${getCurrentPassword().length}`;
}
function setStatusMessage(message, kind = '') {
@@ -425,7 +425,7 @@ export function render({ navigate }) {
screen.append(
createTopBar({
title: 'Зарегистрироваться',
title: 'Регистрация',
back: {
label: '←',
onClick: () => {
+19 -17
View File
@@ -10,7 +10,7 @@ export const REGISTRATION_FAQ_TOPICS = [
title: 'У кого хранятся ключи?',
paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
],
},
@@ -26,11 +26,11 @@ export const REGISTRATION_FAQ_TOPICS = [
},
{
id: 'key-derivation',
shortTitle: 'Деривация',
shortTitle: 'Как пароль превращается в ключи',
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.',
'Уже из этого секрета детерминированно строятся четыре основных ключа: ключ восстановления, главный ключ, ключ публикаций и ключ устройства.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
@@ -39,9 +39,9 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?',
paragraphs: [
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
],
},
@@ -78,9 +78,9 @@ export const REGISTRATION_FAQ_TOPICS = [
{
id: 'first-server',
shortTitle: 'Первый сервер',
title: 'Что такое первый сервер SHiNE?',
title: 'Что такое первый сервер Сияния?',
paragraphs: [
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Первый сервер Сияния это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
],
},
{
@@ -118,7 +118,7 @@ export const REGISTRATION_FAQ_TOPICS = [
shortTitle: 'Кошелёк',
title: 'Можно ли использовать такое устройство как кошелёк?',
paragraphs: [
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'Да. Идея Сияния в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
],
@@ -142,7 +142,6 @@ export function render({ navigate }) {
const heroCard = document.createElement('div');
heroCard.className = 'card stack registration-faq-hero';
heroCard.innerHTML = `
<div class="badge alt">Вопросы о регистрации</div>
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
`;
@@ -174,20 +173,23 @@ export function render({ navigate }) {
}
const topicsCard = document.createElement('div');
topicsCard.className = 'card stack';
topicsCard.className = 'stack';
const topicsLabel = document.createElement('p');
topicsLabel.className = 'field-label';
topicsLabel.className = 'nav-list__title';
topicsLabel.textContent = 'Другие вопросы';
const topicsGrid = document.createElement('div');
topicsGrid.className = 'registration-faq-grid';
topicsGrid.className = 'nav-list';
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
REGISTRATION_FAQ_TOPICS.filter((topic) => topic.id !== selectedTopic.id).forEach((topic) => {
const button = document.createElement('button');
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
button.className = 'nav-row';
button.type = 'button';
button.textContent = topic.shortTitle;
const label = document.createElement('span');
label.className = 'nav-row__label';
label.textContent = topic.title;
button.append(label);
button.addEventListener('click', () => {
state.registrationHelp.selectedTopic = topic.id;
navigate('registration-faq-view');
+1 -1
View File
@@ -83,7 +83,7 @@ export function render({ navigate }) {
const blockchainRow = createKeyInfo(
blockchainToggle,
'Ключ blockchain',
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
'Используется для подписи ваших действий и записей в блокчейне Сияния.',
);
const deviceRow = createKeyInfo(
+7 -11
View File
@@ -6,7 +6,7 @@ export const pageMeta = { id: 'server-settings-view', title: 'Серверы б
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
@@ -27,13 +27,9 @@ export function render({navigate, chrome}) {
const timers = new Map();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.className = 'settings-intro';
introCard.innerHTML = `
<p class="field-label">Серверы блокчейнов и публичных данных</p>
<p class="meta-muted">
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
Эти настройки не меняют список личных серверов доступа пользователя.
</p>
<p class="meta-muted">Откуда приложение читает публичные данные: Solana, Сияние и Arweave. Личные серверы доступа здесь не меняются.</p>
`;
const body = document.createElement('div');
@@ -138,7 +134,7 @@ export function render({navigate, chrome}) {
const callTimeoutLabel = document.createElement('label');
callTimeoutLabel.className = 'field-label';
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)';
callTimeoutLabel.textContent = 'Сколько ждать связи с сервером перед звонком (мс)';
const callTimeoutInput = document.createElement('input');
callTimeoutInput.className = 'input';
@@ -155,13 +151,13 @@ export function render({navigate, chrome}) {
const callTimeoutHint = document.createElement('p');
callTimeoutHint.className = 'meta-muted';
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
callTimeoutHint.textContent = 'Перед звонком приложение проверяет и при необходимости восстанавливает соединение с сервером. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
body.append(callSettings);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.className = 'action-pair';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
@@ -190,7 +186,7 @@ export function render({navigate, chrome}) {
help.textContent = '?';
help.addEventListener('click', () => {
window.alert(
'Текст для разработчиков: для SHiNE вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
'Текст для разработчиков: для Сияния вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
);
});
+64 -38
View File
@@ -1,7 +1,9 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
import { openPaletteEditor } from '../components/palette-editor.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -40,7 +42,7 @@ export function render({navigate, chrome}) {
const logoButton = document.createElement('button');
logoButton.type = 'button';
logoButton.className = 'settings-shine-logo-button';
logoButton.setAttribute('aria-label', 'Логотип SHiNE');
logoButton.setAttribute('aria-label', 'Логотип Сияния');
logoButton.title = 'SHiNE';
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
@@ -56,44 +58,64 @@ export function render({navigate, chrome}) {
});
const card = document.createElement('div');
card.className = 'card stack';
card.className = 'stack settings-sections';
const row = (id, label, hint) => `<button class="nav-row" type="button" id="${id}"><span class="nav-row__label">${label}</span>${hint ? `<span class="nav-row__hint">${hint}</span>` : ''}</button>`;
card.innerHTML = `
<div class="stack">
<label for="settings-theme"><strong>Оформление</strong></label>
<span class="meta-muted">Тема меняет только цвета и не перезагружает экран.</span>
<select class="select" id="settings-theme">
<option value="system">Как на устройстве</option>
<option value="light">Дневное</option>
<option value="dark">Ночное</option>
</select>
<div class="card stack settings-theme-card">
<strong id="settings-theme-label">Оформление</strong>
<div class="tabs tabs--auto" id="settings-theme" role="radiogroup" aria-labelledby="settings-theme-label">
<button type="button" class="tab-btn" role="radio" data-mode="system">Авто</button>
<button type="button" class="tab-btn" role="radio" data-mode="light">День</button>
<button type="button" class="tab-btn" role="radio" data-mode="dark">Ночь</button>
</div>
<span class="meta-muted">«Авто» — как на устройстве.</span>
</div>
<div class="nav-list">
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
${row('settings-remote-addblock', 'Публикация через домашний сервер', 'Когда ключа публикаций нет на этом устройстве')}
${row('settings-language', 'Язык / Language', 'Русский')}
</div>
<div class="nav-list">
<button class="nav-row nav-row--danger" type="button" id="settings-signout"><span class="nav-row__label">Выйти из аккаунта на этом устройстве</span></button>
</div>
<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>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
</span>
</button>
<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="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="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>
`;
const themeSelect = card.querySelector('#settings-theme');
themeSelect.value = getThemeMode();
themeSelect.addEventListener('change', () => setThemeMode(themeSelect.value));
const themeSwitch = card.querySelector('#settings-theme');
const syncThemeSwitch = () => {
const mode = getThemeMode();
themeSwitch.querySelectorAll('[data-mode]').forEach((btn) => {
btn.setAttribute('aria-checked', String(btn.dataset.mode === mode));
});
};
syncThemeSwitch();
const LONG_PRESS_MS = 600;
let longPressTimer = 0;
let longPressFired = false;
const cancelLongPress = () => { window.clearTimeout(longPressTimer); longPressTimer = 0; };
themeSwitch.addEventListener('pointerdown', (event) => {
const mode = event.target.closest('[data-mode]')?.dataset.mode;
longPressFired = false;
if (mode !== 'light' && mode !== 'dark') return;
longPressTimer = window.setTimeout(() => {
longPressFired = true;
setThemeMode(mode);
syncThemeSwitch();
openPaletteEditor({ theme: mode });
}, LONG_PRESS_MS);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach((type) => themeSwitch.addEventListener(type, cancelLongPress));
// На телефоне долгое нажатие иначе вызывает системное меню выделения.
themeSwitch.addEventListener('contextmenu', (event) => event.preventDefault());
themeSwitch.addEventListener('click', (event) => {
const mode = event.target.closest('[data-mode]')?.dataset.mode;
if (!mode || longPressFired) return;
setThemeMode(mode);
syncThemeSwitch();
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
@@ -107,9 +129,12 @@ export function render({navigate, chrome}) {
const signOutBtn = card.querySelector('#settings-signout');
signOutBtn.addEventListener('click', async () => {
const confirmed = window.confirm(
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
);
const confirmed = await confirmDialog({
title: 'Выйти из аккаунта?',
text: 'Вы выйдете на этом устройстве. Если сохранены другие профили, приложение переключится на следующий.',
confirmLabel: 'Выйти',
danger: true,
});
if (!confirmed) return;
signOutBtn.disabled = true;
@@ -184,6 +209,7 @@ export function render({navigate, chrome}) {
screen.cleanup = () => {
isDisposed = true;
cancelLongPress();
};
return screen;
}
+12 -23
View File
@@ -4,7 +4,7 @@ import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
export const pageMeta = { id: 'show-keys-view', title: 'Ключи этого устройства' };
export function render({navigate, chrome}) {
const screen = document.createElement('section');
@@ -23,19 +23,18 @@ export function render({navigate, chrome}) {
};
chrome?.setTopbar(createTopBar({
title: 'Показать ключи',
title: 'Ключи этого устройства',
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
card.className = 'nav-list key-list';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Загружаем сохранённые ключи...';
card.append(status);
const renderField = (id, label) => {
const renderField = (id, label, hint = '') => {
const row = document.createElement('div');
row.className = 'key-card stack';
const eyeIcon = `
@@ -53,8 +52,8 @@ export function render({navigate, chrome}) {
`;
row.innerHTML = `
<div class="row">
<span class="field-label">${label}</span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
</div>
<div class="key-value key-value--compact" data-value="${id}">*****</div>
`;
@@ -64,9 +63,9 @@ export function render({navigate, chrome}) {
};
card.append(
renderField('root', 'root key (base58)'),
renderField('blockchain', 'blockchain.key (base58)'),
renderField('device', 'client key (base58)'),
renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
);
const setMissingState = (id) => {
@@ -91,8 +90,8 @@ export function render({navigate, chrome}) {
valueEl.textContent = visible[id] ? keys[id] : '*****';
btnEl.disabled = false;
btnEl.innerHTML = visible[id]
? field?._eyeIcon || ''
: field?._eyeOffIcon || '';
? field?._eyeOffIcon || ''
: field?._eyeIcon || '';
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
};
@@ -108,16 +107,6 @@ export function render({navigate, chrome}) {
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const closeButton = document.createElement('button');
closeButton.className = 'ghost-btn';
closeButton.type = 'button';
closeButton.textContent = 'Назад';
closeButton.addEventListener('click', () => navigate('device-view'));
actions.append(closeButton);
(async () => {
try {
if (!state.session.login || !state.session.storagePwdInMemory) {
@@ -149,6 +138,6 @@ export function render({navigate, chrome}) {
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
})();
screen.append(card, actions);
screen.append(status, card);
return screen;
}
+4 -4
View File
@@ -31,13 +31,13 @@ export function render({ navigate }) {
loginButton.addEventListener('click', () => navigate('login-view'));
const registerButton = document.createElement('button');
registerButton.className = 'shine-btn shine-btn--register';
registerButton.className = 'secondary-btn shine-btn--register';
registerButton.type = 'button';
registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view'));
const languageButton = document.createElement('button');
languageButton.className = 'shine-btn shine-btn--view';
languageButton.className = 'text-btn start-language-link';
languageButton.type = 'button';
languageButton.textContent = 'Язык / Language';
languageButton.addEventListener('click', () => {
@@ -46,9 +46,9 @@ export function render({ navigate }) {
});
const settingsButton = document.createElement('button');
settingsButton.className = 'shine-btn shine-btn--settings';
settingsButton.className = 'text-btn start-settings-link';
settingsButton.type = 'button';
settingsButton.textContent = 'Настройки';
settingsButton.textContent = 'Настройки входа';
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, languageButton, settingsButton);
@@ -3,7 +3,7 @@ import { authService, setAuthError, setAuthInfo, state } from '../state.js';
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Настройки входа через устройство' };
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Вход с другого устройства' };
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
@@ -38,15 +38,14 @@ export function render({navigate, chrome}) {
status.style.display = 'none';
const actions = document.createElement('div');
actions.className = 'row';
actions.style.flexWrap = 'wrap';
actions.className = 'stack trusted-login-actions';
const enableToggleBtn = document.createElement('button');
enableToggleBtn.className = 'primary-btn';
enableToggleBtn.type = 'button';
const noPasswordBtn = document.createElement('button');
noPasswordBtn.className = 'ghost-btn';
noPasswordBtn.className = 'secondary-btn';
noPasswordBtn.type = 'button';
noPasswordBtn.textContent = 'Сделать вход без пароля';
@@ -77,6 +76,7 @@ export function render({navigate, chrome}) {
busy = flag;
enableToggleBtn.disabled = flag;
noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword;
noPasswordBtn.hidden = !settings.enabled || !settings.hasPassword;
savePasswordBtn.disabled = flag;
passwordInput.disabled = flag;
passwordConfirmInput.disabled = flag;
@@ -87,6 +87,7 @@ export function render({navigate, chrome}) {
enableToggleBtn.textContent = settings.enabled
? 'Запретить вход через другое устройство'
: 'Разрешить вход через другое устройство';
enableToggleBtn.className = settings.enabled ? 'destructive-btn' : 'primary-btn';
actions.innerHTML = '';
actions.append(enableToggleBtn);
@@ -183,7 +184,7 @@ export function render({navigate, chrome}) {
});
chrome?.setTopbar(createTopBar({
title: 'Настройки входа через устройство',
title: 'Вход с другого устройства',
back: { label: '←', onClick: () => navigate('device-pairing-view') },
}));
screen.append(
+13 -139
View File
@@ -5,6 +5,7 @@ import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js';
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
export const pageMeta = { id: 'user', title: 'Профиль' };
@@ -24,68 +25,6 @@ function effectiveSocial(flags = {}) {
return 'none';
}
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
function addIconHtml() {
return `
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
<path d="M10.5 20.5 17 27l13-14" />
</svg>`;
}
function relationMenuHtml(flags = {}) {
const current = effectiveSocial(flags);
const rows = [
@@ -107,7 +46,7 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack user-profile-screen';
const header = createTopBar({
title: requestedLogin || 'Профиль',
title: 'Профиль',
back: { label: '←', onClick: () => navigateBack() },
});
header.classList.add('user-profile-header');
@@ -186,60 +125,20 @@ export function render({ navigate, route, chrome }) {
function renderProfile() {
if (!card) return;
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = header.querySelector('.topbar__title');
if (title) title.textContent = card.login;
body.innerHTML = `
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
</div>
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
${!isSelf ? `
<div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
</div>
</div>` : ''}`;
body.innerHTML = profileCardHtml({
card,
login: requestedLogin,
isSelf,
beforeTilesHtml: '<div class="user-profile-add-menu" hidden></div>',
tilesHtml: isSelf ? '' : [
profileTileHtml({ icon: 'user-plus', label: 'Добавить', attrs: 'data-profile-action="add" aria-haspopup="menu" aria-expanded="false"' }),
profileTileHtml({ label: 'Связи', attrs: 'data-profile-action="links"', iconMarkup: `<img class="pf-tile-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">` }),
profileTileHtml({ icon: 'message', label: 'Написать', attrs: 'data-profile-action="chat"' }),
].join(''),
});
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
@@ -272,31 +171,6 @@ export function render({ navigate, route, chrome }) {
return;
}
const detailButton = event.target.closest('[data-profile-detail]');
if (detailButton) {
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#user-profile-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
return;
}
const relationButton = event.target.closest('[data-relation-kind]');
if (relationButton) {
if (!selfLogin) {
+24 -51
View File
@@ -531,10 +531,10 @@ export function render({navigate, chrome}) {
function styleSupportInputField(field) {
if (!field) return;
field.style.color = '#111111';
field.style.webkitTextFillColor = '#111111';
field.style.caretColor = '#111111';
field.style.backgroundColor = '#ffffff';
field.style.color = 'var(--text-primary)';
field.style.webkitTextFillColor = 'var(--text-primary)';
field.style.caretColor = 'var(--accent)';
field.style.backgroundColor = 'var(--surface)';
}
function renderSupportHub() {
@@ -1283,55 +1283,28 @@ export function render({navigate, chrome}) {
content.innerHTML = '';
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h2 style="margin:0 0 6px;">Кошелёк</h2>
<p class="meta-muted">Выберите режим кошелька.</p>
`;
const solanaBtn = document.createElement('button');
solanaBtn.className = 'primary-btn';
solanaBtn.style.width = '100%';
solanaBtn.textContent = 'Solana кошелёк';
solanaBtn.addEventListener('click', () => {
void renderSolanaWallet();
card.className = 'nav-list';
const items = [
['Solana кошелёк', 'Баланс SOL', () => renderSolanaWallet()],
['Arweave кошелёк', 'Баланс AR', () => renderArweaveWallet()],
['Предоплаченное место Сияния', 'Лимит, израсходовано и остаток', () => renderShineBlockchainWallet()],
['Закрепление в Solana', 'Что закреплено в Solana и на сервере', () => renderSolanaPublishWallet()],
['Поддержать проект Сияние', 'Билеты и справка', () => renderSupportHub()],
];
const buttons = items.map(([label, hint, open]) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'nav-row';
btn.innerHTML = `<span class="nav-row__label"></span><span class="nav-row__hint"></span>`;
btn.querySelector('.nav-row__label').textContent = label;
btn.querySelector('.nav-row__hint').textContent = hint;
btn.addEventListener('click', () => { void open(); });
return btn;
});
const arweaveBtn = document.createElement('button');
arweaveBtn.className = 'primary-btn';
arweaveBtn.style.width = '100%';
arweaveBtn.textContent = 'Arweave кошелёк';
arweaveBtn.addEventListener('click', () => {
void renderArweaveWallet();
});
const shineBchBtn = document.createElement('button');
shineBchBtn.className = 'primary-btn';
shineBchBtn.style.width = '100%';
shineBchBtn.textContent = 'Предоплаченное место Сияния';
shineBchBtn.addEventListener('click', () => {
void renderShineBlockchainWallet();
});
const solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
const supportBtn = document.createElement('button');
supportBtn.className = 'primary-btn';
supportBtn.style.width = '100%';
supportBtn.textContent = 'Поддержать проект Сияние';
supportBtn.addEventListener('click', () => {
void renderSupportHub();
});
const [solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn] = buttons;
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
setStatus('');
}
async function renderShineBlockchainWallet() {
@@ -2051,7 +2024,7 @@ export function render({navigate, chrome}) {
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;