SHA256
Дизайн Артёма
This commit is contained in:
@@ -83,6 +83,7 @@ import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
|
||||
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
|
||||
import * as deviceQrView from './pages/device-qr-view.js';
|
||||
import * as deviceCameraView from './pages/device-camera-view.js';
|
||||
import * as showKeysView from './pages/show-keys-view.js';
|
||||
@@ -404,6 +405,7 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
|
||||
}
|
||||
|
||||
const MANAGED_SHELL_CLASSES = [
|
||||
'app-shell--wide',
|
||||
'app-shell--top-fade',
|
||||
'app-shell--bottom-fade',
|
||||
'app-shell--bottom-fade-composer',
|
||||
@@ -455,6 +457,7 @@ function normalizeShellMode(mode = {}, showAppChrome = true) {
|
||||
function applyShellMode(mode, showAppChrome = true) {
|
||||
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
|
||||
const normalized = normalizeShellMode(mode, showAppChrome);
|
||||
appShellEl.classList.toggle('app-shell--wide', normalized.contentWidth === 'wide');
|
||||
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
|
||||
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
|
||||
@@ -1532,6 +1535,8 @@ async function ensureSessionRuntimeStarted() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
applyThemeMode();
|
||||
watchSystemTheme();
|
||||
consumeCallPushActionFromUrlIfAny();
|
||||
const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
|
||||
void tryLockPortraitOrientation();
|
||||
|
||||
@@ -300,6 +300,7 @@ export function openArweaveAttachmentManager({
|
||||
autoOpenFileDialog = true,
|
||||
shineType = '',
|
||||
extraUploadTags = [],
|
||||
signal = null,
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
@@ -359,6 +360,7 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||||
if (closed) return;
|
||||
const item = persistToHistory
|
||||
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||||
pendingPlacement,
|
||||
@@ -373,6 +375,9 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = () => close(resolve, null);
|
||||
if (signal?.aborted) { onAbort(); return; }
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const bindBackdrop = () => {
|
||||
const modal = root.querySelector('[data-ar-attach-modal="true"]');
|
||||
modal?.addEventListener('click', (event) => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
|
||||
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
|
||||
import { state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { renderUserAvatar } from './avatar-image.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
|
||||
const drafts = new Map();
|
||||
|
||||
function draftKey(key) {
|
||||
const login = String(state.session.login || 'guest').trim().toLowerCase();
|
||||
return `${login}:${String(key || 'editor')}`;
|
||||
}
|
||||
|
||||
function attachmentLabel(item) {
|
||||
const name = String(item?.name || 'Файл');
|
||||
const size = Number(item?.size || 0);
|
||||
if (!size) return name;
|
||||
if (size < 1024) return `${name} · ${size} Б`;
|
||||
if (size < 1024 * 1024) return `${name} · ${Math.ceil(size / 1024)} КБ`;
|
||||
return `${name} · ${(size / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
export function openChannelEditor({
|
||||
id = 'channel-editor',
|
||||
title = 'Ответ',
|
||||
submitLabel = 'Ответить',
|
||||
placeholder = 'Напишите ответ',
|
||||
context = null,
|
||||
key = 'reply',
|
||||
extraControl = null,
|
||||
initialText = '',
|
||||
allowEmptyText = false,
|
||||
allowAttachments = true,
|
||||
rawText = false,
|
||||
onSubmit,
|
||||
isActive = () => true,
|
||||
} = {}) {
|
||||
if (!state.session.isAuthorized) {
|
||||
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
|
||||
return null;
|
||||
}
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || typeof onSubmit !== 'function') return null;
|
||||
if (root.querySelector('.channel-editor-overlay')) return null;
|
||||
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const storageKey = draftKey(key);
|
||||
const login = state.session.login;
|
||||
const saved = drafts.get(storageKey) || { text: initialText, attachments: [] };
|
||||
const attachments = Array.isArray(saved.attachments) ? [...saved.attachments] : [];
|
||||
const extraFields = extraControl ? [...extraControl.querySelectorAll('select,input')] : [];
|
||||
extraFields.forEach((field, index) => {
|
||||
if (saved.controls?.[index] !== undefined) field.value = saved.controls[index];
|
||||
});
|
||||
let inFlight = false;
|
||||
let composing = false;
|
||||
let closed = false;
|
||||
let picking = false;
|
||||
let completed = false;
|
||||
const attachmentController = new AbortController();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'channel-editor-overlay';
|
||||
overlay.id = id;
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-labelledby', `${id}-title`);
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'channel-editor';
|
||||
|
||||
const header = document.createElement('header');
|
||||
header.className = 'channel-editor__header';
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'icon-btn channel-editor__close';
|
||||
closeButton.setAttribute('aria-label', 'Закрыть редактор');
|
||||
closeButton.textContent = '×';
|
||||
const heading = document.createElement('h2');
|
||||
heading.id = `${id}-title`;
|
||||
heading.textContent = title;
|
||||
header.append(closeButton, heading, document.createElement('span'));
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'channel-editor__body';
|
||||
|
||||
if (context && (context.author || context.text || context.attachmentLabel)) {
|
||||
const contextBox = document.createElement('div');
|
||||
contextBox.className = 'channel-editor__context';
|
||||
const contextTitle = document.createElement('strong');
|
||||
contextTitle.textContent = context.author ? `В ответ ${context.author}` : 'Контекст сообщения';
|
||||
const quote = document.createElement('p');
|
||||
quote.textContent = String(context.text || context.attachmentLabel || 'Сообщение без текста');
|
||||
const expand = document.createElement('button');
|
||||
expand.type = 'button';
|
||||
expand.className = 'text-btn channel-editor__context-toggle';
|
||||
expand.textContent = 'Показать целиком';
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
expand.addEventListener('click', () => {
|
||||
const expanded = contextBox.classList.toggle('is-expanded');
|
||||
expand.textContent = expanded ? 'Свернуть' : 'Показать целиком';
|
||||
expand.setAttribute('aria-expanded', String(expanded));
|
||||
});
|
||||
contextBox.append(contextTitle, quote, expand);
|
||||
body.append(contextBox);
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'channel-editor__input';
|
||||
textarea.maxLength = 2000;
|
||||
textarea.placeholder = placeholder;
|
||||
textarea.setAttribute('aria-label', placeholder);
|
||||
textarea.value = String(saved.text || '');
|
||||
|
||||
const attachmentsBox = document.createElement('div');
|
||||
attachmentsBox.className = 'channel-editor__attachments';
|
||||
attachmentsBox.setAttribute('aria-live', 'polite');
|
||||
|
||||
const error = document.createElement('p');
|
||||
error.className = 'channel-editor__error';
|
||||
error.setAttribute('role', 'alert');
|
||||
|
||||
const clearButton = document.createElement('button');
|
||||
clearButton.type = 'button';
|
||||
clearButton.className = 'text-btn channel-editor__clear';
|
||||
clearButton.textContent = 'Очистить черновик';
|
||||
|
||||
const author = document.createElement('div');
|
||||
author.className = 'channel-editor__author';
|
||||
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
|
||||
const authorName = document.createElement('strong');
|
||||
authorName.textContent = login || 'Гость';
|
||||
author.append(authorName);
|
||||
body.append(author);
|
||||
if (extraControl instanceof Node) body.append(extraControl);
|
||||
body.append(textarea, attachmentsBox, error, clearButton);
|
||||
|
||||
const footer = document.createElement('footer');
|
||||
footer.className = 'channel-editor__footer';
|
||||
const attachButton = document.createElement('button');
|
||||
attachButton.type = 'button';
|
||||
attachButton.className = 'secondary-btn channel-editor__attach';
|
||||
attachButton.textContent = 'Прикрепить';
|
||||
attachButton.hidden = !allowAttachments;
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'channel-editor__counter';
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.type = 'button';
|
||||
submitButton.className = 'primary-btn channel-editor__submit';
|
||||
submitButton.textContent = submitLabel;
|
||||
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
|
||||
footer.append(attachButton);
|
||||
footer.append(counter, submitButton);
|
||||
dialog.append(header, body, footer);
|
||||
overlay.append(dialog);
|
||||
root.replaceChildren(overlay);
|
||||
|
||||
const saveDraft = () => {
|
||||
const value = { text: textarea.value, attachments: [...attachments], controls: extraFields.map((field) => field.value) };
|
||||
if (completed) { drafts.delete(storageKey); return; }
|
||||
if (value.text || value.attachments.length) drafts.set(storageKey, value);
|
||||
else drafts.delete(storageKey);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.max(160, textarea.scrollHeight)}px`;
|
||||
const length = textarea.value.length;
|
||||
counter.textContent = `${length} / 2000`;
|
||||
counter.classList.toggle('is-near-limit', length >= 1800);
|
||||
submitButton.disabled = inFlight || picking || length > textarea.maxLength || (!allowEmptyText && !textarea.value.trim() && attachments.length === 0);
|
||||
dialog.setAttribute('aria-busy', String(inFlight || picking));
|
||||
clearButton.hidden = !textarea.value && attachments.length === 0;
|
||||
attachmentsBox.replaceChildren();
|
||||
attachments.forEach((item, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'channel-editor__attachment';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = attachmentLabel(item);
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'icon-btn';
|
||||
remove.setAttribute('aria-label', `Убрать вложение ${item?.name || ''}`);
|
||||
remove.textContent = '×';
|
||||
remove.disabled = inFlight;
|
||||
remove.addEventListener('click', () => {
|
||||
attachments.splice(index, 1);
|
||||
saveDraft();
|
||||
sync();
|
||||
});
|
||||
card.append(label, remove);
|
||||
attachmentsBox.append(card);
|
||||
});
|
||||
};
|
||||
|
||||
const openedUrl = location.href;
|
||||
const historyId = `${Date.now()}:${Math.random()}`;
|
||||
history.pushState({ ...history.state, channelEditor: historyId }, '', openedUrl);
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewport = () => {
|
||||
overlay.style.setProperty('--editor-height', `${viewport?.height || window.innerHeight}px`);
|
||||
overlay.style.setProperty('--editor-top', `${viewport?.offsetTop || 0}px`);
|
||||
};
|
||||
const onBack = (event) => {
|
||||
if (closed) return;
|
||||
event.stopImmediatePropagation();
|
||||
close({ fromHistory: true });
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!overlay.isConnected) close({ restoreFocus: false });
|
||||
});
|
||||
const close = ({ fromHistory = false, restoreFocus = true } = {}) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
saveDraft();
|
||||
observer.disconnect();
|
||||
attachmentController.abort();
|
||||
document.removeEventListener('keydown', onDocumentKeydown);
|
||||
viewport?.removeEventListener('resize', updateViewport);
|
||||
viewport?.removeEventListener('scroll', updateViewport);
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
window.removeEventListener('popstate', onBack, true);
|
||||
overlay.remove();
|
||||
if (!fromHistory && location.href === openedUrl && history.state?.channelEditor === historyId) {
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (location.href === openedUrl) event.stopImmediatePropagation();
|
||||
}, { capture: true, once: true });
|
||||
history.back();
|
||||
}
|
||||
if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true });
|
||||
};
|
||||
overlay.cleanup = () => close({ restoreFocus: false });
|
||||
|
||||
const submit = async () => {
|
||||
if (inFlight || submitButton.disabled) return;
|
||||
inFlight = true;
|
||||
error.textContent = '';
|
||||
textarea.disabled = true;
|
||||
attachButton.disabled = true;
|
||||
clearButton.disabled = true;
|
||||
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = true; });
|
||||
submitButton.textContent = 'Отправляем…';
|
||||
sync();
|
||||
try {
|
||||
await onSubmit({
|
||||
text: rawText ? textarea.value.trim() : composeMessageWithAttachments(textarea.value.trim(), attachments),
|
||||
attachments: [...attachments],
|
||||
});
|
||||
completed = true;
|
||||
attachments.forEach((item) => markArweaveAttachmentPlaced(login, item));
|
||||
// Не удаляем новый черновик, открытый после закрытия отправляющего редактора.
|
||||
if (!closed || drafts.get(storageKey)?.text === textarea.value) drafts.delete(storageKey);
|
||||
if (!isActive() || closed) return;
|
||||
close();
|
||||
} catch (submitError) {
|
||||
if (!isActive() || closed) return;
|
||||
inFlight = false;
|
||||
textarea.disabled = false;
|
||||
attachButton.disabled = false;
|
||||
clearButton.disabled = false;
|
||||
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = false; });
|
||||
submitButton.textContent = submitLabel;
|
||||
error.textContent = toUserMessage(submitError, 'Не удалось отправить. Текст сохранён.');
|
||||
saveDraft();
|
||||
sync();
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentKeydown = (event) => {
|
||||
if (!overlay.isConnected || document.querySelector('.ar-attachment-manager-root')) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !composing && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = [...overlay.querySelectorAll('button:not(:disabled), textarea:not(:disabled), select:not(:disabled)')].filter((el) => !el.hidden);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', () => close());
|
||||
clearButton.addEventListener('click', () => {
|
||||
textarea.value = '';
|
||||
attachments.splice(0);
|
||||
drafts.delete(storageKey);
|
||||
error.textContent = '';
|
||||
sync();
|
||||
textarea.focus();
|
||||
});
|
||||
textarea.addEventListener('input', () => {
|
||||
error.textContent = '';
|
||||
saveDraft();
|
||||
sync();
|
||||
});
|
||||
textarea.addEventListener('compositionstart', () => { composing = true; });
|
||||
extraFields.forEach((field) => field.addEventListener('change', saveDraft));
|
||||
textarea.addEventListener('compositionend', () => { composing = false; });
|
||||
attachButton.addEventListener('click', async () => {
|
||||
if (picking || inFlight) return;
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
error.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
picking = true;
|
||||
dialog.inert = true;
|
||||
sync();
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
signal: attachmentController.signal,
|
||||
});
|
||||
if (!isActive() || !overlay.isConnected || !item) return;
|
||||
attachments.push(item);
|
||||
saveDraft();
|
||||
sync();
|
||||
} catch (attachError) {
|
||||
if (isActive() && overlay.isConnected) {
|
||||
error.textContent = toUserMessage(attachError, 'Не удалось добавить вложение.');
|
||||
}
|
||||
} finally {
|
||||
picking = false;
|
||||
dialog.inert = false;
|
||||
if (!closed) { sync(); attachButton.focus(); }
|
||||
}
|
||||
});
|
||||
submitButton.addEventListener('click', () => void submit());
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
window.addEventListener('popstate', onBack, true);
|
||||
viewport?.addEventListener('resize', updateViewport);
|
||||
viewport?.addEventListener('scroll', updateViewport);
|
||||
window.addEventListener('resize', updateViewport);
|
||||
observer.observe(root, { childList: true });
|
||||
updateViewport();
|
||||
sync();
|
||||
requestAnimationFrame(() => {
|
||||
if (closed || !isActive()) return;
|
||||
const quote = overlay.querySelector('.channel-editor__context p');
|
||||
const expand = overlay.querySelector('.channel-editor__context-toggle');
|
||||
if (quote && expand && quote.clientHeight > 0) expand.hidden = quote.scrollHeight <= quote.clientHeight;
|
||||
sync();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
});
|
||||
return { close };
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function createDropdownMenu({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
close();
|
||||
close({ focusAnchor: true });
|
||||
await item.action?.();
|
||||
});
|
||||
menuEl.append(button);
|
||||
@@ -154,6 +154,7 @@ export function createDropdownMenu({
|
||||
setAnchorOpen(true);
|
||||
onOpen?.();
|
||||
position();
|
||||
menuEl.querySelector('button:not(:disabled)')?.focus();
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
@@ -172,8 +173,19 @@ export function createDropdownMenu({
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close({ focusAnchor: true });
|
||||
if (!portal) return;
|
||||
if (event.key === 'Escape' || event.key === 'Tab') {
|
||||
if (event.key === 'Escape') event.preventDefault();
|
||||
close({ focusAnchor: true });
|
||||
return;
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const buttons = [...menuEl.querySelectorAll('button:not(:disabled)')];
|
||||
if (!buttons.length) return;
|
||||
event.preventDefault();
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
};
|
||||
const onNavigation = () => close();
|
||||
const onViewportChange = () => position();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
export function attachMessageMenu(card, head, items) {
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'icon-btn channel-message-more';
|
||||
trigger.textContent = '⋯';
|
||||
trigger.setAttribute('aria-label', 'Действия сообщения');
|
||||
head.append(trigger);
|
||||
const menu = createDropdownMenu({ anchorEl: trigger, items });
|
||||
card.cleanup = () => menu.destroy();
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state, authService } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
|
||||
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
|
||||
// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю).
|
||||
import { iconHtml as lineIcon } from './ui-icon.js';
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: SHINE_CONNECTIONS_LOGO_SRC, glow: 'rgba(0, 229, 255, .6)', hero: true },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
|
||||
{ pageId: 'messages-list', label: 'Личные' },
|
||||
{ pageId: 'channels-list', label: 'Каналы' },
|
||||
{ pageId: 'network-view', label: 'Связи' },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления' },
|
||||
{ pageId: 'profile-view', label: 'Профиль' },
|
||||
];
|
||||
|
||||
function iconHtml(item) {
|
||||
return item.iconImg
|
||||
? `<img class="toolbar-icon-img" src="${item.iconImg}" alt="" aria-hidden="true" style="--tab-glow:${item.glow}" />`
|
||||
: `<span>${item.icon}</span>`;
|
||||
const names = { 'messages-list': 'message', 'channels-list': 'channels', 'network-view': 'network', 'notifications-view': 'bell', 'profile-view': 'profile' };
|
||||
return lineIcon(names[item.pageId]);
|
||||
}
|
||||
|
||||
function normalizeCounters(payload = {}) {
|
||||
@@ -126,6 +121,8 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
const isMessages = item.pageId === 'messages-list';
|
||||
const isNetwork = item.pageId === 'network-view';
|
||||
const isNotifications = item.pageId === 'notifications-view';
|
||||
btn.type = 'button';
|
||||
if (item.pageId === active) btn.setAttribute('aria-current', 'page');
|
||||
btn.dataset.toolbarPage = item.pageId;
|
||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||
if (isProfile) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const paths = {
|
||||
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
|
||||
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
|
||||
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
|
||||
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
|
||||
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||||
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
|
||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
||||
};
|
||||
|
||||
export function iconHtml(name, filled = false) {
|
||||
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
@@ -12,18 +15,17 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import { openChannelEditor } from '../components/channel-editor.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentCarouselElement,
|
||||
escapeHtml,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -63,7 +65,7 @@ function createThreadAvatar(login) {
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
@@ -78,7 +80,7 @@ function createThreadAvatar(login) {
|
||||
}
|
||||
: null,
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -490,120 +492,18 @@ function resolveNodeText(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDraftAttachments(container, attachments) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
});
|
||||
container.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'thread-reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#thread-reply-text');
|
||||
const attachmentsEl = root.querySelector('#thread-reply-attachments');
|
||||
const errorEl = root.querySelector('#thread-reply-error');
|
||||
const submitEl = root.querySelector('#thread-reply-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
return openChannelEditor({
|
||||
id: 'thread-reply-modal',
|
||||
title: isRating ? 'Оценка' : 'Ответ',
|
||||
submitLabel: isRating ? 'Отправить' : 'Ответить',
|
||||
placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
|
||||
context,
|
||||
key: `${draftKey}:${mode}`,
|
||||
isActive,
|
||||
onSubmit: ({ text }) => onSubmit(text),
|
||||
});
|
||||
|
||||
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
|
||||
@@ -722,58 +622,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-edit-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Редактировать сообщение</h3>
|
||||
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
|
||||
<div class="meta-muted inline-error" id="thread-edit-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
|
||||
</div>
|
||||
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const textEl = root.querySelector('#thread-edit-text');
|
||||
const errorEl = root.querySelector('#thread-edit-error');
|
||||
if (textEl) textEl.value = String(initialText || '');
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
|
||||
const value = String(textEl?.value || '').trim();
|
||||
if (!value && !allowEmptyText) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
|
||||
return openChannelEditor({
|
||||
id: 'thread-edit-modal', title: 'Редактирование', submitLabel: 'Сохранить',
|
||||
placeholder: 'Текст сообщения', initialText, allowEmptyText,
|
||||
key: draftKey, allowAttachments: false, rawText: true, isActive,
|
||||
onSubmit: ({ text }) => onSave(text),
|
||||
});
|
||||
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-edit-save')?.click());
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
@@ -789,7 +644,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const shiningLikes = Number(node?.shiningLikesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const ratings = Number(node?.ratingsCount || 0);
|
||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
||||
const isOwnMessage = Boolean(state.session.isAuthorized && state.session.login) && String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase();
|
||||
const msgSubType = Number(node?.msgSubType || 0);
|
||||
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
|
||||
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
|
||||
@@ -809,6 +664,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'ui-button channel-message-author-tile';
|
||||
const menuItems = [];
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -829,7 +685,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
titleMain.append(loginEl, numberEl);
|
||||
title.append(titleMain);
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
@@ -928,21 +784,17 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
likeButton.className = 'ui-button channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
|
||||
<span class="channel-action-counter">${likes}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
const ok = window.confirm('Поставить лайк?');
|
||||
if (!ok) return;
|
||||
}
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
setActionTitle(likeButton, 'Лайк...');
|
||||
try {
|
||||
@@ -954,6 +806,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
targetBlockNumber: target?.blockNumber,
|
||||
});
|
||||
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
|
||||
} finally {
|
||||
if (likeButton.isConnected) likeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -961,7 +815,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${replies}</span>
|
||||
`;
|
||||
@@ -970,9 +824,14 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
isActive: handlers.isActive,
|
||||
draftKey: `message:${refKey}`,
|
||||
context: {
|
||||
author,
|
||||
text: parsedText.text,
|
||||
attachmentLabel: parsedText.attachments[0]?.name || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
@@ -982,7 +841,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'ui-button channel-action-item thread-share-btn';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
setActionTitle(shareButton, 'Отправить');
|
||||
@@ -994,7 +853,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
const discussionButton = document.createElement('button');
|
||||
discussionButton.type = 'button';
|
||||
discussionButton.className = 'ui-button channel-action-item';
|
||||
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
|
||||
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
|
||||
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
|
||||
actions.append(likeButton, discussionButton, shareButton, replyButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
@@ -1016,7 +881,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
messageBlockNumber: repostTarget.blockNumber,
|
||||
}));
|
||||
});
|
||||
actions.append(originalButton);
|
||||
menuItems.push({ label: 'Оригинал', action: () => originalButton.click() });
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
@@ -1038,7 +903,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
msgSubType,
|
||||
}), { isActive: handlers.isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@@ -1052,14 +917,23 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
isActive: handlers.isActive,
|
||||
draftKey: `edit:${messageRefKey(target)}`,
|
||||
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
|
||||
allowEmptyText: parsedText.attachments.length > 0,
|
||||
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
|
||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
||||
});
|
||||
});
|
||||
actions.append(editButton);
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
|
||||
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
|
||||
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
card.append(actions);
|
||||
authorTile.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -1067,13 +941,10 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
if (!login) return;
|
||||
handlers.navigate(makeProfileRoute(login));
|
||||
});
|
||||
card.addEventListener('click', () => {
|
||||
handlers.onOpenThread(target);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
@@ -1083,11 +954,17 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
const nodeNumber = nextNumber();
|
||||
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
|
||||
row.classList.add('thread-node-level');
|
||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
if (parent) {
|
||||
const context = document.createElement('p');
|
||||
context.className = 'thread-reply-context';
|
||||
const excerpt = parseMessageAttachments(resolveNodeText(parent)).text;
|
||||
context.textContent = `В ответ ${parent.authorLogin || 'автору'} · ${excerpt.slice(0, 100) || 'Вложение'}`;
|
||||
row.prepend(context);
|
||||
}
|
||||
wrap.append(row);
|
||||
|
||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1, branch.node));
|
||||
}
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||
@@ -1139,6 +1016,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
const positionKey = `${state.session.login}:thread:${routeKey}`;
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
@@ -1348,6 +1226,8 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.append(statusBox);
|
||||
|
||||
const clearContent = () => {
|
||||
chrome?.setComposer(null);
|
||||
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
|
||||
for (const timerId of refreshTimers) window.clearTimeout(timerId);
|
||||
refreshTimers.clear();
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
@@ -1365,6 +1245,7 @@ export function render({ navigate, route, chrome }) {
|
||||
'#thread-reply-modal',
|
||||
'#thread-repost-modal',
|
||||
].join(','))) {
|
||||
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
@@ -1377,7 +1258,9 @@ export function render({ navigate, route, chrome }) {
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
const hadContent = !!screen.querySelector('.thread-block');
|
||||
const restorePosition = hadContent ? document.getElementById('app-screen')?.scrollTop : readChannelPosition(positionKey);
|
||||
if (!hadContent) clearContent();
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
@@ -1393,7 +1276,7 @@ export function render({ navigate, route, chrome }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const skeleton = hadContent ? null : renderSkeleton(screen);
|
||||
|
||||
try {
|
||||
let resolvedMessage = selector.message;
|
||||
@@ -1468,7 +1351,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
const focus = payload?.focus || null;
|
||||
@@ -1499,7 +1382,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||
if (threadHeaderButton) {
|
||||
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
|
||||
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
|
||||
threadHeaderButton.disabled = false;
|
||||
threadHeaderButton.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1510,6 +1393,7 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
}
|
||||
|
||||
clearContent();
|
||||
let localSeq = 0;
|
||||
const nextNumber = () => {
|
||||
localSeq += 1;
|
||||
@@ -1532,16 +1416,32 @@ export function render({ navigate, route, chrome }) {
|
||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||
const focusTitle = document.createElement('h3');
|
||||
focusTitle.className = 'section-title';
|
||||
focusTitle.textContent = 'Текущее сообщение';
|
||||
focusWrap.append(focusTitle);
|
||||
focusTitle.textContent = 'Исходное сообщение';
|
||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
||||
const composer = document.createElement('div');
|
||||
composer.className = 'channel-composer';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'primary-btn';
|
||||
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
|
||||
reply.addEventListener('click', () => {
|
||||
const parsed = parseMessageAttachments(resolveNodeText(focus));
|
||||
openReplyModal({
|
||||
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
|
||||
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
|
||||
isActive: () => !disposed,
|
||||
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
|
||||
});
|
||||
});
|
||||
composer.append(reply);
|
||||
chrome?.setComposer(composer);
|
||||
}
|
||||
|
||||
const descendantsWrap = document.createElement('div');
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы и оценки';
|
||||
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
@@ -1549,7 +1449,7 @@ export function render({ navigate, route, chrome }) {
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов и оценок пока нет.';
|
||||
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
@@ -1565,7 +1465,8 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||
if (!hasPendingScroll && focusWrap) {
|
||||
if (!hasPendingScroll && Number.isFinite(restorePosition)) restoreChannelPosition(restorePosition);
|
||||
if (!hasPendingScroll && !Number.isFinite(restorePosition) && focusWrap) {
|
||||
trackTimer(window.setTimeout(() => {
|
||||
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
|
||||
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
@@ -1573,10 +1474,17 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'primary-btn';
|
||||
retry.textContent = 'Повторить';
|
||||
retry.addEventListener('click', () => void refresh());
|
||||
failed.append(retry);
|
||||
screen.append(failed);
|
||||
}
|
||||
};
|
||||
@@ -1584,6 +1492,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
|
||||
+153
-279
@@ -1,3 +1,6 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { renderAvatar, renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { openChannelEditor } from '../components/channel-editor.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
@@ -35,7 +39,7 @@ import {
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
@@ -88,7 +92,7 @@ function createMessageAvatar(login) {
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
@@ -103,7 +107,7 @@ function createMessageAvatar(login) {
|
||||
}
|
||||
: null,
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
className: 'channel-message-avatar avatar-plain',
|
||||
title,
|
||||
});
|
||||
avatarEl.replaceWith(upgraded);
|
||||
@@ -769,7 +773,7 @@ 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;
|
||||
if (!(event.ctrlKey || event.metaKey) || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
submit();
|
||||
});
|
||||
@@ -880,101 +884,18 @@ function renderDraftAttachments(container, attachments) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
|
||||
function openReplyModal({ onSubmit, mode = 'reply', isActive = () => true, context = null, draftKey = 'reply' }) {
|
||||
const isRating = mode === 'rating';
|
||||
const title = isRating ? 'Оценка' : 'Ответ';
|
||||
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||
const emptyError = isRating
|
||||
? 'Введите текст оценки или добавьте вложение.'
|
||||
: 'Введите текст ответа или добавьте вложение.';
|
||||
const submitError = isRating
|
||||
? 'Не удалось отправить оценку.'
|
||||
: 'Не удалось отправить ответ.';
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#reply-text');
|
||||
const attachmentsEl = root.querySelector('#reply-attachments');
|
||||
const errorEl = root.querySelector('#reply-error');
|
||||
const submitEl = root.querySelector('#reply-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#reply-cancel')?.addEventListener('click', close);
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, submitError);
|
||||
}
|
||||
return openChannelEditor({
|
||||
id: 'reply-modal',
|
||||
title: isRating ? 'Оценка' : 'Ответ',
|
||||
submitLabel: isRating ? 'Отправить' : 'Ответить',
|
||||
placeholder: isRating ? 'Напишите оценку' : 'Напишите ответ',
|
||||
context,
|
||||
key: `${draftKey}:${mode}`,
|
||||
isActive,
|
||||
onSubmit: ({ text }) => onSubmit(text),
|
||||
});
|
||||
|
||||
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
|
||||
@@ -1246,104 +1167,30 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-message-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||
<p class="meta-muted">${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<div class="draft-attachments" id="channel-message-attachments"></div>
|
||||
<div class="channel-message-tools">
|
||||
<select id="channel-message-type" class="input channel-message-type-select">
|
||||
<option value="${10}">Пост</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
</select>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const typeWrap = document.createElement('label');
|
||||
typeWrap.className = 'channel-editor__type';
|
||||
typeWrap.textContent = 'Тип сообщения';
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'select';
|
||||
typeSelect.innerHTML = `
|
||||
<option value="10">Публикация</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
`;
|
||||
typeWrap.append(typeSelect);
|
||||
|
||||
const textEl = root.querySelector('#channel-message-text');
|
||||
const typeEl = root.querySelector('#channel-message-type');
|
||||
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
||||
const errorEl = root.querySelector('#channel-message-error');
|
||||
const submitEl = root.querySelector('#channel-message-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (typeEl) typeEl.disabled = inFlight;
|
||||
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
const body = String(textEl?.value || '').trim();
|
||||
const msgSubType = Number(typeEl?.value || 10);
|
||||
if (!body && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
text: composeMessageWithAttachments(body, attachments),
|
||||
msgSubType,
|
||||
});
|
||||
if (!isActive()) return;
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
||||
}
|
||||
return openChannelEditor({
|
||||
id: 'channel-message-modal',
|
||||
title: 'Новое сообщение',
|
||||
submitLabel: 'Опубликовать',
|
||||
placeholder: 'Напишите сообщение',
|
||||
key: `channel-post:${channelName}`,
|
||||
extraControl: typeWrap,
|
||||
isActive,
|
||||
onSubmit: ({ text }) => onSubmit({
|
||||
text,
|
||||
msgSubType: Number(typeSelect.value || 10),
|
||||
}),
|
||||
});
|
||||
|
||||
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!isActive() || !item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||
@@ -1382,59 +1229,13 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="edit-message-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Редактировать сообщение</h3>
|
||||
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
|
||||
<div class="meta-muted inline-error" id="edit-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
|
||||
</div>
|
||||
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#edit-message-text');
|
||||
const errorEl = root.querySelector('#edit-message-error');
|
||||
if (textEl) textEl.value = String(initialText || '');
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
|
||||
const value = String(textEl?.value || '').trim();
|
||||
if (!value && !allowEmptyText) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(value);
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
||||
}
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, isActive = () => true, draftKey }) {
|
||||
return openChannelEditor({
|
||||
id: 'edit-message-modal', title: 'Редактирование', submitLabel: 'Сохранить',
|
||||
placeholder: 'Текст сообщения', initialText, allowEmptyText,
|
||||
key: draftKey, allowAttachments: false, rawText: true, isActive,
|
||||
onSubmit: ({ text }) => onSave(text),
|
||||
});
|
||||
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await onDelete();
|
||||
if (!isActive()) return;
|
||||
close();
|
||||
} catch (error) {
|
||||
if (!isActive()) return;
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
||||
}
|
||||
});
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#edit-message-save')?.click());
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function mapApiMessageToPost(message, selector, localNumber) {
|
||||
@@ -1488,7 +1289,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(),
|
||||
targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0),
|
||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
|
||||
isOwnMessage: Boolean(state.session.isAuthorized && state.session.login) && String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login).trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2034,6 +1835,7 @@ function renderPostCard(post, {
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
const menuItems = [];
|
||||
const headRow = document.createElement('div');
|
||||
headRow.className = 'channel-message-head-row';
|
||||
|
||||
@@ -2055,7 +1857,7 @@ function renderPostCard(post, {
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
editedMarker.className = 'ui-button message-edited-marker';
|
||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
||||
@@ -2186,24 +1988,47 @@ function renderPostCard(post, {
|
||||
const isLiked = post.reactionState === 'liked';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', (event) => {
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike });
|
||||
likeButton.disabled = true;
|
||||
try {
|
||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Не удалось изменить лайк.'));
|
||||
} finally {
|
||||
if (likeButton.isConnected) likeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const discussionButton = document.createElement('button');
|
||||
discussionButton.type = 'button';
|
||||
discussionButton.className = 'ui-button channel-action-item channel-action-discussion';
|
||||
discussionButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Обсуждение</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
`;
|
||||
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
||||
discussionButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const route = buildThreadRoute(post.messageRef, selector);
|
||||
if (route) navigate(route);
|
||||
});
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'ui-button channel-action-item channel-action-reply';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
`;
|
||||
@@ -2212,21 +2037,26 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate,
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
isActive,
|
||||
draftKey: `message:${refKey}`,
|
||||
context: {
|
||||
author: post.authorLogin,
|
||||
text: parsedBody.text,
|
||||
attachmentLabel: parsedBody.attachments[0]?.name || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
// Rating/opinion action is intentionally hidden from UI for now.
|
||||
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||
|
||||
actions.append(likeButton, replyButton);
|
||||
actions.append(likeButton, discussionButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'ui-button channel-action-item channel-action-share';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('share')}</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
setActionTitle(shareButton, 'Отправить');
|
||||
@@ -2237,7 +2067,7 @@ function renderPostCard(post, {
|
||||
await onShare(route);
|
||||
});
|
||||
|
||||
actions.append(shareButton);
|
||||
actions.append(shareButton, replyButton);
|
||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
@@ -2259,7 +2089,7 @@ function renderPostCard(post, {
|
||||
messageBlockNumber: post.targetRef.blockNumber,
|
||||
}));
|
||||
});
|
||||
actions.append(originalBtn);
|
||||
menuItems.push({ label: 'Оригинал', action: () => originalBtn.click() });
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
@@ -2281,7 +2111,7 @@ function renderPostCard(post, {
|
||||
msgSubType: post.msgSubType,
|
||||
}), { isActive });
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
|
||||
if (post.isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@@ -2295,19 +2125,24 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
isActive,
|
||||
draftKey: `edit:${messageRefKey(post.messageRef)}`,
|
||||
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
|
||||
allowEmptyText: parsedBody.attachments.length > 0,
|
||||
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
|
||||
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
||||
});
|
||||
});
|
||||
actions.append(editButton);
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
|
||||
try { await onEdit(post.messageRef, '', { isDelete: true }); }
|
||||
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
card.append(actions);
|
||||
card.addEventListener('click', () => {
|
||||
const route = buildThreadRoute(post.messageRef, selector);
|
||||
if (route) navigate(route);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -2325,8 +2160,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
}
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = 'Подписаться на канал';
|
||||
actionButton.type = 'button';
|
||||
actionButton.className = 'primary-btn channel-main-action';
|
||||
actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
|
||||
|
||||
const addMessageButton = document.createElement('button');
|
||||
addMessageButton.type = 'button';
|
||||
@@ -2416,16 +2252,22 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
if (channelData.isDiary) {
|
||||
screen.append(feed, backButton);
|
||||
} else if (channelData.isOwnChannel) {
|
||||
screen.append(feed, addMessageButton);
|
||||
screen.append(feed);
|
||||
const composer = document.createElement('div');
|
||||
composer.className = 'channel-composer';
|
||||
composer.append(addMessageButton);
|
||||
handlers.chrome?.setComposer(composer);
|
||||
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
||||
screen.append(feed, actionButton);
|
||||
screen.append(actionButton, feed);
|
||||
} else {
|
||||
screen.append(feed);
|
||||
}
|
||||
|
||||
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
|
||||
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
|
||||
const restorePosition = handlers.restorePosition;
|
||||
const pendingScrollTimer = Number.isFinite(restorePosition) && !hasPendingScrollTarget ? 0 : applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
|
||||
if (Number.isFinite(restorePosition) && !hasPendingScrollTarget) restoreChannelPosition(restorePosition);
|
||||
const unreadScrollTimer = !Number.isFinite(restorePosition) && !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
|
||||
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
|
||||
: 0;
|
||||
|
||||
@@ -2500,6 +2342,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel';
|
||||
const positionKey = `${state.session.login}:channel:${routeKey}`;
|
||||
|
||||
let disposed = false;
|
||||
let refreshSeq = 0;
|
||||
@@ -2526,11 +2369,12 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
|
||||
let activeChannelData = null;
|
||||
let activeOpenEntrypointHistory = null;
|
||||
|
||||
const channelHeaderButton = document.createElement('button');
|
||||
channelHeaderButton.type = 'button';
|
||||
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
|
||||
channelHeaderButton.disabled = true;
|
||||
|
||||
const header = createTopBar({
|
||||
@@ -2538,7 +2382,6 @@ export function render({ navigate, route, chrome }) {
|
||||
back: { onClick: () => navigate('channels-list') },
|
||||
className: 'channel-view-topbar',
|
||||
actions: [
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
{
|
||||
label: '⋮',
|
||||
title: 'Действия канала',
|
||||
@@ -2577,7 +2420,14 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({
|
||||
label: 'Оглавление',
|
||||
action: () => {
|
||||
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
|
||||
else showToast('В этом канале пока нет оглавления');
|
||||
},
|
||||
});
|
||||
if (!apiData?.isOwnChannel) {
|
||||
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
|
||||
}
|
||||
@@ -2595,6 +2445,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
}
|
||||
}
|
||||
const aboutIndex = items.findIndex((item) => item.label === 'О канале');
|
||||
if (aboutIndex > 0) items.unshift(...items.splice(aboutIndex, 2));
|
||||
return items;
|
||||
},
|
||||
},
|
||||
@@ -2918,6 +2770,8 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.append(statusBox);
|
||||
|
||||
const clearContent = () => {
|
||||
chrome?.setComposer(null);
|
||||
screen.querySelectorAll('.channel-message-card').forEach((card) => card.cleanup?.());
|
||||
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
|
||||
cleanupSeenTracking = null;
|
||||
Array.from(screen.children).forEach((child) => {
|
||||
@@ -2942,17 +2796,23 @@ export function render({ navigate, route, chrome }) {
|
||||
'#reply-modal',
|
||||
'#repost-modal',
|
||||
].join(',');
|
||||
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
|
||||
if (modalRoot.querySelector(ownedSelector)) {
|
||||
modalRoot.querySelectorAll('.channel-editor-overlay').forEach((editor) => editor.cleanup?.());
|
||||
modalRoot.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
clearContent();
|
||||
const hadContent = !!screen.querySelector('.channel-feed');
|
||||
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
|
||||
if (!hadContent) clearContent();
|
||||
activeChannelData = null;
|
||||
activeOpenEntrypointHistory = null;
|
||||
activeSelector = null;
|
||||
showStatus('');
|
||||
channelHeaderButton.textContent = 'Канал: ...';
|
||||
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
|
||||
channelHeaderButton.disabled = true;
|
||||
channelHeaderButton.onclick = null;
|
||||
if (channelMoreButton) channelMoreButton.disabled = true;
|
||||
@@ -2962,7 +2822,7 @@ export function render({ navigate, route, chrome }) {
|
||||
channelEntrypointButton.onclick = null;
|
||||
}
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
const skeleton = hadContent ? null : renderSkeleton(screen);
|
||||
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
@@ -2981,8 +2841,17 @@ export function render({ navigate, route, chrome }) {
|
||||
},
|
||||
});
|
||||
};
|
||||
activeOpenEntrypointHistory = openEntrypointHistory;
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = titleLabel;
|
||||
const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
|
||||
channelHeaderButton.replaceChildren();
|
||||
const titleNode = document.createElement('span');
|
||||
titleNode.className = 'channel-header-title';
|
||||
titleNode.textContent = titleLabel;
|
||||
const ownerNode = document.createElement('span');
|
||||
ownerNode.className = 'channel-header-owner';
|
||||
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
|
||||
channelHeaderButton.append(titleNode, ownerNode);
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
@@ -3009,8 +2878,11 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
clearContent();
|
||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||
chrome,
|
||||
restorePosition,
|
||||
showStatus,
|
||||
isActive: () => !disposed,
|
||||
onAddMessage: () => {
|
||||
@@ -3094,7 +2966,8 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (disposed || seq !== refreshSeq) return;
|
||||
skeleton.remove();
|
||||
skeleton?.remove();
|
||||
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить канал.')); return; }
|
||||
if (isChannelsDemoMode()) {
|
||||
renderDemoFallback(screen, navigate, error);
|
||||
return;
|
||||
@@ -3108,6 +2981,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.refresh = refresh;
|
||||
screen.cleanup = () => {
|
||||
if (disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
disposed = true;
|
||||
refreshSeq += 1;
|
||||
clearContent();
|
||||
|
||||
@@ -18,8 +18,9 @@ import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
@@ -30,6 +31,7 @@ const DIARY_DISPLAY_NAME = 'Дневник';
|
||||
const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||
const listQueries = new Map();
|
||||
|
||||
function channelMenuIcon(name) {
|
||||
const paths = {
|
||||
@@ -120,6 +122,7 @@ function createChannelAvatar(channel = {}) {
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'lg',
|
||||
className: 'avatar-plain',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
@@ -847,16 +850,20 @@ function toListModel(groups) {
|
||||
];
|
||||
}
|
||||
|
||||
function renderEmptyState() {
|
||||
function renderEmptyState(listState, navigate) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||
if (!state.session.isAuthorized) {
|
||||
return wrap;
|
||||
}
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = listState.query ? 'Ничего не найдено' : listState.viewMode === CHANNELS_VIEW_FOLLOWING ? 'Пока нет подписок' : listState.viewMode === CHANNELS_VIEW_OWNED ? 'Здесь будут ваши каналы' : 'Откройте свой первый канал';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = 'У вас пока нет доступных каналов.';
|
||||
wrap.append(text);
|
||||
text.textContent = state.session.isAuthorized ? 'Найдите канал по имени автора или создайте свой.' : 'Войдите, чтобы видеть свои каналы и подписки.';
|
||||
const action = document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.className = 'primary-btn';
|
||||
action.textContent = state.session.isAuthorized ? 'Найти по @автору' : 'Войти';
|
||||
action.addEventListener('click', () => state.session.isAuthorized ? openChannelFinderModal({ navigate }) : navigate('login-view'));
|
||||
wrap.append(heading, text, action);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
@@ -962,7 +969,7 @@ function renderChannelMain(channel) {
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
|
||||
technical.textContent = `@${channel.ownerName || 'автор'}`;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -976,7 +983,7 @@ function renderChannelMain(channel) {
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
|
||||
|
||||
previewLine.append(preview);
|
||||
|
||||
@@ -997,10 +1004,15 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
|
||||
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
|
||||
return true;
|
||||
}).filter((channel) => {
|
||||
const query = String(listState.query || '').trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return [channel.title, channel.ownerName, channel.channelName, channel.technicalLabel]
|
||||
.some((value) => String(value || '').toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
container.append(renderEmptyState());
|
||||
container.append(renderEmptyState(listState, navigate));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1010,10 +1022,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed });
|
||||
|
||||
filtered.forEach((channel) => {
|
||||
const row = document.createElement('article');
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'channel-row';
|
||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||
row.classList.toggle('is-counters-visible', countersVisible);
|
||||
row.classList.toggle('has-unread', Number(channel.unreadCount || 0) > 0);
|
||||
|
||||
const avatar = createChannelAvatar(channel);
|
||||
|
||||
@@ -1047,6 +1061,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
|
||||
if (listState.disposed) return;
|
||||
const seq = ++listState.loadSeq;
|
||||
if (!silent) renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
@@ -1072,6 +1088,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
if (listState.disposed || seq !== listState.loadSeq) return;
|
||||
|
||||
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
|
||||
// The server/API implementation is preserved so the feature can be restored later.
|
||||
@@ -1096,7 +1113,12 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
|
||||
navigate,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
});
|
||||
if (Number.isFinite(listState.restorePosition)) {
|
||||
restoreChannelPosition(listState.restorePosition);
|
||||
listState.restorePosition = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (listState.disposed || seq !== listState.loadSeq) return;
|
||||
if (silent) return;
|
||||
setChannelsFeed(null, {});
|
||||
contentEl.innerHTML = '';
|
||||
@@ -1118,52 +1140,81 @@ export function render({ navigate, route, chrome }) {
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const positionKey = `${state.session.login}:channels:${normalizeChannelsViewMode(route)}`;
|
||||
const listState = {
|
||||
restorePosition: readChannelPosition(positionKey),
|
||||
disposed: false,
|
||||
loadSeq: 0,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
query: listQueries.get(positionKey) || '',
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: topTitle,
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
],
|
||||
});
|
||||
|
||||
const topBarEl = createTopBar({
|
||||
center: topTitle,
|
||||
title: 'Каналы',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Ещё действия',
|
||||
className: 'channels-top-more-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
|
||||
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
|
||||
],
|
||||
},
|
||||
label: '+',
|
||||
title: 'Создать канал',
|
||||
ariaLabel: 'Создать канал',
|
||||
className: 'channels-create-btn',
|
||||
onClick: () => navigate('add-channel-view'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channels-list-controls';
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'channels-inline-search';
|
||||
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.value = listState.query;
|
||||
searchInput.placeholder = 'В вашем списке';
|
||||
searchInput.setAttribute('aria-label', 'Найти канал или автора');
|
||||
const serverSearchButton = document.createElement('button');
|
||||
serverSearchButton.type = 'button';
|
||||
serverSearchButton.className = 'text-btn channels-server-search';
|
||||
serverSearchButton.textContent = 'По @автору';
|
||||
serverSearchButton.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||
searchWrap.append(searchInput, serverSearchButton);
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs tabs--three';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
tabs.setAttribute('aria-label', 'Фильтр каналов');
|
||||
tabs.addEventListener('keydown', (event) => {
|
||||
const buttons = [...tabs.querySelectorAll('button')];
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
});
|
||||
[
|
||||
[CHANNELS_VIEW_ALL, 'Все'],
|
||||
[CHANNELS_VIEW_FOLLOWING, 'Подписки'],
|
||||
[CHANNELS_VIEW_OWNED, 'Мои'],
|
||||
].forEach(([mode, label]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'tab-btn';
|
||||
button.textContent = label;
|
||||
button.setAttribute('role', 'tab');
|
||||
const selected = listState.viewMode === mode;
|
||||
button.classList.toggle('active', selected);
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.addEventListener('click', () => navigate(buildChannelsViewRoute(mode)));
|
||||
tabs.append(button);
|
||||
});
|
||||
controls.append(searchWrap, tabs);
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
@@ -1192,13 +1243,16 @@ export function render({ navigate, route, chrome }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
|
||||
};
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
listState.restorePosition = undefined;
|
||||
listState.query = searchInput.value;
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl);
|
||||
screen.append(controls, contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
@@ -1210,9 +1264,12 @@ export function render({ navigate, route, chrome }) {
|
||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
screen.cleanup = () => {
|
||||
if (listState.disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
listQueries.set(positionKey, listState.query);
|
||||
listState.disposed = true;
|
||||
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||
unsubscribeCountersChanged();
|
||||
channelsFilterMenu.destroy();
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
|
||||
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
@@ -57,6 +58,15 @@ export function render({navigate, chrome}) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<div class="stack">
|
||||
<label for="settings-theme"><strong>Оформление</strong></label>
|
||||
<span class="meta-muted">Тема меняет только цвета и не перезагружает экран.</span>
|
||||
<select class="select" id="settings-theme">
|
||||
<option value="system">Как на устройстве</option>
|
||||
<option value="light">Дневное</option>
|
||||
<option value="dark">Ночное</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
|
||||
@@ -81,6 +91,10 @@ export function render({navigate, chrome}) {
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
|
||||
const themeSelect = card.querySelector('#settings-theme');
|
||||
themeSelect.value = getThemeMode();
|
||||
themeSelect.addEventListener('change', () => setThemeMode(themeSelect.value));
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Состояние чтения живёт только в текущей вкладке и разделено по аккаунтам.
|
||||
const positions = new Map();
|
||||
export function rememberChannelPosition(key) {
|
||||
positions.set(key, document.getElementById('app-screen')?.scrollTop || 0);
|
||||
}
|
||||
export function readChannelPosition(key) { return positions.get(key); }
|
||||
export function restoreChannelPosition(value) {
|
||||
const root = document.getElementById('app-screen');
|
||||
if (root && Number.isFinite(value)) root.scrollTop = value;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const STORAGE_KEY = 'shine-ui-theme-mode-v1';
|
||||
const MODES = new Set(['system', 'light', 'dark']);
|
||||
let sessionMode = null;
|
||||
|
||||
function normalizeMode(value) {
|
||||
const mode = String(value || '').trim().toLowerCase();
|
||||
return MODES.has(mode) ? mode : 'system';
|
||||
}
|
||||
|
||||
export function getThemeMode() {
|
||||
if (sessionMode !== null) return sessionMode;
|
||||
try {
|
||||
return normalizeMode(localStorage.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
export function applyThemeMode(mode = getThemeMode()) {
|
||||
const normalized = normalizeMode(mode);
|
||||
const resolved = normalized === 'system'
|
||||
? (window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark')
|
||||
: normalized;
|
||||
document.documentElement.dataset.themeMode = normalized;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
return { mode: normalized, resolved };
|
||||
}
|
||||
|
||||
export function setThemeMode(mode) {
|
||||
const normalized = normalizeMode(mode);
|
||||
sessionMode = normalized;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, normalized);
|
||||
} catch {
|
||||
// В приватном режиме тема всё равно применяется до закрытия страницы.
|
||||
}
|
||||
return applyThemeMode(normalized);
|
||||
}
|
||||
|
||||
export function watchSystemTheme() {
|
||||
const media = window.matchMedia?.('(prefers-color-scheme: light)');
|
||||
if (!media) return () => {};
|
||||
const onChange = () => {
|
||||
if (getThemeMode() === 'system') applyThemeMode('system');
|
||||
};
|
||||
media.addEventListener?.('change', onChange);
|
||||
return () => media.removeEventListener?.('change', onChange);
|
||||
}
|
||||
Reference in New Issue
Block a user