@@ -444,16 +440,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
};
root.querySelector('#thread-repost-cancel')?.addEventListener('click', close);
- root.querySelector('#thread-repost-voice')?.addEventListener('click', async () => {
- await openSpeechInputModal({
- navigate,
- onTextReady: (text) => {
- const prev = String(textEl?.value || '').trim();
- if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
- },
- });
- });
-
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const idx = Number(selectEl?.value ?? -1);
@@ -477,6 +463,8 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
}
});
+ bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
+
if (textEl) textEl.focus();
}
@@ -562,6 +550,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
+ bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-edit-save')?.click());
if (textEl) textEl.focus();
}
@@ -714,7 +703,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
// Репосты временно отключены до будущей реализации.
- // Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
+ // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, shareButton);
if (repostTarget) {
const originalButton = document.createElement('button');
@@ -878,7 +867,7 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
- state.authReturnHash = window.location.pathname || '/channels-list';
+ state.authReturnHash = window.location.pathname || '/channels';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js
index cd82608b..65a12dc6 100644
--- a/shine-UI/js/pages/channel-view.js
+++ b/shine-UI/js/pages/channel-view.js
@@ -16,7 +16,6 @@ import {
showToast,
softHaptic,
} from '../services/channels-ux.js';
-import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -297,6 +296,16 @@ function openAboutChannelModal(channel) {
});
}
+function bindSubmitOnPlainEnter(textarea, submit) {
+ if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return;
+ textarea.addEventListener('keydown', (event) => {
+ if (event.key !== 'Enter') return;
+ if (event.shiftKey || event.ctrlKey) return;
+ event.preventDefault();
+ submit();
+ });
+}
+
function openReplyModal({ onSubmit, navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
@@ -304,9 +313,6 @@ function openReplyModal({ onSubmit, navigate }) {
Ответ
-
- 🎤 Голосом
-
Отмена
@@ -333,15 +339,6 @@ function openReplyModal({ onSubmit, navigate }) {
};
root.querySelector('#reply-cancel')?.addEventListener('click', close);
- root.querySelector('#reply-voice')?.addEventListener('click', async () => {
- await openSpeechInputModal({
- navigate,
- onTextReady: (text) => {
- const prev = String(textEl?.value || '').trim();
- if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
- },
- });
- });
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
@@ -363,6 +360,8 @@ function openReplyModal({ onSubmit, navigate }) {
}
});
+ bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
+
if (textEl) textEl.focus();
}
@@ -387,9 +386,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
${options}
Комментарий
-
- 🎤 Голосом
-
Отмена
@@ -417,15 +413,6 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
const close = () => { root.innerHTML = ''; };
root.querySelector('#repost-cancel')?.addEventListener('click', close);
- root.querySelector('#repost-voice')?.addEventListener('click', async () => {
- await openSpeechInputModal({
- navigate,
- onTextReady: (text) => {
- const prev = String(textEl?.value || '').trim();
- if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
- },
- });
- });
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const idx = Number(selectEl?.value ?? -1);
@@ -448,6 +435,7 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
});
+ bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
}
@@ -506,6 +494,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
}
});
+ bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
+
if (textEl) textEl.focus();
}
@@ -592,6 +582,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
+ bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click());
if (textEl) textEl.focus();
}
@@ -1004,7 +995,7 @@ function renderPostCard(post, {
});
});
// Репосты временно отключены до будущей реализации.
- // Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
+ // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton);
const shareButton = document.createElement('button');
@@ -1200,7 +1191,7 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
- state.authReturnHash = window.location.pathname || '/channels-list';
+ state.authReturnHash = window.location.pathname || '/channels';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js
index 98b02f6b..0c12dd65 100644
--- a/shine-UI/js/pages/channels-list.js
+++ b/shine-UI/js/pages/channels-list.js
@@ -1197,7 +1197,7 @@ export function render({ navigate, route }) {
const rerenderList = () => {
try {
- const expectedPath = '/channels-list';
+ const expectedPath = '/channels';
if (window.location.pathname !== expectedPath) {
window.history.replaceState({}, '', expectedPath);
}
diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js
index bdbd3417..9fbbe469 100644
--- a/shine-UI/js/pages/chat-view.js
+++ b/shine-UI/js/pages/chat-view.js
@@ -19,13 +19,12 @@ import {
import { startOutgoingCall } from '../services/call-service.js';
import {
createEmojiPicker,
- createTelegramAnimatedEmoji,
- appendTelegramAnimatedText,
- isTelegramAnimatedEmoji,
- stopAllTelegramEmojiAnimations,
+ createTwemojiAsset,
+ appendTwemojiText,
+ hasTwemojiAsset,
+ stopAllTwemojiAnimations,
} from '../components/emoji-picker.js?v=202607152130';
-import { openSpeechInputModal } from '../components/speech-input-modal.js';
-import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
+import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
@@ -144,6 +143,7 @@ function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
messageText = '',
+ infoText = '',
canReply = false,
showReadAloud = true,
canEdit = false,
@@ -165,6 +165,7 @@ function openMessageActionsMenu({
${showReadAloud ? '
Прочесть ' : ''}
${canEdit ? '
Изменить ' : ''}
${canDelete ? '
Удалить ' : ''}
+ ${String(infoText || '').trim() ? `
${String(infoText || '').trim()}
` : ''}
`;
@@ -312,27 +313,22 @@ function openChatActionsMenu({
});
}
-function showTtsMissingConfigDialog(navigate) {
+function showTtsMissingConfigDialog() {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
Озвучка не настроена
-
Перейти в настройки инструментов?
+
Функция голосовых инструментов сейчас отключена.
- Нет
- Да
+ OK
`;
const close = () => { root.innerHTML = ''; };
- root.querySelector('#chat-tts-no')?.addEventListener('click', close);
- root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
- close();
- navigate('tools-settings-view');
- });
+ root.querySelector('#chat-tts-ok')?.addEventListener('click', close);
}
function autoResizeComposer(textarea) {
@@ -341,6 +337,20 @@ function autoResizeComposer(textarea) {
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
}
+function createHeaderPhoneHandsetIcon() {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(ns, 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.setAttribute('class', 'header-icon-svg header-icon-svg--phone');
+
+ const path = document.createElementNS(ns, 'path');
+ path.setAttribute('d', 'M7.62 10.79a15.1 15.1 0 0 0 5.59 5.59l1.87-1.87a1 1 0 0 1 1.02-.24c1.12.37 2.31.57 3.52.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.61 21 3 13.39 3 4a1 1 0 0 1 1-1h3.37a1 1 0 0 1 1 1c0 1.21.2 2.4.57 3.52a1 1 0 0 1-.24 1.02l-1.08 1.27Z');
+ path.setAttribute('fill', 'currentColor');
+ svg.append(path);
+ return svg;
+}
+
function openConfirmContactModal(targetLogin = '') {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
@@ -438,9 +448,27 @@ function buildCallStatusText(callSummary) {
return 'Не дозвонился';
}
-function resolveDeliveryStatus(msg) {
+function resolveEffectiveReadState(messages, msg) {
+ if (msg?.from !== 'out') return { isRead: false, readAtMs: 0 };
+ const explicitReadAtMs = Number(msg?.readAtMs || 0);
+ if (Number.isFinite(explicitReadAtMs) && explicitReadAtMs > 0) {
+ return { isRead: true, readAtMs: explicitReadAtMs };
+ }
+ if (msg?.secondTick) {
+ return { isRead: true, readAtMs: 0 };
+ }
+ const messageTimeMs = resolveMessageTimeMs(msg);
+ const hasNewerRead = (messages || []).some((row) => {
+ if (row?.from !== 'out') return false;
+ if (resolveMessageTimeMs(row) <= messageTimeMs) return false;
+ return Boolean(Number(row?.readAtMs || 0) > 0 || row?.secondTick);
+ });
+ return { isRead: hasNewerRead, readAtMs: 0 };
+}
+
+function resolveDeliveryStatus(messages, msg) {
if (msg?.from !== 'out') return '';
- if (msg?.secondTick) return '✓✓';
+ if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓';
if (msg?.firstTick) return '✓';
return '…';
}
@@ -473,16 +501,21 @@ function scrollToLatestMessage(list) {
function renderMessageText(textNode, messageText) {
const text = String(messageText || '');
const emoji = text.trim();
- if (text === emoji && isTelegramAnimatedEmoji(emoji)) {
- textNode.classList.add('bubble-text--animated-emoji');
- textNode.append(createTelegramAnimatedEmoji(emoji, { className: 'chat-telegram-emoji' }));
+ if (text === emoji && hasTwemojiAsset(emoji)) {
+ textNode.classList.add('bubble-text--twemoji-only');
+ textNode.append(createTwemojiAsset(emoji, { className: 'chat-twemoji' }));
return;
}
- if (appendTelegramAnimatedText(textNode, text, { className: 'chat-telegram-emoji chat-telegram-emoji--inline' })) {
- textNode.classList.add('bubble-text--telegram-emoji-inline');
+ if (appendTwemojiText(textNode, text, { className: 'chat-twemoji chat-twemoji--inline' })) {
+ textNode.classList.add('bubble-text--twemoji-inline');
}
}
+function isTwemojiOnlyMessage(messageText) {
+ const emoji = String(messageText || '').trim();
+ return Boolean(emoji && String(messageText || '') === emoji && hasTwemojiAsset(emoji));
+}
+
function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
@@ -547,6 +580,10 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
+ const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || '');
+ if (emojiOnly) {
+ bubble.classList.add('bubble--emoji-only');
+ }
if (parsedText.callSummary) {
const callCard = document.createElement('div');
@@ -616,7 +653,7 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode);
- const status = resolveDeliveryStatus(msg);
+ const status = resolveDeliveryStatus(messages, msg);
if (status) {
const statusNode = document.createElement('span');
statusNode.className = 'bubble-status';
@@ -675,6 +712,49 @@ function preserveComposerSelection(input, callback) {
}
}
+async function mergeDirectMessagesPage(chatId, payloadMessages) {
+ const items = Array.isArray(payloadMessages) ? [...payloadMessages] : [];
+ items.sort((a, b) => Number(a?.timeMs || 0) - Number(b?.timeMs || 0));
+ for (const item of items) {
+ const blobB64 = String(item?.blobB64 || '').trim();
+ const messageKey = String(item?.messageKey || '').trim();
+ if (!blobB64 || !messageKey) continue;
+ try {
+ const parsed = authService.parseSignedMessageBlob(blobB64);
+ let text = '';
+ try {
+ const decrypted = await authService.decryptSignedMessageContent({
+ parsed,
+ login: state.session.login,
+ storagePwd: state.session.storagePwdInMemory,
+ });
+ text = String(decrypted?.text || '');
+ } catch (error) {
+ text = `Нерасшифрованное сообщение (${error?.message || 'decrypt failed'})`;
+ }
+ addSignedMessageToChat({
+ chatId,
+ messageKey,
+ baseKey: String(item?.baseKey || parsed?.baseKey || ''),
+ from: Number(parsed?.messageType || 0) === 2 ? 'out' : 'in',
+ text,
+ messageType: Number(parsed?.messageType || item?.messageType || 0),
+ unread: false,
+ readAtMs: Number(item?.readAtMs || 0),
+ rawBlobB64: blobB64,
+ revisionTimeMs: Number(item?.revisionTimeMs || parsed?.revisionTimeMs || 0),
+ });
+ } catch (error) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'dm-history',
+ message: 'Не удалось обработать сообщение из истории DM',
+ details: { chatId, messageKey, error: error?.message || 'unknown' },
+ });
+ }
+ }
+}
+
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
}
@@ -690,14 +770,19 @@ export function render({ navigate, route }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-chat-screen';
- const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings);
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
+ let historyHasMore = true;
+ let historyLoading = false;
+ let historyNextBeforeTimeMs = 0;
+ let historyNextBeforeMessageKey = '';
+ let historyBootstrapped = false;
+ let boundScrollContainer = null;
const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
- showTtsMissingConfigDialog(navigate);
+ showTtsMissingConfigDialog();
return;
}
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
@@ -747,6 +832,16 @@ export function render({ navigate, route }) {
const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
+ const historyLoader = document.createElement('div');
+ historyLoader.className = 'dm-history-loader';
+ historyLoader.hidden = true;
+ historyLoader.innerHTML = `
+
+
+ Загрузка истории…
+
+ `;
+
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
@@ -756,10 +851,10 @@ export function render({ navigate, route }) {
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
{
- label: '☎',
title: 'Позвонить',
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
+ iconNode: createHeaderPhoneHandsetIcon(),
onClick: handleStartCall,
},
{
@@ -870,8 +965,7 @@ export function render({ navigate, route }) {
- ☺
- ${isSpeechToTextReady ? '🎤 ' : ''}
+ ☺
➤
`;
@@ -898,6 +992,16 @@ export function render({ navigate, route }) {
}
};
+ const setHistoryLoadingState = (isLoading) => {
+ historyLoader.hidden = !isLoading;
+ historyLoader.classList.toggle('is-visible', !!isLoading);
+ };
+
+ const syncComposerLayout = () => {
+ const height = Number.parseFloat(input?.style.height || '0');
+ form.classList.toggle('dm-chat-input--multiline', height > 52);
+ };
+
const rememberEmojiSelection = () => {
if (!input) return;
const valueLength = String(input.value || '').length;
@@ -909,11 +1013,15 @@ export function render({ navigate, route }) {
const closeEmojiPicker = () => {
if (!emojiPickerOpen) return;
emojiPickerOpen = false;
- stopAllTelegramEmojiAnimations();
+ stopAllTwemojiAnimations();
emojiSlot.hidden = true;
emojiToggle?.setAttribute('aria-expanded', 'false');
};
+ // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера —
+ // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель.
+ const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true;
+
const insertEmoji = (emoji) => {
if (!input) return;
const value = String(input.value || '');
@@ -922,13 +1030,16 @@ export function render({ navigate, route }) {
input.value = `${value.slice(0, start)}${emoji}${value.slice(end)}`;
const nextPosition = start + emoji.length;
emojiSelection = { start: nextPosition, end: nextPosition };
- input.focus();
- try {
- input.setSelectionRange(nextPosition, nextPosition);
- } catch {
- // Ignore browsers that do not expose textarea selection during focus changes.
+ if (isFinePointer) {
+ input.focus();
+ try {
+ input.setSelectionRange(nextPosition, nextPosition);
+ } catch {
+ // Ignore browsers that do not expose textarea selection during focus changes.
+ }
}
autoResizeComposer(input);
+ syncComposerLayout();
};
if (emojiSlot) {
@@ -969,6 +1080,7 @@ export function render({ navigate, route }) {
if (restoreDraft && input) {
input.value = draftToRestore || '';
autoResizeComposer(input);
+ syncComposerLayout();
focusInputToEnd();
}
};
@@ -979,6 +1091,7 @@ export function render({ navigate, route }) {
syncEditBanner();
if (restoreDraft && input) {
autoResizeComposer(input);
+ syncComposerLayout();
focusInputToEnd();
}
};
@@ -1001,6 +1114,7 @@ export function render({ navigate, route }) {
if (input) {
input.value = String(parsed?.visibleText || '');
autoResizeComposer(input);
+ syncComposerLayout();
focusInputToEnd();
}
};
@@ -1162,6 +1276,7 @@ export function render({ navigate, route }) {
if (input) {
input.value = text;
autoResizeComposer(input);
+ syncComposerLayout();
focusInputToEnd();
}
addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`);
@@ -1180,10 +1295,14 @@ export function render({ navigate, route }) {
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
+ const readState = resolveEffectiveReadState(getChatMessages(chatId), msg);
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: parsed?.displayText || msg?.text || '',
+ infoText: (msg?.from === 'out' && readState.readAtMs > 0)
+ ? `Прочитано: ${formatMessageTime(readState.readAtMs)}`
+ : '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
@@ -1212,6 +1331,57 @@ export function render({ navigate, route }) {
});
};
+ const loadHistoryPage = async ({ preserveScroll = false } = {}) => {
+ if (historyLoading) return;
+ if (historyBootstrapped && !historyHasMore) return;
+ historyLoading = true;
+ setHistoryLoadingState(true);
+ const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
+ const prevScrollTop = Number(scrollContainer?.scrollTop || 0);
+ const prevScrollHeight = Number(scrollContainer?.scrollHeight || 0);
+ try {
+ const payload = await authService.getDirectMessages({
+ peerLogin: chatId,
+ limit: 50,
+ beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
+ beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
+ });
+ await mergeDirectMessagesPage(chatId, payload?.messages || []);
+ historyHasMore = Boolean(payload?.hasMore);
+ historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
+ historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
+ historyBootstrapped = true;
+ renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
+ if (preserveScroll) {
+ window.requestAnimationFrame(() => {
+ const nextHeight = Number(scrollContainer?.scrollHeight || 0);
+ scrollContainer.scrollTop = Math.max(0, prevScrollTop + (nextHeight - prevScrollHeight));
+ });
+ } else {
+ window.requestAnimationFrame(() => scrollToLatestMessage(log));
+ }
+ void sendReadReceiptsForVisible(chatId);
+ } catch (error) {
+ addAppLogEntry({
+ level: 'warn',
+ source: 'dm-history',
+ message: 'Не удалось загрузить историю DM',
+ details: { chatId, error: error?.message || 'unknown' },
+ });
+ } finally {
+ historyLoading = false;
+ setHistoryLoadingState(false);
+ }
+ };
+
+ const handleHistoryScroll = () => {
+ const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
+ if (!scrollContainer) return;
+ if (Number(scrollContainer.scrollTop || 0) > 120) return;
+ if (!historyBootstrapped || !historyHasMore || historyLoading) return;
+ void loadHistoryPage({ preserveScroll: true });
+ };
+
editCancelBtn?.addEventListener('click', () => {
if (activeEdit) {
cancelEditMode({ restoreDraft: true });
@@ -1221,9 +1391,11 @@ export function render({ navigate, route }) {
});
autoResizeComposer(input);
+ syncComposerLayout();
input?.addEventListener('input', () => {
rememberEmojiSelection();
autoResizeComposer(input);
+ syncComposerLayout();
});
input?.addEventListener('select', rememberEmojiSelection);
input?.addEventListener('keyup', rememberEmojiSelection);
@@ -1247,27 +1419,18 @@ export function render({ navigate, route }) {
emojiPickerOpen = !emojiPickerOpen;
emojiSlot.hidden = !emojiPickerOpen;
emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen));
+ // Либо клавиатура, либо пикер. На тач-устройствах при
+ // открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно.
+ if (emojiPickerOpen && !isFinePointer) input?.blur();
});
screen.addEventListener('pointerdown', (event) => {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
});
- // Enter — отправить; Ctrl+Enter — перенос строки.
+ // Enter — отправить; Shift+Enter и Ctrl+Enter — перенос строки.
input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
- if (event.ctrlKey) {
- event.preventDefault();
- const start = Number(input.selectionStart ?? input.value.length);
- const end = Number(input.selectionEnd ?? input.value.length);
- const value = String(input.value || '');
- input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
- const nextPos = start + 1;
- try {
- input.setSelectionRange(nextPos, nextPos);
- } catch {
- // ignore
- }
- autoResizeComposer(input);
+ if (event.shiftKey || event.ctrlKey) {
return;
}
event.preventDefault();
@@ -1275,26 +1438,12 @@ export function render({ navigate, route }) {
if (!text) return;
input.value = '';
autoResizeComposer(input);
+ syncComposerLayout();
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
});
- form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
- await openSpeechInputModal({
- navigate,
- onTextReady: (text) => {
- const prev = String(input.value || '').trim();
- input.value = prev ? `${prev} ${text}` : text;
- autoResizeComposer(input);
- },
- onSendText: async (text) => sendTextMessage(text),
- onSendQueued: () => {
- showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
- },
- });
- });
-
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
try {
@@ -1310,6 +1459,7 @@ export function render({ navigate, route }) {
if (!text) return;
input.value = '';
autoResizeComposer(input);
+ syncComposerLayout();
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
@@ -1329,32 +1479,31 @@ export function render({ navigate, route }) {
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
- wrap.append(log, form);
+ wrap.append(historyLoader, log, form);
screen.append(wrap);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
});
- if (hasUnreadIncoming) {
- window.requestAnimationFrame(() => scrollToUnreadSeparator(log));
- window.setTimeout(() => scrollToUnreadSeparator(log), 180);
- } else {
- window.requestAnimationFrame(() => scrollToLatestMessage(log));
- window.setTimeout(() => scrollToLatestMessage(log), 180);
- }
window.setTimeout(() => {
if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated();
}
}, 220);
void sendReadReceiptsForVisible(chatId);
+ window.requestAnimationFrame(() => {
+ boundScrollContainer = log.closest('.screen-content') || wrap.parentElement || wrap;
+ boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true });
+ void loadHistoryPage({ preserveScroll: true });
+ });
screen.cleanup = () => {
setChatKeyboardOpen(false);
- stopAllTelegramEmojiAnimations();
+ stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
+ boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
};
return screen;
}
diff --git a/shine-UI/js/pages/developer-settings-view.js b/shine-UI/js/pages/developer-settings-view.js
index f8f90e6e..a404bbbe 100644
--- a/shine-UI/js/pages/developer-settings-view.js
+++ b/shine-UI/js/pages/developer-settings-view.js
@@ -185,7 +185,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
async function forceUiUpdateNow() {
try {
- window.history.replaceState({}, '', '/settings-view');
+ window.history.replaceState({}, '', '/settings');
window.dispatchEvent(new PopStateEvent('popstate'));
} catch {}
if (!('serviceWorker' in navigator)) {
@@ -264,6 +264,7 @@ export function render({ navigate }) {
Клиент не обновляется?
Отправлять ошибки на сервер
Solana: init регистрации
+
Solana: проверить public RPC
Лог приложения
Диагностика PWA / Push
Как установить PWA
@@ -278,9 +279,11 @@ export function render({ navigate }) {
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
const solanaUsersInitBtn = card.querySelector('#settings-solana-users-init');
+ const solanaRpcCheckBtn = card.querySelector('#settings-solana-rpc-check');
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
solanaUsersInitBtn?.addEventListener('click', () => navigate('solana-users-init-view'));
+ solanaRpcCheckBtn?.addEventListener('click', () => navigate('solana-rpc-check-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({
diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js
index 7299a77b..620d7ef3 100644
--- a/shine-UI/js/pages/messages-list.js
+++ b/shine-UI/js/pages/messages-list.js
@@ -142,7 +142,7 @@ export function render({ navigate }) {
if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.';
if (timeEl) timeEl.textContent = String(item.time || '');
row.prepend(avatarWrap);
- row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`));
+ row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.id))}`));
return row;
}
diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js
index b76c1137..733e21a1 100644
--- a/shine-UI/js/pages/network-view.js
+++ b/shine-UI/js/pages/network-view.js
@@ -430,7 +430,7 @@ export function render({ navigate, route }) {
const act = btn.dataset.act;
if (act === 'close') hideNodeSheet();
else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); }
- else if (act === 'write') navigate(`chat-view/${encodeURIComponent(login)}`);
+ else if (act === 'write') navigate(`chat/${encodeURIComponent(login)}`);
};
}
@@ -462,7 +462,7 @@ export function render({ navigate, route }) {
point,
actions: [
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } },
- { label: 'Написать', onClick: () => navigate(`chat-view/${encodeURIComponent(login)}`) },
+ { label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(login)}`) },
],
});
},
diff --git a/shine-UI/js/pages/profile-edit-view.js b/shine-UI/js/pages/profile-edit-view.js
index f56e2fed..b86ac20c 100644
--- a/shine-UI/js/pages/profile-edit-view.js
+++ b/shine-UI/js/pages/profile-edit-view.js
@@ -96,7 +96,7 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Редактирование профиля',
- leftAction: { label: '←', onClick: () => navigate('settings-view') },
+ leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js
index 73845002..4682f5bf 100644
--- a/shine-UI/js/pages/profile-view.js
+++ b/shine-UI/js/pages/profile-view.js
@@ -101,8 +101,18 @@ export function render({ navigate }) {
const topActions = document.createElement('div');
topActions.className = 'profile-top-actions';
topActions.innerHTML = `
-
+
+
+
+
+
+
+
+
+
`;
+ topActions.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => navigate('profile-edit-view'));
+ topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet'));
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
const card = document.createElement('div');
diff --git a/shine-UI/js/pages/public-support-queue-view.js b/shine-UI/js/pages/public-support-queue-view.js
index f775a5a5..bfb9a782 100644
--- a/shine-UI/js/pages/public-support-queue-view.js
+++ b/shine-UI/js/pages/public-support-queue-view.js
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js';
+import { defaultSolanaCluster } from '../deploy-config.js';
import { state } from '../state.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
@@ -6,6 +7,7 @@ export const pageMeta = { id: 'queue', title: 'Очередь билета' };
const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v';
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
+const SHINE_MAINNET_SOLANA_ENDPOINT = 'https://solana-rpc.publicnode.com';
const SHINE_PAYMENTS_SEEDS = {
config: 'shine_payments_config',
coef: 'shine_payments_coef_limit',
@@ -15,6 +17,14 @@ const SHINE_PAYMENTS_SEEDS = {
q3: 'shine_payments_q3_ticket',
};
+function isTestDeployUi() {
+ return String(defaultSolanaCluster || '').trim().toLowerCase() !== 'mainnet-beta';
+}
+
+function resolveQueueEndpoint(useMainnetQueue) {
+ return useMainnetQueue ? SHINE_MAINNET_SOLANA_ENDPOINT : state.entrySettings.solanaServer;
+}
+
function utf8Bytes(text) {
return new TextEncoder().encode(String(text || ''));
}
@@ -57,6 +67,18 @@ function formatQueuePrefix(queueId) {
return 'Q1';
}
+function formatQueueTitle(queueId) {
+ if (queueId === 2) return 'Вторая очередь';
+ if (queueId === 3) return 'Третья очередь';
+ return 'Первая очередь';
+}
+
+function formatSupportTicketLabel(queueId, index) {
+ const cleanIndex = BigInt(index || 0n).toString();
+ if (queueId === 2 || queueId === 3) return `${queueId}/${cleanIndex}`;
+ return cleanIndex;
+}
+
function formatUsdCentsText(cents) {
const value = Number(cents || 0n) / 100;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
@@ -140,9 +162,9 @@ function parseSupportTicketInput(rawValue) {
if (/^\d+$/.test(clean)) {
return { queueId: 1, index: BigInt(clean) };
}
- const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/);
+ const match = clean.match(/^([23])\s*\/\s*(\d+)$/);
if (!match) {
- throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.');
+ throw new Error('Формат: для первой очереди просто номер, для второй и третьей - например 2/7 или 3/8.');
}
return {
queueId: Number(match[1]),
@@ -256,8 +278,7 @@ export function render({ navigate }) {
card.innerHTML = `
Публичная очередь билета
- Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8.
- Страница покажет состояние билета и сколько позиций осталось до выплаты.
+ Введите номер билета.
`;
@@ -269,7 +290,7 @@ export function render({ navigate }) {
const queryInput = document.createElement('input');
queryInput.id = 'public-queue-ticket-query';
queryInput.type = 'text';
- queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
+ queryInput.placeholder = 'Например: 12, 2/5 или 3/8';
queryInput.autocomplete = 'off';
queryInput.spellcheck = false;
styleInputField(queryInput);
@@ -286,7 +307,18 @@ export function render({ navigate }) {
result.className = 'card stack';
result.innerHTML = `
Введите номер билета и нажмите кнопку.
`;
+ const testDeploy = isTestDeployUi();
+ const mainnetRow = document.createElement('label');
+ mainnetRow.className = 'checkbox-row';
+ mainnetRow.innerHTML = `
+
+
Показывать данные mainnet (${SHINE_MAINNET_SOLANA_ENDPOINT})
+ `;
+ mainnetRow.style.display = testDeploy ? '' : 'none';
+ const mainnetCheckbox = mainnetRow.querySelector('#public-queue-mainnet-source');
+
let lastCoreState = null;
+ let lastEndpoint = '';
async function renderTicketInfo() {
const raw = String(queryInput.value || '').trim();
@@ -297,8 +329,12 @@ export function render({ navigate }) {
}
try {
const parsed = parseSupportTicketInput(raw);
- const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
+ const endpoint = resolveQueueEndpoint(Boolean(testDeploy && mainnetCheckbox?.checked));
+ const core = lastCoreState && lastEndpoint === endpoint
+ ? lastCoreState
+ : await loadSupportPaymentsCore(endpoint);
lastCoreState = core;
+ lastEndpoint = endpoint;
const solana = await loadSolanaWeb3();
const ticketPdaSeed = queueSeedFor(parsed.queueId);
const ticketIndexBytes = u64ToBytes(parsed.index);
@@ -309,9 +345,9 @@ export function render({ navigate }) {
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
if (!ticketInfo) {
result.innerHTML = `
-
Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.
+
Билет ${formatSupportTicketLabel(parsed.queueId, parsed.index)} пока не создан.
`;
- status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} не найден.`;
+ status.textContent = `Билет ${formatSupportTicketLabel(parsed.queueId, parsed.index)} не найден.`;
return;
}
const ticket = parsePaymentsTicket(ticketInfo.data);
@@ -319,18 +355,28 @@ export function render({ navigate }) {
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
- result.innerHTML = `
-
Билет: ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}
-
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
-
Получатель: ${ticket.recipientWallet}
-
До него в очереди: ${ticketsBefore.toString()} билетов
-
Из них уже выплачено: ${paidBefore.toString()} билетов
-
Ещё осталось до него: ${remainingBefore.toString()} билетов
-
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
-
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
-
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
- `;
- status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`;
+ if (ticket.queueId === 2 || ticket.queueId === 3) {
+ result.innerHTML = `
+
${formatQueueTitle(ticket.queueId)}
+
Номер билета: ${ticket.index.toString()}
+
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
+
Получатель: ${ticket.recipientWallet}
+
Сумма: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+ `;
+ } else {
+ result.innerHTML = `
+
Билет: ${formatSupportTicketLabel(parsed.queueId, ticket.index)}
+
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
+
Получатель: ${ticket.recipientWallet}
+
До него в очереди: ${ticketsBefore.toString()} билетов
+
Из них уже выплачено: ${paidBefore.toString()} билетов
+
Ещё осталось до него: ${remainingBefore.toString()} билетов
+
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
+
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
+ `;
+ }
+ status.textContent = `Билет ${formatSupportTicketLabel(ticket.queueId, ticket.index)} найден. Источник Solana: ${endpoint}`;
} catch (error) {
result.innerHTML = `
${String(error?.message || 'Не удалось загрузить билет')}
`;
status.textContent = `Не удалось посмотреть билет: ${error?.message || 'unknown'}`;
@@ -340,6 +386,11 @@ export function render({ navigate }) {
actions.querySelector('#public-queue-load')?.addEventListener('click', () => {
void renderTicketInfo();
});
+ mainnetCheckbox?.addEventListener('change', () => {
+ lastCoreState = null;
+ lastEndpoint = '';
+ status.textContent = `Источник Solana: ${resolveQueueEndpoint(Boolean(testDeploy && mainnetCheckbox.checked))}`;
+ });
actions.querySelector('#public-queue-reset')?.addEventListener('click', () => {
queryInput.value = '';
result.innerHTML = `
Введите номер билета и нажмите кнопку.
`;
@@ -352,7 +403,7 @@ export function render({ navigate }) {
}
});
- content.append(card, inputLabel, queryInput, actions, result, status);
+ content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
screen.append(
renderHeader({
title: 'Очередь билета',
diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js
index f66f6973..7189a89f 100644
--- a/shine-UI/js/pages/registration-keys-view.js
+++ b/shine-UI/js/pages/registration-keys-view.js
@@ -162,6 +162,9 @@ export function render({ navigate }) {
state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null;
+ state.registrationDraft.preGeneratedKeyBundle = null;
+ state.registrationPayment.walletAddress = '';
+ state.registrationPayment.balanceSOL = '0.0000';
await refreshSessions();
setAuthInfo(isLoginFlow
diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js
index 02ccdb0b..e7976e1f 100644
--- a/shine-UI/js/pages/registration-payment-view.js
+++ b/shine-UI/js/pages/registration-payment-view.js
@@ -16,6 +16,7 @@ import {
} from '../services/solana-wallet-service.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import {
+ checkLoginExistsOnSolana,
formatSolanaErrorDetails,
isUserAlreadyExistsSolanaError,
registerUserOnSolana,
@@ -24,8 +25,14 @@ import { defaultServerLogin } from '../deploy-config.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01;
-const AUTO_LOGIN_INITIAL_DELAY_MS = 10000;
+const RECOMMENDED_TOPUP_SOL = 0.02;
+const AUTO_LOGIN_INITIAL_DELAY_MS = 1000;
const AUTO_LOGIN_RETRY_MS = 2000;
+const REGISTRATION_PROGRESS_DURATION_MS = 12000;
+const REGISTRATION_POLL_START_DELAY_MS = 4000;
+const REGISTRATION_POLL_INTERVAL_MS = 2000;
+const REGISTRATION_CONFIRM_TIMEOUT_MS = 25000;
+const REGISTRATION_SUCCESS_SETTLE_DELAY_MS = 2000;
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
function getExplorerClusterName(endpoint) {
@@ -36,6 +43,20 @@ function getExplorerClusterName(endpoint) {
return 'mainnet-beta';
}
+function isTestRegistrationContour() {
+ const solanaCluster = getExplorerClusterName(state.entrySettings.solanaServer);
+ if (solanaCluster !== 'mainnet-beta') return true;
+ const shineServer = String(state.entrySettings.shineServer || '').trim().toLowerCase();
+ const shineServerLogin = String(state.entrySettings.shineServerLogin || '').trim().toLowerCase();
+ return (
+ shineServer.includes('t1.shineup.me')
+ || shineServer.includes('t2.shineup.me')
+ || shineServer.includes('t3.shineup.me')
+ || shineServer.includes('t4.shineup.me')
+ || ['t1', 't2', 't3', 't4'].includes(shineServerLogin)
+ );
+}
+
function makeSolanaExplorerTxUrl(signature, endpoint) {
const cleanSignature = String(signature || '').trim();
if (!cleanSignature) return '';
@@ -60,6 +81,10 @@ function getCryptoRuntimeState() {
return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext };
}
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
async function completeRegistrationLogin({ navigate, keyBundle }) {
await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser(
@@ -142,14 +167,16 @@ export function render({ navigate }) {
walletValue.type = 'text';
walletValue.value = state.registrationPayment.walletAddress || '';
walletValue.readOnly = true;
+ walletValue.style.width = '100%';
const walletRow = document.createElement('div');
- walletRow.className = 'inline-input-row';
+ walletRow.className = 'stack';
const copyButton = document.createElement('button');
copyButton.className = 'ghost-btn';
copyButton.type = 'button';
copyButton.textContent = 'Скопировать номер';
+ copyButton.style.width = '100%';
copyButton.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(walletValue.value);
@@ -222,6 +249,7 @@ export function render({ navigate }) {
balanceRow.append(balanceValue, refreshButton);
+ const isTestContour = isTestRegistrationContour();
const topupButton = document.createElement('button');
topupButton.className = 'ghost-btn';
topupButton.type = 'button';
@@ -267,11 +295,15 @@ export function render({ navigate }) {
if (currentBalance == null) return;
if (currentBalance < MIN_REQUIRED_SOL) {
status.className = 'status-line is-unavailable';
- status.textContent = `Для регистрации нужно минимум ${MIN_REQUIRED_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL. Пополните на промо-странице или попросите перевод у знакомого с тестовыми SOL.`;
+ status.textContent = isTestContour
+ ? `Для регистрации нужно минимум ${MIN_REQUIRED_SOL} SOL. Рекомендуем пополнить кошелёк примерно на ${RECOMMENDED_TOPUP_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL.`
+ : `Для регистрации пополните этот кошелёк соланами примерно на ${RECOMMENDED_TOPUP_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL. Минимум для продолжения: ${MIN_REQUIRED_SOL} SOL.`;
status.style.display = '';
- const openTopup = window.confirm('Открыть страницу пополнения с вашим кошельком?');
- if (openTopup) {
- window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
+ if (isTestContour) {
+ const openTopup = window.confirm('Открыть страницу пополнения с вашим кошельком?');
+ if (openTopup) {
+ window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
+ }
}
return;
}
@@ -299,7 +331,7 @@ export function render({ navigate }) {
}
}
- renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId });
+ renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message);
@@ -313,15 +345,18 @@ export function render({ navigate }) {
});
card.innerHTML = `
-
Для регистрации в тестовой Solana нужно минимум 0,01 SOL на вашем кошельке.
-
Номер кошелька (client.key)
+
Для регистрации пополните этот кошелёк соланами примерно на ${RECOMMENDED_TOPUP_SOL} SOL.
+
Номер кошелька
Баланс (Solana)
`;
card.children[1].append(walletRow);
card.children[2].append(balanceRow);
- card.append(topupButton, showKeysButton, submitButton, status);
+ if (isTestContour) {
+ card.append(topupButton);
+ }
+ card.append(showKeysButton, submitButton, status);
screen.append(
renderHeader({
@@ -345,6 +380,192 @@ export function render({ navigate }) {
return screen;
}
+function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) {
+ const screen = document.querySelector('section.stack');
+ if (!screen) return;
+ const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
+ const card = screen.querySelector('.card.stack');
+ if (!card) return;
+ card.classList.add('registration-finish-card');
+
+ let progressTimerId = null;
+ let pollStartTimerId = null;
+ let pollTimerId = null;
+ let successTimerId = null;
+ let confirmationInFlight = false;
+ let stageClosed = false;
+ let successPending = false;
+ let timeoutShown = false;
+ const stageStartedAt = Date.now();
+ const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer);
+
+ const title = document.createElement('h2');
+ title.className = 'registration-finish-title';
+ title.textContent = 'Идёт регистрация в блокчейне...';
+
+ const hint = document.createElement('p');
+ hint.className = 'auth-copy registration-finish-text';
+ hint.textContent = 'Ждём, пока транзакция будет полностью одобрена сетью Solana.';
+
+ const txIdLine = document.createElement('p');
+ txIdLine.className = 'meta-muted registration-finish-tx';
+ if (registrationTxId && txExplorerUrl) {
+ const txLabel = document.createElement('span');
+ txLabel.textContent = 'Tx ID регистрации: ';
+ const txLink = document.createElement('a');
+ txLink.className = 'registration-finish-tx-link';
+ txLink.href = txExplorerUrl;
+ txLink.target = '_blank';
+ txLink.rel = 'noopener noreferrer';
+ txLink.textContent = registrationTxId;
+ txIdLine.append(txLabel, txLink);
+ } else {
+ txIdLine.textContent = 'Tx ID регистрации: ожидаем подтверждённую подпись';
+ }
+
+ const progressWrap = document.createElement('div');
+ progressWrap.className = 'registration-progress';
+
+ const progressBar = document.createElement('div');
+ progressBar.className = 'registration-progress-bar';
+ progressWrap.append(progressBar);
+
+ const progress = document.createElement('p');
+ progress.className = 'meta-muted registration-finish-progress';
+ progress.textContent = 'Подготавливаем проверку подтверждения...';
+
+ const pollStatus = document.createElement('p');
+ pollStatus.className = 'meta-muted registration-finish-progress';
+ pollStatus.textContent = 'Через несколько секунд начнём проверять подтверждение регистрации.';
+
+ const stopTimers = () => {
+ if (progressTimerId) {
+ window.clearInterval(progressTimerId);
+ progressTimerId = null;
+ }
+ if (pollStartTimerId) {
+ window.clearTimeout(pollStartTimerId);
+ pollStartTimerId = null;
+ }
+ if (pollTimerId) {
+ window.clearInterval(pollTimerId);
+ pollTimerId = null;
+ }
+ if (successTimerId) {
+ window.clearTimeout(successTimerId);
+ successTimerId = null;
+ }
+ };
+
+ const updateVisualProgress = () => {
+ const elapsed = Date.now() - stageStartedAt;
+ let widthPercent = 0;
+ if (elapsed <= REGISTRATION_PROGRESS_DURATION_MS) {
+ const phaseRatio = clamp(elapsed / REGISTRATION_PROGRESS_DURATION_MS, 0, 1);
+ widthPercent = 100 * (1 - ((1 - phaseRatio) ** 1.85));
+ } else {
+ const extraRatio = 1 - Math.exp(-(elapsed - REGISTRATION_PROGRESS_DURATION_MS) / 9000);
+ widthPercent = 88 + (10 * clamp(extraRatio, 0, 1));
+ }
+ progressBar.style.width = `${clamp(widthPercent, 0, 98)}%`;
+
+ if (elapsed < REGISTRATION_POLL_START_DELAY_MS) {
+ progress.textContent = 'Отправили регистрацию в сеть. Даём транзакции несколько секунд на обработку.';
+ return;
+ }
+ if (elapsed < REGISTRATION_CONFIRM_TIMEOUT_MS) {
+ progress.textContent = 'Идёт ожидание подтверждения Solana. Индикатор может замедлиться, это нормально.';
+ return;
+ }
+ progress.textContent = 'Подтверждение затянулось. Продолжаем автоматическую проверку.';
+ };
+
+ const finalizeSuccess = () => {
+ if (stageClosed || successPending) return;
+ successPending = true;
+ stopTimers();
+ progress.textContent = 'Подтверждение получено. Завершаем регистрацию...';
+ pollStatus.textContent = 'Запись уже найдена в Solana, готовим финальный экран.';
+ status.style.display = 'none';
+ progressBar.style.transition = `width ${REGISTRATION_SUCCESS_SETTLE_DELAY_MS}ms ease-out`;
+ progressBar.style.width = '100%';
+ successTimerId = window.setTimeout(() => {
+ if (stageClosed) return;
+ stageClosed = true;
+ renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId });
+ }, REGISTRATION_SUCCESS_SETTLE_DELAY_MS);
+ };
+
+ const showTimeoutState = () => {
+ if (timeoutShown || stageClosed) return;
+ timeoutShown = true;
+ status.className = 'status-line is-unavailable';
+ status.textContent = 'Подтверждение не пришло вовремя. Возможно, регистрация уже прошла, а возможно ещё нет. Проверьте Tx ID ниже: мы продолжим автоматическую проверку.';
+ status.style.display = '';
+ pollStatus.textContent = 'Пока подтверждения нет. Подождите ещё немного, мы продолжаем проверять регистрацию.';
+ };
+
+ const tryCheckRegistration = async () => {
+ if (stageClosed || confirmationInFlight) return;
+ confirmationInFlight = true;
+ progress.textContent = 'Проверяем подтверждение регистрации в Solana...';
+ status.style.display = timeoutShown ? '' : 'none';
+ try {
+ const result = await checkLoginExistsOnSolana({
+ login: state.registrationDraft.login,
+ solanaEndpoint: state.entrySettings.solanaServer,
+ });
+ if (result?.exists) {
+ finalizeSuccess();
+ return;
+ }
+ pollStatus.textContent = 'Пока ещё не прошла, подождите ещё немного...';
+ if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) {
+ showTimeoutState();
+ }
+ } catch (error) {
+ console.warn('Registration confirmation check failed', toUserMessage(error, 'registration-confirmation'));
+ pollStatus.textContent = 'Проверяем регистрацию повторно...';
+ if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) {
+ showTimeoutState();
+ }
+ } finally {
+ confirmationInFlight = false;
+ }
+ };
+
+ if (headerBackButton) {
+ const replacement = headerBackButton.cloneNode(true);
+ replacement.addEventListener('click', () => {
+ stageClosed = true;
+ stopTimers();
+ navigate('start-view');
+ });
+ headerBackButton.replaceWith(replacement);
+ }
+
+ card.innerHTML = '';
+ status.style.display = 'none';
+ card.append(title, hint, txIdLine, progressWrap, progress, pollStatus, status);
+
+ updateVisualProgress();
+ progressTimerId = window.setInterval(() => {
+ if (stageClosed) return;
+ updateVisualProgress();
+ if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) {
+ showTimeoutState();
+ }
+ }, 160);
+
+ pollStartTimerId = window.setTimeout(() => {
+ if (stageClosed) return;
+ void tryCheckRegistration();
+ pollTimerId = window.setInterval(() => {
+ void tryCheckRegistration();
+ }, REGISTRATION_POLL_INTERVAL_MS);
+ }, REGISTRATION_POLL_START_DELAY_MS);
+}
+
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
@@ -365,7 +586,7 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
const hint = document.createElement('p');
hint.className = 'auth-copy registration-finish-text';
- hint.textContent = 'Подождите 10 секунд, пока обновится транзакция вашей регистрации в блокчейне Solana. После этого вход в аккаунт произойдёт автоматически.';
+ hint.textContent = 'Регистрация подтверждена в блокчейне Solana. Сейчас выполним автоматический вход в аккаунт.';
const txIdLine = document.createElement('p');
txIdLine.className = 'meta-muted registration-finish-tx';
diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js
index e946644d..a66c16ec 100644
--- a/shine-UI/js/pages/settings-view.js
+++ b/shine-UI/js/pages/settings-view.js
@@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js';
-import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js';
-import { makeProfileLinksRoute } from '../services/shine-routes.js';
+import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -41,26 +40,16 @@ export function render({ navigate }) {
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
-
Редактировать профиль
-
Кошелёк
-
Показать связи
Устройства
Remote AddBlock через homeserver
Настройки серверов
-
Настройки инструментов ввода
Язык / Language
Завершить текущий сеанс
`;
- card.querySelector('#settings-profile-edit').addEventListener('click', () => navigate('profile-edit-view'));
- card.querySelector('#settings-wallet').addEventListener('click', () => navigate('wallet-view'));
- card.querySelector('#settings-links').addEventListener('click', () => {
- navigate(makeProfileLinksRoute(state.session.login));
- });
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
- card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout');
diff --git a/shine-UI/js/pages/solana-rpc-check-view.js b/shine-UI/js/pages/solana-rpc-check-view.js
new file mode 100644
index 00000000..1974acc9
--- /dev/null
+++ b/shine-UI/js/pages/solana-rpc-check-view.js
@@ -0,0 +1,258 @@
+import { renderHeader } from '../components/header.js';
+
+export const pageMeta = { id: 'solana-rpc-check-view', title: 'Проверка Solana RPC' };
+
+const MAINNET_RPC_ENDPOINTS = [
+ { name: 'GetBlock', url: 'https://go.getblock.us/86aac42ad4484f3c813079afc201451c' },
+ { name: 'PublicNode', url: 'https://solana-rpc.publicnode.com' },
+ { name: 'BlockEden', url: 'https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc' },
+ { name: 'dRPC', url: 'https://solana.drpc.org/' },
+ { name: 'Grove', url: 'https://solana.rpc.grove.city/v1/01fdb492' },
+ { name: 'LeoRPC', url: 'https://solana.leorpc.com/?api_key=FREE' },
+ { name: 'OnFinality', url: 'https://solana.api.onfinality.io/public' },
+ { name: 'Solana Foundation', url: 'https://api.mainnet-beta.solana.com' },
+ { name: 'Solana Vibe Station', url: 'https://public.rpc.solanavibestation.com/' },
+ { name: 'TheRPC', url: 'https://solana.therpc.io' },
+];
+
+const RPC_PAYLOAD = {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'getVersion',
+ params: [],
+};
+
+function classifyResult(result) {
+ if (!result) return { label: 'Нет данных', tone: 'neutral' };
+ if (result.ok) return { label: 'Работает в браузере', tone: 'ok' };
+ if (result.httpStatus === 402) return { label: 'Требует платный план', tone: 'bad' };
+ if (result.httpStatus === 403) return { label: 'Доступ запрещён', tone: 'bad' };
+ if (result.httpStatus === 429) return { label: 'Упёрлась в rate limit', tone: 'warn' };
+ if (result.httpStatus >= 500) return { label: 'Ошибка на стороне RPC', tone: 'bad' };
+ if (result.errorCode === 35) return { label: 'Free tier не даёт mainnet', tone: 'bad' };
+ if (result.networkError) return { label: 'Не проходит из браузера', tone: 'bad' };
+ return { label: 'Отвечает, но не подходит', tone: 'warn' };
+}
+
+function shortenText(value, max = 140) {
+ const text = String(value || '').trim();
+ if (!text) return '—';
+ return text.length > max ? `${text.slice(0, max)}...` : text;
+}
+
+async function testEndpoint(url, timeoutMs = 12000) {
+ const startedAt = performance.now();
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ mode: 'cors',
+ cache: 'no-store',
+ credentials: 'omit',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(RPC_PAYLOAD),
+ signal: controller.signal,
+ });
+ const elapsedMs = Math.round(performance.now() - startedAt);
+ let data = null;
+ let raw = '';
+ try {
+ raw = await response.text();
+ data = raw ? JSON.parse(raw) : null;
+ } catch {
+ data = null;
+ }
+ return {
+ url,
+ ok: response.ok && !!data?.result,
+ httpStatus: response.status,
+ elapsedMs,
+ result: data?.result || null,
+ errorCode: data?.error?.code ?? null,
+ errorMessage: data?.error?.message || '',
+ raw: shortenText(raw, 180),
+ networkError: '',
+ };
+ } catch (error) {
+ const elapsedMs = Math.round(performance.now() - startedAt);
+ return {
+ url,
+ ok: false,
+ httpStatus: 0,
+ elapsedMs,
+ result: null,
+ errorCode: null,
+ errorMessage: '',
+ raw: '',
+ networkError: error?.name === 'AbortError' ? 'Timeout' : (error?.message || 'Network error'),
+ };
+ } finally {
+ window.clearTimeout(timeoutId);
+ }
+}
+
+function makeResultCard(endpoint) {
+ const card = document.createElement('article');
+ card.className = 'card stack solana-rpc-card';
+
+ const head = document.createElement('div');
+ head.className = 'solana-rpc-card-head';
+
+ const title = document.createElement('strong');
+ title.textContent = endpoint.name;
+
+ const badge = document.createElement('span');
+ badge.className = 'badge alt solana-rpc-badge';
+ badge.textContent = 'Ожидает проверки';
+
+ head.append(title, badge);
+
+ const endpointLine = document.createElement('a');
+ endpointLine.className = 'solana-rpc-url';
+ endpointLine.href = endpoint.url;
+ endpointLine.target = '_blank';
+ endpointLine.rel = 'noopener noreferrer';
+ endpointLine.textContent = endpoint.url;
+
+ const meta = document.createElement('div');
+ meta.className = 'solana-rpc-meta';
+
+ const statusLine = document.createElement('div');
+ statusLine.className = 'meta-muted';
+ statusLine.textContent = 'Ещё не проверяли.';
+
+ const details = document.createElement('div');
+ details.className = 'meta-muted solana-rpc-details';
+ details.textContent = '—';
+
+ meta.append(statusLine, details);
+ card.append(head, endpointLine, meta);
+
+ return { card, badge, statusLine, details };
+}
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const intro = document.createElement('div');
+ intro.className = 'card stack';
+ intro.innerHTML = `
+
Проверка mainnet RPC из браузера
+
+ Этот экран делает реальный browser fetch к публичным Solana mainnet RPC.
+ Если ответ приходит прямо здесь, значит нода годится для клиентского UI без промежуточного сервера.
+
+ `;
+
+ const controls = document.createElement('div');
+ controls.className = 'auth-footer-actions';
+
+ const runBtn = document.createElement('button');
+ runBtn.className = 'primary-btn';
+ runBtn.type = 'button';
+ runBtn.textContent = 'Проверить все ноды';
+
+ const resetBtn = document.createElement('button');
+ resetBtn.className = 'ghost-btn';
+ resetBtn.type = 'button';
+ resetBtn.textContent = 'Сбросить';
+
+ controls.append(runBtn, resetBtn);
+ intro.append(controls);
+
+ const summary = document.createElement('p');
+ summary.className = 'status-line';
+ summary.textContent = 'Пока проверки не запускались.';
+
+ const grid = document.createElement('div');
+ grid.className = 'stack';
+
+ const cards = MAINNET_RPC_ENDPOINTS.map((endpoint) => {
+ const ui = makeResultCard(endpoint);
+ grid.append(ui.card);
+ return { endpoint, ui };
+ });
+
+ let runToken = 0;
+
+ const resetState = () => {
+ runToken += 1;
+ summary.className = 'status-line';
+ summary.textContent = 'Пока проверки не запускались.';
+ cards.forEach(({ ui }) => {
+ ui.badge.className = 'badge alt solana-rpc-badge';
+ ui.badge.textContent = 'Ожидает проверки';
+ ui.statusLine.textContent = 'Ещё не проверяли.';
+ ui.details.textContent = '—';
+ });
+ };
+
+ const runChecks = async () => {
+ const token = ++runToken;
+ runBtn.disabled = true;
+ resetBtn.disabled = true;
+ summary.className = 'status-line';
+ summary.textContent = 'Проверяем mainnet RPC из браузера...';
+
+ let okCount = 0;
+ let warnCount = 0;
+ let badCount = 0;
+
+ const tasks = cards.map(async ({ endpoint, ui }) => {
+ ui.badge.className = 'badge alt solana-rpc-badge';
+ ui.badge.textContent = 'Проверяем...';
+ ui.statusLine.textContent = 'Отправляем JSON-RPC запрос getVersion...';
+ ui.details.textContent = 'Ждём ответ.';
+
+ const result = await testEndpoint(endpoint.url);
+ if (token !== runToken) return;
+
+ const verdict = classifyResult(result);
+ if (verdict.tone === 'ok') okCount += 1;
+ else if (verdict.tone === 'warn') warnCount += 1;
+ else if (verdict.tone === 'bad') badCount += 1;
+
+ ui.badge.className = `badge solana-rpc-badge is-${verdict.tone}`;
+ ui.badge.textContent = verdict.label;
+ ui.statusLine.textContent = `HTTP ${result.httpStatus || 'ERR'} • ${result.elapsedMs} мс`;
+ if (result.ok) {
+ ui.details.textContent = `solana-core: ${result.result?.['solana-core'] || 'unknown'} • feature-set: ${result.result?.['feature-set'] || 'unknown'}`;
+ } else if (result.networkError) {
+ ui.details.textContent = `Browser error: ${result.networkError}`;
+ } else if (result.errorMessage) {
+ ui.details.textContent = shortenText(result.errorMessage, 180);
+ } else {
+ ui.details.textContent = result.raw || 'Нода ответила без пригодного JSON-RPC result.';
+ }
+ });
+
+ await Promise.all(tasks);
+ if (token !== runToken) return;
+
+ summary.className = 'status-line is-available';
+ summary.textContent = `Готово. Для браузера подходят: ${okCount}. С оговорками: ${warnCount}. Не подходят: ${badCount}.`;
+ runBtn.disabled = false;
+ resetBtn.disabled = false;
+ };
+
+ runBtn.addEventListener('click', () => {
+ void runChecks();
+ });
+ resetBtn.addEventListener('click', resetState);
+
+ screen.append(
+ renderHeader({
+ title: 'Проверка Solana RPC',
+ leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
+ }),
+ intro,
+ summary,
+ grid,
+ );
+
+ void runChecks();
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/tools-settings-view.js b/shine-UI/js/pages/tools-settings-view.js
deleted file mode 100644
index b2391b7a..00000000
--- a/shine-UI/js/pages/tools-settings-view.js
+++ /dev/null
@@ -1,168 +0,0 @@
-import { renderHeader } from '../components/header.js';
-import { saveEntrySettings, state } from '../state.js';
-import { speakTextBySettings } from '../services/speech-tools-service.js';
-
-export const pageMeta = { id: 'tools-settings-view', title: 'Настройки инструментов' };
-
-function optionsMarkup(options, selected) {
- return options.map((opt) => (
- `
${opt.label} `
- )).join('');
-}
-
-export function render({ navigate }) {
- const screen = document.createElement('section');
- screen.className = 'stack';
-
- const stt = state.entrySettings.tools?.speechToText || {};
- const tts = state.entrySettings.tools?.textToSpeech || {};
-
- const card = document.createElement('div');
- card.className = 'card stack';
- card.innerHTML = `
-
Распознавание речи
-
Провайдер
-
- ${optionsMarkup([{ value: 'openai', label: 'OpenAI (Whisper/Transcribe)' }], String(stt.provider || 'openai'))}
-
-
Уровень
-
- ${optionsMarkup([
- { value: 'easy', label: 'Easy (дешевле)' },
- { value: 'medium', label: 'Medium' },
- { value: 'hard', label: 'Hard (лучше)' },
- ], String(stt.quality || 'medium'))}
-
-
Адрес OpenAI API
-
-
Кастомная модель (опционально)
-
-
API key
-
- `;
-
- const card2 = document.createElement('div');
- card2.className = 'card stack';
- card2.innerHTML = `
-
Прочесть вслух (TTS)
-
Провайдер
-
- ${optionsMarkup([
- { value: 'browser', label: 'Браузер (SpeechSynthesis)' },
- { value: 'piper-http', label: 'Piper (локальный HTTP)' },
- { value: 'openai', label: 'OpenAI TTS API' },
- ], String(tts.provider || 'browser'))}
-
-
Уровень
-
- ${optionsMarkup([
- { value: 'easy', label: 'Easy' },
- { value: 'medium', label: 'Medium' },
- { value: 'hard', label: 'Hard' },
- ], String(tts.quality || 'medium'))}
-
-
Голос
-
-
Piper HTTP адрес
-
-
OpenAI/внешний API адрес
-
-
API key (для внешнего API)
-
-
Модель (для внешнего API)
-
-
Тестовая фраза
-
-
- Проверить озвучку
- Загрузить и настроить (шаблон)
- Ссылки на Piper/голоса
-
-
Для офлайн-озвучки через Piper используйте локальный HTTP-обёртчик. Кнопка «шаблон» подставляет базовые значения.
- `;
-
- const actions = document.createElement('div');
- actions.className = 'auth-footer-actions';
- actions.innerHTML = `
-
Отмена
-
Сохранить
- `;
-
- actions.querySelector('#tools-cancel')?.addEventListener('click', () => navigate('settings-view'));
- actions.querySelector('#tools-save')?.addEventListener('click', async () => {
- const next = {
- ...state.entrySettings,
- tools: {
- speechToText: {
- provider: card.querySelector('#stt-provider')?.value || 'openai',
- quality: card.querySelector('#stt-quality')?.value || 'medium',
- baseUrl: String(card.querySelector('#stt-base-url')?.value || '').trim(),
- apiKey: String(card.querySelector('#stt-api-key')?.value || '').trim(),
- model: String(card.querySelector('#stt-model')?.value || '').trim(),
- },
- textToSpeech: {
- provider: card2.querySelector('#tts-provider')?.value || 'browser',
- quality: card2.querySelector('#tts-quality')?.value || 'medium',
- voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
- piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
- externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
- apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
- model: String(card2.querySelector('#tts-model')?.value || '').trim(),
- },
- },
- };
- await saveEntrySettings(next);
- navigate('settings-view');
- });
-
- card2.querySelector('#piper-autofill')?.addEventListener('click', () => {
- card2.querySelector('#tts-provider').value = 'piper-http';
- card2.querySelector('#tts-piper-url').value = 'http://127.0.0.1:5000';
- card2.querySelector('#tts-quality').value = 'medium';
- if (!String(card2.querySelector('#tts-voice').value || '').trim()) {
- card2.querySelector('#tts-voice').value = 'ru_RU-irina-medium';
- }
- });
-
- card2.querySelector('#piper-links')?.addEventListener('click', () => {
- window.open('https://github.com/rhasspy/piper', '_blank', 'noopener,noreferrer');
- window.open('https://huggingface.co/rhasspy/piper-voices/tree/main', '_blank', 'noopener,noreferrer');
- });
-
- card2.querySelector('#tts-test-btn')?.addEventListener('click', async () => {
- const ttsProvider = card2.querySelector('#tts-provider')?.value || 'openai';
- const text = String(card2.querySelector('#tts-test-text')?.value || '').trim();
- const runtimeSettings = {
- ...state.entrySettings,
- tools: {
- ...state.entrySettings.tools,
- textToSpeech: {
- provider: ttsProvider,
- quality: card2.querySelector('#tts-quality')?.value || 'medium',
- voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
- piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
- externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
- apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
- model: String(card2.querySelector('#tts-model')?.value || '').trim(),
- },
- },
- };
- try {
- await speakTextBySettings(text || 'Проверка озвучки', runtimeSettings);
- } catch (error) {
- window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
- }
- });
-
- screen.append(
- renderHeader({
- title: 'Настройки инструментов',
- leftAction: { label: '←', onClick: () => navigate('settings-view') },
- }),
- card,
- card2,
- actions,
- );
-
- return screen;
-}
diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js
index 01da7f7d..2462493e 100644
--- a/shine-UI/js/pages/wallet-view.js
+++ b/shine-UI/js/pages/wallet-view.js
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js';
+import { defaultSolanaCluster } from '../deploy-config.js';
import { authService, state } from '../state.js';
import {
formatSol,
@@ -65,6 +66,7 @@ function sessionArgsOrThrow() {
const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v';
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
+const SHINE_MAINNET_SOLANA_ENDPOINT = 'https://solana-rpc.publicnode.com';
const SHINE_PAYMENTS_SEEDS = {
config: 'shine_payments_config',
coef: 'shine_payments_coef_limit',
@@ -84,6 +86,14 @@ const SUPPORT_TICKET_HELP_TEXT = [
'Номер билета нужно сохранить отдельно: именно по нему удобно отслеживать, когда он дойдет до выплаты.',
].join('\n');
+function isTestDeployUi() {
+ return String(defaultSolanaCluster || '').trim().toLowerCase() !== 'mainnet-beta';
+}
+
+function resolveSupportQueueEndpoint(useMainnetQueue) {
+ return useMainnetQueue ? SHINE_MAINNET_SOLANA_ENDPOINT : state.entrySettings.solanaServer;
+}
+
function utf8Bytes(text) {
return new TextEncoder().encode(String(text || ''));
}
@@ -190,6 +200,18 @@ function formatQueuePrefix(queueId) {
return 'Q1';
}
+function formatQueueTitle(queueId) {
+ if (queueId === 2) return 'Вторая очередь';
+ if (queueId === 3) return 'Третья очередь';
+ return 'Первая очередь';
+}
+
+function formatSupportTicketLabel(queueId, index) {
+ const cleanIndex = BigInt(index || 0n).toString();
+ if (queueId === 2 || queueId === 3) return `${queueId}/${cleanIndex}`;
+ return cleanIndex;
+}
+
function parsePaymentsCoef(data) {
let offset = 0;
const version = data[offset];
@@ -266,9 +288,9 @@ function parseSupportTicketInput(rawValue) {
if (/^\d+$/.test(clean)) {
return { queueId: 1, index: BigInt(clean) };
}
- const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/);
+ const match = clean.match(/^([23])\s*\/\s*(\d+)$/);
if (!match) {
- throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.');
+ throw new Error('Формат: для первой очереди просто номер, для второй и третьей - например 2/7 или 3/8.');
}
return {
queueId: Number(match[1]),
@@ -411,7 +433,7 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Кошелёк',
- leftAction: { label: '←', onClick: () => navigate('settings-view') },
+ leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
content,
status,
@@ -467,7 +489,7 @@ export function render({ navigate }) {
void renderSupportBuyIntro();
});
actions.querySelector('#support-queue')?.addEventListener('click', () => {
- void renderSupportQueue();
+ navigate('quene');
});
actions.querySelector('#support-keygen')?.addEventListener('click', () => {
void renderSupportKeygen();
@@ -679,8 +701,7 @@ export function render({ navigate }) {
card.innerHTML = `
Посмотреть очередь билета
- Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8.
- Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.
+ Введите номер билета.
`;
@@ -692,11 +713,21 @@ export function render({ navigate }) {
const queryInput = document.createElement('input');
queryInput.id = 'support-ticket-query';
queryInput.type = 'text';
- queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
+ queryInput.placeholder = 'Например: 12, 2/5 или 3/8';
queryInput.autocomplete = 'off';
queryInput.spellcheck = false;
styleSupportInputField(queryInput);
+ const testDeploy = isTestDeployUi();
+ const mainnetRow = document.createElement('label');
+ mainnetRow.className = 'checkbox-row';
+ mainnetRow.innerHTML = `
+
+
Показывать данные mainnet (${SHINE_MAINNET_SOLANA_ENDPOINT})
+ `;
+ mainnetRow.style.display = testDeploy ? '' : 'none';
+ const mainnetCheckbox = mainnetRow.querySelector('#support-ticket-mainnet-source');
+
const actions = document.createElement('div');
actions.className = 'row';
actions.innerHTML = `
@@ -711,6 +742,7 @@ export function render({ navigate }) {
`;
let lastCoreState = null;
+ let lastEndpoint = '';
const renderTicketInfo = async () => {
const raw = String(queryInput.value || '').trim();
@@ -720,8 +752,12 @@ export function render({ navigate }) {
}
try {
const parsed = parseSupportTicketInput(raw);
- const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
+ const endpoint = resolveSupportQueueEndpoint(Boolean(testDeploy && mainnetCheckbox?.checked));
+ const core = lastCoreState && lastEndpoint === endpoint
+ ? lastCoreState
+ : await loadSupportPaymentsCore(endpoint);
lastCoreState = core;
+ lastEndpoint = endpoint;
const solana = await loadSolanaWeb3();
const ticketPdaSeed = queueSeedFor(parsed.queueId);
const ticketIndexBytes = u64ToBytes(parsed.index);
@@ -732,7 +768,7 @@ export function render({ navigate }) {
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
if (!ticketInfo) {
result.innerHTML = `
-
Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.
+
Билет ${formatSupportTicketLabel(parsed.queueId, parsed.index)} пока не создан.
`;
return;
}
@@ -741,18 +777,28 @@ export function render({ navigate }) {
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
- result.innerHTML = `
-
Билет: ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}
-
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
-
Получатель: ${ticket.recipientWallet}
-
До него в очереди: ${ticketsBefore.toString()} билетов
-
Из них уже выплачено: ${paidBefore.toString()} билетов
-
Ещё осталось до него: ${remainingBefore.toString()} билетов
-
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
-
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
-
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
- `;
- setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`);
+ if (ticket.queueId === 2 || ticket.queueId === 3) {
+ result.innerHTML = `
+
${formatQueueTitle(ticket.queueId)}
+
Номер билета: ${ticket.index.toString()}
+
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
+
Получатель: ${ticket.recipientWallet}
+
Сумма: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+ `;
+ } else {
+ result.innerHTML = `
+
Билет: ${formatSupportTicketLabel(parsed.queueId, ticket.index)}
+
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
+
Получатель: ${ticket.recipientWallet}
+
До него в очереди: ${ticketsBefore.toString()} билетов
+
Из них уже выплачено: ${paidBefore.toString()} билетов
+
Ещё осталось до него: ${remainingBefore.toString()} билетов
+
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
+
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
+ `;
+ }
+ setStatus(`Билет ${formatSupportTicketLabel(ticket.queueId, ticket.index)} найден. Источник Solana: ${endpoint}`);
} catch (error) {
result.innerHTML = `
${String(error?.message || 'Не удалось загрузить билет')}
`;
setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`);
@@ -767,9 +813,14 @@ export function render({ navigate }) {
result.innerHTML = `
Введите номер билета и нажмите кнопку.
`;
setStatus('Поле билета очищено.');
});
+ mainnetCheckbox?.addEventListener('change', () => {
+ lastCoreState = null;
+ lastEndpoint = '';
+ setStatus(`Источник Solana: ${resolveSupportQueueEndpoint(Boolean(testDeploy && mainnetCheckbox.checked))}`);
+ });
- content.append(backBtn, card, inputLabel, queryInput, actions, result);
- setStatus('Просмотр билета готов. Введите номер в нужном формате.');
+ content.append(backBtn, card, mainnetRow, inputLabel, queryInput, actions, result);
+ setStatus(`Просмотр билета готов. Источник Solana: ${resolveSupportQueueEndpoint(Boolean(testDeploy && mainnetCheckbox?.checked))}`);
}
async function renderSupportBuyIntro() {
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 637dc031..fa65c242 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -1,6 +1,53 @@
import { parseShineRootSegment } from './services/shine-routes.js';
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
+const PRETTY_PATHS = new Map([
+ ['start-view', 'start'],
+ ['entry-settings-view', 'entry-settings'],
+ ['register-view', 'register'],
+ ['registration-faq-view', 'register/faq'],
+ ['registration-payment-view', 'register/payment'],
+ ['registration-draft-keys-view', 'register/draft-keys'],
+ ['registration-keys-view', 'register/keys'],
+ ['topup-view', 'topup'],
+ ['devnet-topup-view', 'devnet-topup'],
+ ['login-view', 'login'],
+ ['login-camera-view', 'login/camera'],
+ ['login-other-device-view', 'login/device'],
+ ['login-password-view', 'login/password'],
+ ['key-storage-view', 'key-storage'],
+ ['profile-view', 'profile'],
+ ['profile-edit-view', 'profile/edit'],
+ ['messages-list', 'messages'],
+ ['contact-search-view', 'contacts'],
+ ['chat-view', 'chat'],
+ ['channels-list', 'channels'],
+ ['add-channel-view', 'channels/new'],
+ ['add-personal-public-chat-view', 'channels/new-public-chat'],
+ ['channel-view', 'channel'],
+ ['channel-thread-view', 'thread'],
+ ['network-view', 'network'],
+ ['notifications-view', 'notifications'],
+ ['settings-view', 'settings'],
+ ['server-settings-view', 'settings/servers'],
+ ['developer-settings-view', 'settings/developer'],
+ ['trusted-device-login-settings-view', 'settings/device-login'],
+ ['language-view', 'settings/language'],
+ ['app-log-view', 'settings/app-log'],
+ ['pwa-diagnostics-view', 'settings/pwa-diagnostics'],
+ ['solana-users-init-view', 'settings/solana-users-init'],
+ ['solana-rpc-check-view', 'settings/solana-rpc-check'],
+ ['wallet-view', 'wallet'],
+ ['queue', 'quene'],
+ ['device-view', 'devices'],
+ ['device-session-view', 'devices'],
+ ['connect-device-view', 'devices/connect'],
+ ['device-pairing-view', 'devices/pairing'],
+ ['device-qr-view', 'devices/qr'],
+ ['device-camera-view', 'devices/camera'],
+ ['show-keys-view', 'devices/keys'],
+ ['remote-addblock-session-view', 'remote-addblock-session'],
+]);
export const PRE_AUTH_PAGES = [
'start-view',
@@ -39,6 +86,7 @@ export function getRoute() {
return value || '';
}
};
+ const isLikelyBlockNumber = (value) => /^[0-9]+$/.test(String(value || '').trim());
const shineLogin = parseShineRootSegment(pageId);
if (shineLogin) {
@@ -68,12 +116,12 @@ export function getRoute() {
params: { mode: 'feed', login: shineLogin, scope: 'all' },
};
}
- if (section === 'msg') {
+ if (segments.length === 2 && isLikelyBlockNumber(segments[1])) {
return {
pageId: 'channel-thread-view',
params: {
- messageBlockchainName: decodePart(segments[2]),
- messageBlockNumber: segments[3] || '',
+ messageBlockchainName: shineLogin,
+ messageBlockNumber: segments[1] || '',
messageBlockHash: '',
channelOwnerBlockchainName: '',
channelRootBlockNumber: '',
@@ -81,10 +129,10 @@ export function getRoute() {
},
};
}
- if (section === 'channel') {
- const ownerBlockchainName = decodePart(segments[2] || '');
- const channelName = decodePart(segments[3] || '');
- const messageBlockNumber = segments[4] || '';
+ if (segments.length >= 2) {
+ const ownerBlockchainName = shineLogin;
+ const channelName = decodePart(segments[1] || '');
+ const messageBlockNumber = segments[2] || '';
if (ownerBlockchainName && channelName && messageBlockNumber) {
return {
pageId: 'channel-thread-view',
@@ -100,15 +148,150 @@ export function getRoute() {
},
};
}
- return {
- pageId: 'channel-view',
- params: { ownerBlockchainName, channelName, channelId: '' },
- };
+ if (ownerBlockchainName && channelName) {
+ return {
+ pageId: 'channel-view',
+ params: { ownerBlockchainName, channelName, channelId: '' },
+ };
+ }
}
}
- if (pageId === 'chat-view') {
- return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
+ if (pageId === 'chat' || pageId === 'chat-view') {
+ return { pageId: 'chat-view', params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
+ }
+
+ if (pageId === 'start') {
+ return { pageId: 'start-view', params: {} };
+ }
+
+ if (pageId === 'entry-settings') {
+ return { pageId: 'entry-settings-view', params: {} };
+ }
+
+ if (pageId === 'register') {
+ const sub = decodePart(segments[1] || '').toLowerCase();
+ if (sub === 'faq') return { pageId: 'registration-faq-view', params: {} };
+ if (sub === 'payment') return { pageId: 'registration-payment-view', params: {} };
+ if (sub === 'draft-keys') return { pageId: 'registration-draft-keys-view', params: {} };
+ if (sub === 'keys') return { pageId: 'registration-keys-view', params: {} };
+ return { pageId: 'register-view', params: {} };
+ }
+
+ if (pageId === 'topup') {
+ return { pageId: 'topup-view', params: {} };
+ }
+
+ if (pageId === 'devnet-topup') {
+ return { pageId: 'devnet-topup-view', params: {} };
+ }
+
+ if (pageId === 'login') {
+ const sub = decodePart(segments[1] || '').toLowerCase();
+ if (sub === 'camera') return { pageId: 'login-camera-view', params: {} };
+ if (sub === 'device') return { pageId: 'login-other-device-view', params: {} };
+ if (sub === 'password') return { pageId: 'login-password-view', params: {} };
+ return { pageId: 'login-view', params: {} };
+ }
+
+ if (pageId === 'key-storage') {
+ return { pageId: 'key-storage-view', params: {} };
+ }
+
+ if (pageId === 'profile') {
+ const sub = decodePart(segments[1] || '').toLowerCase();
+ if (sub === 'edit') return { pageId: 'profile-edit-view', params: {} };
+ return { pageId: 'profile-view', params: {} };
+ }
+
+ if (pageId === 'messages') {
+ return { pageId: 'messages-list', params: {} };
+ }
+
+ if (pageId === 'contacts') {
+ return { pageId: 'contact-search-view', params: {} };
+ }
+
+ if (pageId === 'channels') {
+ const sub = decodePart(segments[1] || '').toLowerCase();
+ if (sub === 'new') return { pageId: 'add-channel-view', params: {} };
+ if (sub === 'new-public-chat') return { pageId: 'add-personal-public-chat-view', params: {} };
+ return { pageId: 'channels-list', params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
+ }
+
+ if (pageId === 'channel') {
+ if (segments.length >= 4) {
+ return {
+ pageId: 'channel-view',
+ params: {
+ ownerBlockchainName: decodePart(segments[1]),
+ channelRootBlockNumber: segments[2] || '',
+ channelRootBlockHash: segments[3] || '',
+ channelId: '',
+ },
+ };
+ }
+ return { pageId: 'channel-view', params: { channelId: dynamicId || '' } };
+ }
+
+ if (pageId === 'thread') {
+ return {
+ pageId: 'channel-thread-view',
+ params: {
+ messageBlockchainName: decodePart(segments[1]),
+ messageBlockNumber: segments[2] || '',
+ messageBlockHash: '',
+ channelOwnerBlockchainName: decodePart(segments[3]),
+ channelRootBlockNumber: segments[4] || '',
+ channelRootBlockHash: segments[5] || '',
+ },
+ };
+ }
+
+ if (pageId === 'network') {
+ return { pageId: 'network-view', params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
+ }
+
+ if (pageId === 'notifications') {
+ return { pageId: 'notifications-view', params: {} };
+ }
+
+ if (pageId === 'settings') {
+ const sub = decodePart(segments[1] || '').toLowerCase();
+ if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
+ if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
+ if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
+ if (sub === 'language') return { pageId: 'language-view', params: {} };
+ if (sub === 'app-log') return { pageId: 'app-log-view', params: {} };
+ if (sub === 'pwa-diagnostics') return { pageId: 'pwa-diagnostics-view', params: {} };
+ if (sub === 'solana-users-init') return { pageId: 'solana-users-init-view', params: {} };
+ if (sub === 'solana-rpc-check') return { pageId: 'solana-rpc-check-view', params: {} };
+ return { pageId: 'settings-view', params: {} };
+ }
+
+ if (pageId === 'wallet') {
+ return { pageId: 'wallet-view', params: {} };
+ }
+
+ if (pageId === 'quene' || pageId === 'queue') {
+ return { pageId: 'queue', params: {} };
+ }
+
+ if (pageId === 'devices') {
+ const sub = decodePart(segments[1] || '').toLowerCase();
+ if (sub === 'connect') return { pageId: 'connect-device-view', params: {} };
+ if (sub === 'pairing') return { pageId: 'device-pairing-view', params: {} };
+ if (sub === 'qr') return { pageId: 'device-qr-view', params: {} };
+ if (sub === 'camera') return { pageId: 'device-camera-view', params: {} };
+ if (sub === 'keys') return { pageId: 'show-keys-view', params: {} };
+ if (dynamicId) {
+ return { pageId: 'device-session-view', params: { sessionId: decodeURIComponent(dynamicId) } };
+ }
+ return { pageId: 'device-view', params: {} };
+ }
+
+ if (pageId === 'remote-addblock-session') {
+ return { pageId: 'remote-addblock-session-view', params: {} };
}
if (pageId === 'channel-view') {
@@ -152,15 +335,11 @@ export function getRoute() {
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
}
- if (pageId === 'queue') {
- return { pageId, params: {} };
- }
-
return { pageId, params: {} };
}
export function navigate(path) {
- const cleanPath = String(path || '').replace(/^\/+/, '');
+ const cleanPath = toPrettyPath(path);
const nextPath = cleanPath ? `/${cleanPath}` : '/';
if (window.location.pathname !== nextPath) {
window.history.pushState({}, '', nextPath);
@@ -168,6 +347,16 @@ export function navigate(path) {
window.dispatchEvent(new PopStateEvent('popstate'));
}
+export function toPrettyPath(path) {
+ const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
+ if (!raw) return '';
+ const segments = raw.split('/').filter(Boolean);
+ if (!segments.length) return '';
+ const [head, ...tail] = segments;
+ const prettyHead = PRETTY_PATHS.get(head) || head;
+ return [prettyHead, ...tail].join('/');
+}
+
export function navigateBack() {
if (window.history.length <= 1) return;
window.history.back();
@@ -181,7 +370,6 @@ export function resolveToolbarActive(pageId) {
pageId === 'settings-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
- pageId === 'tools-settings-view' ||
pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index 029201ce..6bc03eeb 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -2579,6 +2579,23 @@ export class AuthService {
};
}
+ async getDirectMessages({ peerLogin, limit = 50, beforeTimeMs = 0, beforeMessageKey = '' } = {}) {
+ const payload = {
+ peerLogin: String(peerLogin || '').trim(),
+ limit: Number(limit || 0),
+ };
+ if (!payload.peerLogin) throw new Error('Не передан peerLogin');
+ if (Number.isFinite(Number(beforeTimeMs)) && Number(beforeTimeMs) > 0) {
+ payload.beforeTimeMs = Math.trunc(Number(beforeTimeMs));
+ }
+ if (String(beforeMessageKey || '').trim()) {
+ payload.beforeMessageKey = String(beforeMessageKey || '').trim();
+ }
+ const response = await this.ws.request('GetDirectMessages', payload);
+ if (response.status !== 200) throw opError('GetDirectMessages', response);
+ return response.payload || {};
+ }
+
async ackSessionDelivery(messageKey) {
const response = await this.ws.request('AckSessionDelivery', { messageKey });
if (response.status !== 200) throw opError('AckSessionDelivery', response);
diff --git a/shine-UI/js/services/shine-routes.js b/shine-UI/js/services/shine-routes.js
index 5b50929f..6026e628 100644
--- a/shine-UI/js/services/shine-routes.js
+++ b/shine-UI/js/services/shine-routes.js
@@ -23,17 +23,17 @@ export function extractLoginFromBlockchainName(value = '') {
export function makeProfileRoute(login = '') {
const clean = normalizeLogin(login);
- return clean ? `shine.${encodeRoutePart(clean)}` : 'profile-view';
+ return clean ? `shine.${encodeRoutePart(clean)}` : 'profile';
}
export function makeProfileLinksRoute(login = '') {
const clean = normalizeLogin(login);
- return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network-view/keep-history';
+ return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network/keep-history';
}
export function makeProfileChannelsRoute(login = '', scope = 'all') {
const clean = normalizeLogin(login);
- if (!clean) return 'channels-list/feed';
+ if (!clean) return 'channels/feed';
const normalizedScope = String(scope || '').trim().toLowerCase();
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
@@ -41,21 +41,19 @@ export function makeProfileChannelsRoute(login = '', scope = 'all') {
}
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
- const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName);
const ownerBch = String(ownerBlockchainName || '').trim();
const chName = String(channelName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
- if (!cleanOwnerLogin || !ownerBch || !chName) return '';
- if (msgNo) return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
- return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
+ if (!ownerBch || !chName) return '';
+ if (msgNo) return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
+ return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
- const cleanOwnerLogin = normalizeLogin(ownerLogin);
const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
- if (!cleanOwnerLogin || !msgBch || !msgNo) return '';
- return `shine.${encodeRoutePart(cleanOwnerLogin)}/msg/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
+ if (!msgBch || !msgNo) return '';
+ return `shine.${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
}
export function parseShineRootSegment(segment = '') {
diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js
index defdc926..444ff2cf 100644
--- a/shine-UI/js/services/solana-wallet-service.js
+++ b/shine-UI/js/services/solana-wallet-service.js
@@ -4,7 +4,7 @@ import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
-const TOPUP_SITE_URL = '/devnet-topup-view';
+const TOPUP_SITE_URL = '/devnet-topup';
let solanaLibPromise = null;
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
diff --git a/shine-UI/js/services/speech-tools-service.js b/shine-UI/js/services/speech-tools-service.js
index e4580616..bde1ef62 100644
--- a/shine-UI/js/services/speech-tools-service.js
+++ b/shine-UI/js/services/speech-tools-service.js
@@ -1,9 +1,3 @@
-const OPENAI_MODELS_BY_QUALITY = {
- easy: 'whisper-1',
- medium: 'gpt-4o-mini-transcribe',
- hard: 'gpt-4o-transcribe',
-};
-
const PIPER_LENGTH_SCALE_BY_QUALITY = {
easy: '1.15',
medium: '1.0',
@@ -16,18 +10,6 @@ function normalizeOpenAiBaseUrl(url) {
return value.replace(/\/+$/, '');
}
-function resolveSttModel(config) {
- const quality = String(config?.quality || 'medium').toLowerCase();
- const customModel = String(config?.model || '').trim();
- return customModel || OPENAI_MODELS_BY_QUALITY[quality] || OPENAI_MODELS_BY_QUALITY.medium;
-}
-
-export function isSpeechToTextConfigured(entrySettings) {
- const cfg = entrySettings?.tools?.speechToText || {};
- if (String(cfg.provider || 'openai') !== 'openai') return false;
- return !!String(cfg.apiKey || '').trim();
-}
-
export function isTextToSpeechConfigured(entrySettings) {
const cfg = entrySettings?.tools?.textToSpeech || {};
const provider = String(cfg.provider || 'browser');
@@ -37,135 +19,6 @@ export function isTextToSpeechConfigured(entrySettings) {
return false;
}
-export async function transcribeAudioBySettings(audioBlob, entrySettings) {
- const cfg = entrySettings?.tools?.speechToText || {};
- const provider = String(cfg.provider || 'openai');
- if (provider !== 'openai') {
- throw new Error('Поддерживается только провайдер OpenAI для распознавания.');
- }
-
- const apiKey = String(cfg.apiKey || '').trim();
- if (!apiKey) throw new Error('Не заполнен OpenAI API key.');
-
- const model = resolveSttModel(cfg);
- const baseUrl = normalizeOpenAiBaseUrl(cfg.baseUrl);
-
- const form = new FormData();
- form.append('model', model);
- form.append('language', 'ru');
- form.append('response_format', 'json');
- form.append('file', audioBlob, 'voice-input.webm');
-
- const response = await fetch(`${baseUrl}/audio/transcriptions`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- },
- body: form,
- });
-
- if (!response.ok) {
- const body = await response.text().catch(() => '');
- throw new Error(`Ошибка STT API (${response.status}): ${body || 'unknown error'}`);
- }
-
- const payload = await response.json();
- const text = String(payload?.text || '').trim();
- if (!text) throw new Error('Пустой ответ распознавания.');
- return text;
-}
-
-export function createMicrophoneRecorder() {
- const Ctx = window.AudioContext || window.webkitAudioContext;
- let stream = null;
- let recorder = null;
- let startedAtMs = 0;
- let chunks = [];
- let timerId = 0;
- let level = 0;
- let analyser = null;
- let rafId = 0;
-
- async function start(onTick) {
- stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
- recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
- startedAtMs = Date.now();
- chunks = [];
-
- recorder.ondataavailable = (event) => {
- if (event?.data?.size > 0) chunks.push(event.data);
- };
- recorder.start(250);
-
- timerId = window.setInterval(() => {
- if (typeof onTick === 'function') {
- onTick({
- elapsedMs: Date.now() - startedAtMs,
- level,
- });
- }
- }, 120);
-
- if (Ctx) {
- const audioCtx = new Ctx();
- const source = audioCtx.createMediaStreamSource(stream);
- analyser = audioCtx.createAnalyser();
- analyser.fftSize = 256;
- source.connect(analyser);
- const data = new Uint8Array(analyser.frequencyBinCount);
- const read = () => {
- if (!analyser) return;
- analyser.getByteFrequencyData(data);
- let sum = 0;
- for (let i = 0; i < data.length; i += 1) sum += data[i];
- level = data.length > 0 ? Math.max(0, Math.min(1, (sum / data.length) / 255)) : 0;
- rafId = window.requestAnimationFrame(read);
- };
- read();
- }
- }
-
- async function stop() {
- if (!recorder) return null;
- const blob = await new Promise((resolve) => {
- recorder.onstop = () => resolve(new Blob(chunks, { type: 'audio/webm' }));
- recorder.stop();
- });
- cleanup();
- return blob;
- }
-
- function cancel() {
- try {
- recorder?.stop();
- } catch {
- // ignore
- }
- cleanup();
- }
-
- function cleanup() {
- if (timerId) window.clearInterval(timerId);
- if (rafId) window.cancelAnimationFrame(rafId);
- timerId = 0;
- rafId = 0;
- analyser = null;
- if (stream) {
- stream.getTracks().forEach((track) => {
- try {
- track.stop();
- } catch {
- // ignore
- }
- });
- }
- stream = null;
- recorder = null;
- }
-
- return { start, stop, cancel };
-}
-
export async function speakTextBySettings(text, entrySettings) {
const value = String(text || '').trim();
if (!value) return;
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index 9cc7242f..bcbab5ab 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -95,7 +95,6 @@ const DEFAULT_SHINE_SERVER_LOGIN_VALUE = DEFAULT_SHINE_SERVER_LOGIN;
const DEFAULT_SHINE_SERVER_HTTP_VALUE = DEFAULT_SHINE_SERVER_HTTP;
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
-const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
function normalizeStoredSolanaServer(value) {
const raw = String(value || '').trim();
@@ -109,16 +108,8 @@ export function normalizeDmChatId(value) {
function normalizeToolsSettings(rawTools) {
const source = rawTools && typeof rawTools === 'object' ? rawTools : {};
- const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {};
const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {};
return {
- speechToText: {
- provider: String(stt.provider || 'openai'),
- baseUrl: String(stt.baseUrl || DEFAULT_OPENAI_BASE_URL),
- apiKey: String(stt.apiKey || ''),
- quality: String(stt.quality || 'medium'),
- model: String(stt.model || ''),
- },
textToSpeech: {
provider: String(tts.provider || 'openai'),
quality: String(tts.quality || 'medium'),
@@ -420,6 +411,7 @@ function persistMessageRecord(chatId, row) {
unread: Boolean(row.unread),
firstTick: Boolean(row.firstTick),
secondTick: Boolean(row.secondTick),
+ readAtMs: Number(row.readAtMs || 0),
readReceiptSent: Boolean(row.readReceiptSent),
refBaseKey: String(row.refBaseKey || ''),
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
@@ -455,6 +447,7 @@ export async function hydrateMessagesFromStore() {
unread: Boolean(row.unread),
firstTick: Boolean(row.firstTick),
secondTick: Boolean(row.secondTick),
+ readAtMs: Number(row.readAtMs || 0),
readReceiptSent: Boolean(row.readReceiptSent),
refBaseKey: String(row.refBaseKey || ''),
createdAtMs: Number(row.ts || 0),
@@ -556,8 +549,9 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {})
});
}
-export function markOutgoingReadByBaseKey(baseKey) {
+export function markOutgoingReadByBaseKey(baseKey, readAtMs = 0) {
if (!baseKey) return;
+ const normalizedReadAtMs = Number(readAtMs || 0);
const keys = Object.keys(state.chats || {});
let matched = false;
keys.forEach((chatId) => {
@@ -567,6 +561,9 @@ export function markOutgoingReadByBaseKey(baseKey) {
if (row.baseKey === baseKey) {
matched = true;
row.secondTick = true;
+ if (Number.isFinite(normalizedReadAtMs) && normalizedReadAtMs > 0) {
+ row.readAtMs = normalizedReadAtMs;
+ }
persistMessageRecord(chatId, row);
}
});
@@ -574,7 +571,9 @@ export function markOutgoingReadByBaseKey(baseKey) {
if (matched) {
delete state.pendingOutgoingReadByBaseKey[baseKey];
} else {
- state.pendingOutgoingReadByBaseKey[baseKey] = true;
+ state.pendingOutgoingReadByBaseKey[baseKey] = (Number.isFinite(normalizedReadAtMs) && normalizedReadAtMs > 0)
+ ? normalizedReadAtMs
+ : true;
}
return matched;
}
@@ -626,6 +625,7 @@ export function addSignedMessageToChat({
text = '',
messageType = 1,
unread = false,
+ readAtMs = 0,
rawBlobB64 = '',
refBaseKey = '',
revisionTimeMs = 0,
@@ -667,9 +667,14 @@ export function addSignedMessageToChat({
row.refBaseKey = String(refBaseKey || '');
row.firstTick = row.from === 'out';
row.secondTick = Boolean(existing?.secondTick);
+ row.readAtMs = Number(existing?.readAtMs || 0);
row.readReceiptSent = Boolean(existing?.readReceiptSent);
if (row.baseKey && row.from === 'out' && state.pendingOutgoingReadByBaseKey[row.baseKey]) {
row.secondTick = true;
+ const pendingReadAtMs = Number(state.pendingOutgoingReadByBaseKey[row.baseKey] || 0);
+ if (Number.isFinite(pendingReadAtMs) && pendingReadAtMs > 0) {
+ row.readAtMs = pendingReadAtMs;
+ }
delete state.pendingOutgoingReadByBaseKey[row.baseKey];
}
if (row.baseKey && row.from === 'in' && state.pendingIncomingReadByBaseKey[row.baseKey]) {
@@ -681,6 +686,13 @@ export function addSignedMessageToChat({
if (existingIndex < 0) {
list.push(row);
}
+ const nextReadAtMs = Number(readAtMs || 0);
+ if (Number.isFinite(nextReadAtMs) && nextReadAtMs > 0) {
+ row.readAtMs = nextReadAtMs;
+ if (row.from === 'out') {
+ row.secondTick = true;
+ }
+ }
sortChatMessagesInPlace(normalizedChatId);
persistMessageRecord(normalizedChatId, row);
return true;
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index c6a4c7e5..33b33a52 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -59,6 +59,17 @@
border-radius: 12px;
}
+.header-icon-svg {
+ display: block;
+ width: 1em;
+ height: 1em;
+}
+
+.header-icon-svg--phone {
+ width: 22px;
+ height: 22px;
+}
+
.chat-header-menu-btn {
border-radius: 12px;
font-size: 24px;
@@ -4628,10 +4639,98 @@ html, body { overflow-x: hidden; }
display: none;
}
+.screen-content:has(> .dm-chat-screen) {
+ padding-top: 0;
+ padding-bottom: 0;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
+}
+
+.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+ display: block;
+}
+
+.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb {
+ background: rgba(212, 175, 55, 0.7);
+ border-radius: 999px;
+}
+
+.screen-content:has(> .dm-chat-screen)::-webkit-scrollbar-thumb:hover {
+ background: rgba(240, 198, 76, 0.9);
+}
+
.dm-messages-log {
gap: 10px;
}
+.dm-history-loader {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ display: flex;
+ justify-content: center;
+ pointer-events: none;
+ margin-bottom: -4px;
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.dm-history-loader[hidden] {
+ display: none;
+}
+
+.dm-history-loader.is-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.dm-history-loader__pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 34px;
+ padding: 7px 14px;
+ border-radius: 999px;
+ border: 1px solid rgba(212, 175, 55, 0.24);
+ background: rgba(9, 15, 26, 0.92);
+ color: rgba(245, 226, 179, 0.96);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+}
+
+.dm-history-loader__label {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.dm-history-loader__spinner {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ border: 2px solid rgba(255, 234, 186, 0.22);
+ border-top-color: rgba(242, 201, 92, 0.96);
+ box-shadow: 0 0 10px rgba(212, 175, 55, 0.18);
+ animation: dm-history-loader-spin 0.85s linear infinite;
+}
+
+@keyframes dm-history-loader-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
.dm-screen .bubble {
max-width: 78%;
padding: 11px 14px;
@@ -4660,12 +4759,13 @@ html, body { overflow-x: hidden; }
.dm-chat-input {
gap: 10px;
- grid-template-columns: 1fr auto;
+ grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
position: sticky;
bottom: 0;
z-index: 10;
- padding: 10px;
+ margin-inline: -14px;
+ padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(8, 12, 20, 0.9);
backdrop-filter: blur(12px);
@@ -4708,18 +4808,35 @@ html, body { overflow-x: hidden; }
.dm-actions-col {
display: grid;
- grid-template-columns: repeat(2, 42px);
- grid-template-rows: 42px;
+ grid-template-columns: repeat(2, 46px);
+ grid-template-rows: 46px;
grid-template-areas: "emoji send";
gap: 6px;
+ align-self: end;
+}
+
+.dm-chat-input--multiline .dm-actions-col {
+ grid-template-columns: 46px;
+ grid-template-rows: repeat(2, 46px);
+ grid-template-areas:
+ "emoji"
+ "send";
}
.dm-emoji-btn {
grid-area: emoji;
- min-width: 42px;
- width: 42px;
+ min-width: 46px;
+ width: 46px;
padding: 0;
- font-size: 20px;
+ line-height: 1;
+}
+
+.dm-emoji-btn__glyph {
+ display: inline-grid;
+ place-items: center;
+ width: 100%;
+ height: 100%;
+ font-size: 27px;
line-height: 1;
}
@@ -4832,7 +4949,7 @@ html, body { overflow-x: hidden; }
box-shadow: none;
}
-.telegram-emoji,
+.twemoji,
.emoji-picker-preview {
display: inline-grid;
place-items: center;
@@ -4841,7 +4958,7 @@ html, body { overflow-x: hidden; }
vertical-align: -0.12em;
}
-.telegram-emoji-image {
+.twemoji-image {
display: block;
width: 100%;
height: 100%;
@@ -4849,17 +4966,43 @@ html, body { overflow-x: hidden; }
pointer-events: none;
}
-.telegram-emoji.is-error .telegram-emoji-image,
-.emoji-picker-preview.is-error .telegram-emoji-image {
+.twemoji.is-error .twemoji-image,
+.emoji-picker-preview.is-error .twemoji-image {
visibility: hidden;
}
-.bubble-text--animated-emoji {
+.bubble.bubble--emoji-only,
+.dm-screen .bubble.bubble--emoji-only {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
+ color: inherit;
+}
+
+.bubble.bubble--emoji-only.in,
+.dm-screen .bubble.bubble--emoji-only.in,
+.bubble.bubble--emoji-only.out,
+.dm-screen .bubble.bubble--emoji-only.out {
+ border: 0;
+ background: transparent;
+}
+
+.bubble.bubble--emoji-only .bubble-meta {
+ margin-top: 2px;
+ padding: 0 4px;
+ color: rgba(198, 214, 247, 0.72);
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
+}
+
+.bubble-text--twemoji-only {
font-size: 56px;
line-height: 1;
}
-.bubble-text--animated-emoji .chat-telegram-emoji {
+.bubble-text--twemoji-only .chat-twemoji {
width: 1em;
height: 1em;
}
@@ -4867,6 +5010,8 @@ html, body { overflow-x: hidden; }
.dm-chat-input .dm-input {
min-height: 42px;
max-height: 180px;
+ width: 100%;
+ min-width: 0;
resize: none;
overflow-y: auto;
scrollbar-width: none;
@@ -4875,27 +5020,15 @@ html, body { overflow-x: hidden; }
padding: 10px 12px;
}
+.dm-chat-input--multiline .dm-input {
+ padding-right: 14px;
+}
+
.dm-chat-input .dm-input::-webkit-scrollbar {
width: 0;
height: 0;
}
-.voice-level-wrap {
- width: 100%;
- height: 8px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.15);
- overflow: hidden;
-}
-
-.voice-level-fill {
- width: 2%;
- height: 100%;
- border-radius: 999px;
- background: rgba(212, 175, 55, 0.95);
- transition: width 80ms linear;
-}
-
.dm-screen .input,
.dm-input {
background: rgba(255, 255, 255, 0.04);
@@ -4919,8 +5052,8 @@ html, body { overflow-x: hidden; }
.dm-send-icon-btn {
grid-area: send;
- min-width: 42px;
- width: 42px;
+ min-width: 46px;
+ width: 46px;
padding: 0;
font-size: 15px;
line-height: 1;
@@ -4991,16 +5124,6 @@ html, body { overflow-x: hidden; }
margin-top: 2px;
}
-.speech-actions-top {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
-}
-
-.speech-send-now-btn {
- width: 100%;
-}
-
.dm-screen .dm-list > .card.meta-muted {
width: calc(100% - 40px);
margin: 0 20px;
@@ -5549,27 +5672,38 @@ html, body { overflow-x: hidden; }
.profile-top-actions {
display: flex;
justify-content: flex-end;
+ gap: 8px;
}
.profile-top-action-btn {
- height: 32px;
- min-height: 32px;
- padding: 0 10px;
- font-size: 12px;
- line-height: 1.15;
- text-align: center;
- white-space: pre-line;
- overflow: hidden;
- text-overflow: ellipsis;
+ position: relative;
+ width: 48px;
+ height: 48px;
+ min-width: 48px;
+ min-height: 48px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 15px;
}
-.profile-top-menu-btn {
- width: 32px;
- min-width: 32px;
- padding: 0;
- font-size: 22px;
- line-height: 1;
- letter-spacing: 0;
+.profile-top-icon-btn img {
+ width: 48px;
+ height: 48px;
+ display: block;
+ opacity: 0.94;
+ filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.34));
+}
+
+.profile-top-icon-btn.is-active {
+ border-color: rgba(212, 175, 55, 0.46);
+ background: rgba(212, 175, 55, 0.12);
+ box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.08) inset, 0 10px 22px rgba(0, 0, 0, 0.22);
+}
+
+.profile-top-icon-btn:hover {
+ border-color: rgba(230, 236, 245, 0.28);
}
.profile-info-modal {
diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md
index ea1dc0a2..32440859 100644
--- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md
+++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md
@@ -2,7 +2,7 @@
Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`.
-Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `Dev_Docs/Blockchain/`.
+Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `docs/Blockchain/`.
Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля.
diff --git a/shine-solana/shine/doc/shine_payments_e2e_test_plan.md b/shine-solana/shine/doc/shine_payments_e2e_test_plan.md
index 36d93820..0ddc72e2 100644
--- a/shine-solana/shine/doc/shine_payments_e2e_test_plan.md
+++ b/shine-solana/shine/doc/shine_payments_e2e_test_plan.md
@@ -32,10 +32,6 @@
9. Финальное погашение всех очередей.
10. Возврат максимально возможных остатков лампортов на базовый кошелёк.
-## Где смотреть подробный сценарий
+## Статус документа
-Актуальный подробный сценарий тестирования и его статус ведётся в:
-
-- `Dev_Docs/Pending_Features/2026-06-06_1659_shine_payments_e2e_перепись_и_q3.md`
-
-Этот файл в `shine-solana/shine/doc/` оставлен как постоянная заметка, что у программы есть отдельный e2e-план проверки.
+Этот файл в `shine-solana/shine/doc/` является постоянной заметкой, что у программы есть отдельный e2e-план проверки.
diff --git a/tools/understand-anything-lab/.gitignore b/tools/understand-anything-lab/.gitignore
deleted file mode 100644
index 97f65d06..00000000
--- a/tools/understand-anything-lab/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-upstream/
-work/
-.understand-anything/
-*.log
-
diff --git a/tools/understand-anything-lab/README.md b/tools/understand-anything-lab/README.md
deleted file mode 100644
index 19078330..00000000
--- a/tools/understand-anything-lab/README.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Эксперимент Understand Anything
-
-Эта папка нужна, чтобы безопасно проверить `Lum1104/Understand-Anything` в SHiNE без подключения к сборке, деплою и рабочему серверному коду.
-
-## Что здесь лежит
-
-- `upstream/` - локальный клон `https://github.com/Lum1104/Understand-Anything.git`; он игнорируется git, чтобы не тащить внешний репозиторий внутрь SHiNE.
-- `install_codex_skills.sh` - локальная установка skills для Codex из уже скачанного `upstream/`, без `curl | bash`.
-
-Проверенный upstream:
-
-- commit: `26edf61856fa476e466bda1814819a266a293c47`
-- дата commit: `2026-05-26T13:09:57+08:00`
-- сообщение: `Merge pull request #235 from ZebangCheng/feat/add-trae-platform`
-
-## Безопасный сценарий проверки
-
-1. Подготовить окружение:
-
- ```bash
- node --version
- pnpm --version
- ```
-
- Для полноценного запуска нужен Node.js 22 или новее и pnpm 10 или новее. На текущей машине при первичной проверке были обнаружены Node.js `v18.19.1` и отсутствие `pnpm`, поэтому полноценный `/understand` пока не запускался.
-
-2. Установить skills для Codex из локального клона:
-
- ```bash
- ./tools/understand-anything-lab/install_codex_skills.sh
- ```
-
- Скрипт использует локальный `tools/understand-anything-lab/upstream`, а не скачивает установщик из интернета.
-
-3. Перезапустить Codex-сессию, чтобы появились команды/skills:
-
- ```text
- /understand --language ru
- /understand-dashboard
- ```
-
-4. После первого запуска проверить созданный файл:
-
- ```bash
- ls -la .understand-anything/
- ```
-
-## Что не делать без отдельного решения
-
-- Не включать `Understand Anything` в Gradle-сборку.
-- Не добавлять его в `deployServer` или `deployUI`.
-- Не переносить серверные модули в новую папку одновременно с этим экспериментом.
-- Не включать auto-update hook через `/understand --auto-update`, пока не понятно, нужен ли граф в каждом commit.
-
-## Что можно коммитить позже
-
-Если эксперимент подтвердится, можно будет отдельно решить, нужно ли хранить `.understand-anything/knowledge-graph.json` в репозитории. По рекомендации upstream обычно коммитят содержимое `.understand-anything/`, кроме:
-
-```gitignore
-.understand-anything/intermediate/
-.understand-anything/diff-overlay.json
-```
-
-До такого решения артефакты графа лучше считать локальными.
-
diff --git a/tools/understand-anything-lab/install_codex_skills.sh b/tools/understand-anything-lab/install_codex_skills.sh
deleted file mode 100755
index 11438cfe..00000000
--- a/tools/understand-anything-lab/install_codex_skills.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
-UPSTREAM_DIR="$SCRIPT_DIR/upstream"
-
-if [[ ! -d "$UPSTREAM_DIR/.git" ]]; then
- echo "Не найден локальный upstream-клон: $UPSTREAM_DIR" >&2
- echo "Сначала выполните:" >&2
- echo " git clone --depth 1 https://github.com/Lum1104/Understand-Anything.git \"$UPSTREAM_DIR\"" >&2
- exit 1
-fi
-
-if ! command -v node >/dev/null 2>&1; then
- echo "Не найден Node.js. Для полноценного запуска Understand Anything нужен Node.js 22+." >&2
- exit 1
-fi
-
-if ! command -v pnpm >/dev/null 2>&1; then
- echo "Не найден pnpm. Для полноценного запуска Understand Anything нужен pnpm 10+." >&2
- echo "Skills можно установить, но команда /understand не соберёт core без pnpm." >&2
-fi
-
-UA_DIR="$UPSTREAM_DIR" "$UPSTREAM_DIR/install.sh" codex
-
-echo
-echo "Skills Understand Anything установлены для Codex из локального клона."
-echo "Перезапустите Codex-сессию перед использованием /understand."
-
diff --git a/Как_устроены_каналы_в_блокчейне_SHiNE.md b/Как_устроены_каналы_в_блокчейне_SHiNE.md
deleted file mode 100644
index 886cb99b..00000000
--- a/Как_устроены_каналы_в_блокчейне_SHiNE.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# Как устроены каналы в блокчейне SHiNE
-
-## 1) Коротко: что такое “канал” в текущей реализации
-
-В SHiNE канал — это не отдельная таблица сообщений, а **линия блоков** внутри блокчейна пользователя:
-
-- канал создается TECH-блоком `CreateChannelBody` (`msg_type=0`, `msg_sub_type=1`);
-- сообщения канала — это `TEXT_POST` (`msg_type=1`, `msg_sub_type=10`) с `line_code = rootBlockNumber канала`;
-- ответы (треды) — это `TEXT_REPLY` (`msg_sub_type=20`) с target-ссылкой на конкретный блок-сообщение.
-
-То есть канал = “корневой блок канала” + все посты в его линии + связанные ответы.
-
----
-
-## 2) Как канал появляется
-
-Создание канала идет через `AddBlock`:
-
-1. UI собирает `CreateChannelBody v2`.
-2. UI подписывает блок приватным blockchain-ключом на устройстве.
-3. UI отправляет на сервер `AddBlock` с `blockBytesB64` (полный бинарный блок: preimage + sigMarker + signature).
-4. Сервер:
- - проверяет цепочку (`prevHash`, `blockNumber=last+1`);
- - парсит body;
- - валидирует подпись;
- - валидирует имя канала;
- - сохраняет блок и обновляет state.
-
-Дополнительно сервер поддерживает `channel_names_state` как нормализованное состояние названий каналов.
-
----
-
-## 3) Правила имени и описания канала
-
-### Имя канала (`ChannelNameRules`)
-
-- длина: `3..32` символов (code points);
-- допустимые символы: только латиница `A-Z a-z`, цифры `0-9`, `_`, `-`;
-- имя нормализуется как `trim`;
-- канонический slug: lower-case того же имени (без дополнительных преобразований).
-
-### Уникальность
-
-Проверяется по slug. При конфликте сервер возвращает `channel_name_already_exists`.
-
-### Описание канала
-
-В `CreateChannelBody v2` описание хранится прямо в блоке (до 200 байт UTF-8).
-После создания описание в текущей реализации не редактируется (отдельного механизма обновления пока нет).
-
----
-
-## 4) Канал “news” (root 0)
-
-`rootBlockNumber=0` — системный канал `news` (новостной канал по умолчанию).
-Публикации `TEXT_POST` в `news` сейчас отключены (на сервере есть явный запрет записи в root 0).
-
----
-
-## 5) Как идут сообщения в канале
-
-### Публикация поста
-
-UI вызывает `addBlockTextPost` -> `AddBlock` с `TEXT_POST`.
-
-Ограничения:
-
-- писать можно только в **свой** блокчейн и свои каналы;
-- для поста задается `line_code` канала;
-- пост в канале — это новый неизменяемый блок.
-
-### Ответы
-
-Ответы (`TEXT_REPLY`) не обязаны лежать в той же линии.
-Они ссылаются на целевой блок через target (`to_bch_name`, `to_block_number`, `to_block_hash`).
-
-Это позволяет отвечать и из других блокчейнов (межпользовательский тред).
-
----
-
-## 6) Редактирование и удаление сообщений
-
-### Редактирование
-
-Поддержано на уровне протокола:
-
-- `TEXT_EDIT_POST` (11) — правка поста;
-- `TEXT_EDIT_REPLY` (21) — правка ответа.
-
-Правка — это **новый блок**, ссылающийся на оригинал.
-Оригинальный блок не меняется.
-
-Серверные read-API уже собирают `versions[]` и `versionsTotal`.
-
-### Удаление
-
-Сейчас отдельного subtype “delete post/reply” нет.
-Физического удаления блоков из цепочки нет (блоки иммутабельны).
-
-Итог:
-
-- изменить можно через edit-блок;
-- удалить “как в чате” сейчас нельзя.
-
----
-
-## 7) Как UI получает канал
-
-Основные read-API:
-
-- `ListSubscriptionsFeed` — список каналов/подписок;
-- `GetChannelMessages` — посты канала;
-- `GetMessageThread` — тред вокруг выбранного сообщения.
-
-Важно: UI получает **JSON-представление**, собранное сервером из блоков БД, а не сырые блоки по умолчанию.
-
-В JSON возвращаются:
-
-- `messageRef` (номер и hash блока),
-- автор,
-- текущий текст,
-- `versions[]` (оригинал + правки),
-- counters (`likesCount`, `repliesCount`).
-
----
-
-## 8) Как строится тред
-
-`GetMessageThread`:
-
-1. Находит focus-сообщение по `(blockchainName, blockNumber)`.
-2. Строит `ancestors` вверх по target-ссылкам.
-3. Строит `descendants` вниз: replies, где target = focus.
-
-Запросы в БД идут по `to_bch_name + to_block_number + to_block_hash`, поэтому ответы из других блокчейнов тоже связываются.
-
----
-
-## 9) Что проверяется криптографически
-
-При записи (`AddBlock`) сервер проверяет:
-
-- корректность формата блока;
-- непрерывность цепочки;
-- `SHA-256(preimage)` и Ed25519-подпись;
-- соответствие публичного blockchain-ключа пользователя.
-
-На чтении (`GetChannelMessages`, `GetMessageThread`) сервер отдает уже сохраненные данные (JSON из БД).
-Повторная верификация каждой записи при каждом чтении не делается.
-
----
-
-## 10) UI-статус на сегодня (важно)
-
-На момент этого документа:
-
-- в UI нет полноценного экрана истории правок сообщения (хотя `versions` уже приходят);
-- нет операции удаления сообщений (и на протоколе нет delete subtype);
-- канал читается как JSON-слой поверх блоков, а не как “сырой бинарный блок-объект”.
-
----
-
-## 11) Практический вывод по модели данных
-
-Каналы в SHiNE — это append-only модель:
-
-- каждое действие = новый подписанный блок;
-- “изменение” = добавление новой версии, не перезапись старой;
-- целостность и авторство обеспечиваются подписью и связностью цепочки;
-- UI может показывать удобный “чатовый” вид, но источник истины — блоки.
diff --git a/Логика_доставки_почты.md b/Логика_доставки_почты.md
deleted file mode 100644
index 2573680f..00000000
--- a/Логика_доставки_почты.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Логика доставки почты (Signed Messages v2)
-
-## Что отправляет клиент
-- Клиент формирует **пару подписанных блоков** с одинаковой базой (`baseKey`):
-- `incoming` — сообщение для получателя (`targetLogin = toLogin`).
-- `outgoing` — копия для отправителя (`targetLogin = fromLogin`).
-- Пара отправляется методом `ReceiveOutcomingMessage` (старое имя `SendMessagePair` оставлено для совместимости).
-
-## Что делает сервер при `ReceiveOutcomingMessage`
-1. Валидирует поля запроса (`incomingBlobB64`, `outgoingBlobB64`).
-2. Разбирает оба блока и проверяет, что это корректная пара.
-3. Проверяет пользователей и криптоподписи по каждому блоку.
-4. Пытается сохранить обе записи **одной транзакцией**:
-- либо добавляются **обе** записи,
-- либо при дубле/конфликте не добавляется **ни одна**.
-5. Если пара реально добавилась в БД, сервер запускает realtime-доставку в активные сессии целевых пользователей.
-6. Если это дубль, дальнейшая доставка не выполняется (повтор не разгоняется).
-
-## Как сообщение доходит до клиента (WS + WebPush)
-1. После успешной записи сервер пытается доставить сообщение во все активные сессии `targetLogin`.
-2. Для каждой сессии сервер сначала создаёт/проверяет запись доставки в `signed_message_session_delivery` (pending).
-3. Если сессия онлайн, сервер шлёт `SignedMessageArrived` по WebSocket.
-4. Если сессия офлайн и тип сообщения входящий текст (`TYPE_INCOMING_TEXT`), сервер пробует WebPush (если у сессии сохранены `endpoint/p256dh/auth`).
-5. При следующем логине/переподключении сервер дочитывает pending-сообщения и повторно отправляет их в эту сессию как backlog.
-
-## Подтверждение доставки (ACK)
-- Используется метод `AckSessionDelivery`.
-- Клиент отправляет ACK после обработки `SignedMessageArrived` с `messageKey`.
-- Сервер помечает `(messageKey, sessionId)` как `delivered=1`, и это сообщение перестаёт быть pending для этой сессии.
-
-### Важно про безопасность ACK
-- `AckSessionDelivery` требует авторизованную WS-сессию (`ctx.isAuthenticatedUser()`).
-- `sessionId` берётся сервером из текущего `ConnectionContext`, а не из payload запроса.
-- Поэтому подтвердить доставку «просто зная messageKey/sessionId» без авторизованной сессии нельзя.
-
-## Почему допускаются дубли сети
-- В модели с несколькими серверами возможны повторные пересылки одного и того же сообщения.
-- Дедупликация делается на уровне БД по ключам записи.
-- За счёт этого «шторм» затухает: сервер, который уже видел сообщение, больше его не разгоняет.
-
-## Будущая мультисерверная схема (цель)
-- Клиент может отправить `ReceiveOutcomingMessage` на любой из своих серверов.
-- Сервер-источник:
-- сохраняет пару,
-- рассылает исходящее по серверам пользователя A,
-- рассылает входящее по серверам пользователя B.
-- Остальные серверы повторяют тот же принцип, но благодаря дедупликации повторная пересылка быстро прекращается.
-
-## Важные текущие ограничения
-- **A) Реальной мультисерверности пока нет.** Сейчас фактически предполагается один сервер на пользователя.
-- **B) Нет полноценного graceful shutdown для очереди пересылки.** Возможен сценарий: запись уже сохранена в БД, но сервер перезагрузился до пересылки дальше. Это нужно доработать отдельно.
diff --git a/Логика_установки_соединения_через_сервер.md b/Логика_установки_соединения_через_сервер.md
deleted file mode 100644
index f52929db..00000000
--- a/Логика_установки_соединения_через_сервер.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Логика установки звонка через сервер (актуальная)
-
-## 1) Ключевые API
-- `CallInviteBroadcast` — широковещательный старт входящего звонка (`type=100`) по пользователю.
-- `CallSignalToSession` — точечный сигнал в конкретную сессию (`RINGING/ACCEPT/DECLINE/TIMEOUT/HANGUP/OFFER/ANSWER/ICE`).
-
-## 2) Базовый поток звонка
-1. Инициатор отправляет `CallInviteBroadcast(toLogin, callId, type=100)`.
-2. Сервер отправляет онлайн-сессиям callee событие `IncomingCallInvite` по WS.
-3. Офлайн-сессиям callee сервер отправляет WebPush `incoming_call` (с TTL).
-4. Любая сессия callee, получив invite, может отправить `RINGING`.
-5. Инициатор после первого `RINGING` показывает «Вызываем…».
-6. При `ACCEPT` выбирается одна целевая сессия, остальные получают `HANGUP/stop_call` и закрывают экран входящего.
-7. Далее только выбранная пара сессий обменивается `OFFER/ANSWER/ICE`.
-
-## 3) WebPush по звонкам
-### `incoming_call`
-- Используется как fallback для офлайн-сессий.
-- Поля: `kind`, `callId`, `fromLogin`, `fromSessionId`, `toLogin`, `sentAtMs`, `expiresAtMs`.
-- TTL сейчас: **10 секунд**.
-- Если push пришёл после `expiresAtMs`, уведомление не показывается.
-
-### `stop_call`
-- Отправляется при завершении/отмене/принятии на другом устройстве.
-- Поля: `kind`, `callId`, `reason`, `fromLogin`, `fromSessionId`, `toLogin`, `sentAtMs`.
-- Service Worker закрывает уведомление по `tag=callId`.
-
-## 4) Кнопки в push-уведомлении
-- Для `incoming_call` есть actions:
-- `accept` — «Ответить»
-- `decline` — «Сбросить»
-- По нажатию action:
-- Service Worker шлёт событие в открытые вкладки,
-- и открывает/фокусит UI с параметрами действия.
-- UI пытается выполнить действие сразу (принять/отклонить), если сессия уже авторизована.
-
-## 5) Важные ограничения
-- Источник истины по звонку — серверный сигналинг (WS + серверные сигналы), push только вспомогательный канал.
-- Push может прийти с задержкой и не по порядку, поэтому клиент фильтрует устаревшие события (`expiresAtMs` + `callId` + `stop_call`).
-- Полноценный «автоответ в фоне без открытия UI» в web/PWA не гарантируется платформой.
-
-## 6) Ограничение текущей архитектуры
-- Сейчас звонки надёжно работают в рамках одного сигнального контура (когда обе стороны подключены к совместимому серверу/кластеру).
-- Межсерверный сценарий «A и B на полностью разных серверах» пока не завершён и вынесен в TODO.
diff --git a/Типы_блоков_и_сообщений_SHiNE.md b/Типы_блоков_и_сообщений_SHiNE.md
deleted file mode 100644
index c0770300..00000000
--- a/Типы_блоков_и_сообщений_SHiNE.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Типы блоков и сообщений SHiNE (карта протокола)
-
-## 1) Главный принцип
-
-В блокчейн попадают только записи `AddBlock` (подписанные бинарные блоки).
-Все остальное (например, call signaling, push-события, служебные JSON-операции) — не блокчейн-данные.
-
----
-
-## 2) Базовые `msg_type`
-
-## `msg_type=0` — TECH
-
-- `subType=0` — `HEADER_COMPAT` (техническая совместимость);
-- `subType=1` — `TECH_CREATE_CHANNEL` (создание канала).
-
-Используется для структуры каналов.
-
-## `msg_type=1` — TEXT
-
-- `10` — `TEXT_POST` (пост в линии канала);
-- `11` — `TEXT_EDIT_POST` (правка поста);
-- `20` — `TEXT_REPLY` (ответ на сообщение через target);
-- `21` — `TEXT_EDIT_REPLY` (правка ответа).
-
-Это основной контент каналов и тредов.
-
-## `msg_type=2` — REACTION
-
-- `1` — `REACTION_LIKE`;
-- `2` — `REACTION_UNLIKE`.
-
-Лайки/снятие лайка, считаются через state-триггеры и/или агрегации.
-
-## `msg_type=3` — CONNECTION
-
-Связи между пользователями (friend/contact/follow/spouse/parent/child/sibling + обратные UN*).
-
-Используется для соцграфа и подписок:
-
-- `FOLLOW/UNFOLLOW` — подписки на авторов/каналы.
-
-## `msg_type=4` — USER_PARAM
-
-Ключ-значение параметра пользователя (profile / тех.параметры).
-
-Для описаний каналов `USER_PARAM` больше не используется: описание хранится только в `CreateChannelBody v2`.
-
----
-
-## 3) Что **не** является блокчейн-типом
-
-Ниже операции есть в протоколе, но не через `AddBlock`:
-
-- `CallInviteBroadcast`, `CallSignalToSession` (сигналинг звонков),
-- `SendDirectMessage`, `ReceiveIncomingMessage`, `ReceiveOutcomingMessage`,
-- `AckSessionDelivery`, `UpsertPushToken`, `SendTestWebPush`,
-- системные `Ping`, `GetServerInfo`, логи и т.п.
-
-Это JSON-операции поверх WS/серверной логики.
-
----
-
-## 4) Формат блока (высокоуровнево)
-
-Блок включает:
-
-1. preimage (header + body),
-2. `sigMarker`,
-3. `signature64`.
-
-`hash32 = SHA-256(preimage)`, подпись Ed25519 проверяется сервером при `AddBlock`.
-
-Ключевые проверки на сервере:
-
-- `blockNumber == last + 1`,
-- `prevHash` совпадает с последним хэшем цепочки,
-- body валиден по типу/версии/subtype,
-- подпись корректна.
-
----
-
-## 5) Где и как это используется в UI
-
-## Уже активно
-
-- создание канала (`TECH_CREATE_CHANNEL`);
-- пост в канал (`TEXT_POST`);
-- ответ (`TEXT_REPLY`);
-- лайк/анлайк (`REACTION_*`);
-- follow/unfollow через connection-блоки.
-
-## Частично готово на API, но не доведено в UI
-
-- отображение полной истории правок (`versions[]` есть в API, но UI показывает не полностью как отдельный workflow);
-- редактирование поста/ответа (типы в протоколе есть, UI-сценарий не завершен);
-- удаление сообщений отсутствует как тип.
-
----
-
-## 6) Про “AI сообщения”
-
-Отдельного `msg_type/subType` “AI message” в текущем протоколе нет.
-Если нужно, это обычно делают либо:
-
-- как новый `TEXT_*` subtype (если это контент канала),
-- либо как отдельный новый `msg_type` (если нужна независимая семантика/правила).
-
----
-
-## 7) Почему в UI виден JSON, а не “сырой блок”
-
-Текущий read-path сделан так:
-
-- сервер читает блоки из БД;
-- парсит и собирает удобное JSON-представление;
-- UI рендерит его как сообщения/треды.
-
-Плюсы:
-
-- проще и быстрее для интерфейса;
-- не дублируется сложная логика парсинга блоков на клиенте.
-
-Минусы:
-
-- клиент не делает локальную крипто-верификацию каждого прочитанного элемента.
-
-При необходимости можно добавить режим “raw block view” и верификацию на клиенте как отдельный экспертный экран.
-
----
-
-## 8) Рекомендация по UX/протоколу
-
-Для обычного пользователя лучше оставить “UI-сообщения” (читаемо и быстро).
-Для аудита/доверия имеет смысл добавить отдельный режим:
-
-- показать `blockNumber/hash/signature`,
-- показать все версии,
-- кнопка “проверить подпись локально” (advanced).
-
-Так получится и удобство, и проверяемость.