SHA256
Каналы: добавить профиль через TEXT_CHANNEL_META
This commit is contained in:
@@ -16,8 +16,10 @@ const RECENT_UPLOAD_MS = 20 * 60 * 1000;
|
||||
function normalizeHistoryItem(input = {}) {
|
||||
const item = normalizeAttachment(input);
|
||||
const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0;
|
||||
const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment';
|
||||
return {
|
||||
...item,
|
||||
purpose,
|
||||
pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs,
|
||||
placedInShineAtMs,
|
||||
};
|
||||
@@ -133,6 +135,36 @@ function shortAddress(value) {
|
||||
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
||||
}
|
||||
|
||||
async function resizeImageFileToAvatar(file) {
|
||||
const source = file instanceof File ? file : null;
|
||||
if (!source) throw new Error('Выберите изображение.');
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('Не удалось прочитать изображение.'));
|
||||
reader.readAsDataURL(source);
|
||||
});
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Не удалось открыть изображение.'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height);
|
||||
if (!side || side <= 0) throw new Error('Некорректный размер изображения.');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas недоступен.');
|
||||
const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2);
|
||||
const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2);
|
||||
ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9));
|
||||
if (!blob) throw new Error('Не удалось сжать аватар.');
|
||||
return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() });
|
||||
}
|
||||
|
||||
export function openArweaveAttachmentManager({
|
||||
login,
|
||||
storagePwd,
|
||||
@@ -140,6 +172,8 @@ export function openArweaveAttachmentManager({
|
||||
onSelect,
|
||||
selectedTxIds = [],
|
||||
historyOnly = false,
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
} = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
@@ -158,6 +192,8 @@ export function openArweaveAttachmentManager({
|
||||
let priceInfo = null;
|
||||
let balanceInfo = null;
|
||||
let autoOpenedFileDialog = false;
|
||||
const isAvatarMode = String(mode || '') === 'avatar';
|
||||
const purposeFilter = isAvatarMode || String(historyPurpose || '').trim() === 'avatar' ? 'avatar' : '';
|
||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||
|
||||
function selectedWallet() {
|
||||
@@ -207,18 +243,18 @@ export function openArweaveAttachmentManager({
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'}</h3>
|
||||
<h3 class="modal-title">${isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')}</h3>
|
||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="input" id="ar-attach-wallet"></select>
|
||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||
${historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>'}
|
||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
||||
<input class="input" id="ar-attach-file" type="file" />
|
||||
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
<div class="ar-attachment-meta" data-meta="true"></div>
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${historyOnly ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
|
||||
${historyOnly ? '' : '<button class="secondary-btn" type="button" data-action="history">История загрузок</button>'}
|
||||
${historyOnly || isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
|
||||
${historyOnly ? '' : `<button class="secondary-btn" type="button" data-action="history">${isAvatarMode ? 'История аватаров' : 'История загрузок'}</button>`}
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
@@ -266,13 +302,19 @@ export function openArweaveAttachmentManager({
|
||||
try {
|
||||
const wallet = selectedWallet();
|
||||
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
|
||||
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
|
||||
throw new Error('Для аватара можно выбрать только изображение.');
|
||||
}
|
||||
if (isAvatarMode) {
|
||||
selectedFile = await resizeImageFileToAvatar(selectedFile);
|
||||
}
|
||||
const buffer = await selectedFile.arrayBuffer();
|
||||
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
|
||||
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
|
||||
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
|
||||
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
|
||||
metaEl.innerHTML = `
|
||||
<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>
|
||||
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>`}
|
||||
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
|
||||
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
||||
@@ -305,19 +347,20 @@ export function openArweaveAttachmentManager({
|
||||
gateway: cleanGateway,
|
||||
jwk: wallet.jwk,
|
||||
file: selectedFile,
|
||||
shineType: 'attachment',
|
||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: 'SHiNE-Attachment-Name', value: selectedFile.name || 'file' },
|
||||
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||
],
|
||||
});
|
||||
finish(resolve, {
|
||||
name: selectedFile.name || 'file',
|
||||
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
|
||||
size: selectedFile.size,
|
||||
sha256: selectedSha256,
|
||||
ar: uploaded.id,
|
||||
uploadedAtMs: Date.now(),
|
||||
}, { pendingPlacement: historyOnly });
|
||||
purpose: isAvatarMode ? 'avatar' : 'attachment',
|
||||
}, { pendingPlacement: historyOnly && !isAvatarMode });
|
||||
} catch (error) {
|
||||
uploadBtn.disabled = false;
|
||||
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
|
||||
@@ -394,7 +437,10 @@ export function openArweaveAttachmentManager({
|
||||
};
|
||||
|
||||
const showHistory = () => {
|
||||
const rows = readArweaveAttachmentHistory(cleanLogin).slice().reverse();
|
||||
const rows = readArweaveAttachmentHistory(cleanLogin)
|
||||
.filter((item) => !purposeFilter || item.purpose === purposeFilter)
|
||||
.slice()
|
||||
.reverse();
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card ar-attachment-manager-card--wide">
|
||||
@@ -405,7 +451,7 @@ export function openArweaveAttachmentManager({
|
||||
const isSelected = selectedTxIdSet.has(item.ar);
|
||||
return `
|
||||
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
|
||||
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}</strong>
|
||||
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}${item.purpose === 'avatar' ? ' · аватар' : ''}</strong>
|
||||
<div class="ar-attachment-history-meta-row">
|
||||
<span>${escapeHtml(formatBytes(item.size))}</span>
|
||||
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
normalizeChannelDescription,
|
||||
normalizeChannelDisplayName,
|
||||
validateChannelDisplayName,
|
||||
} from '../services/channel-name-rules.js';
|
||||
import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
||||
|
||||
@@ -21,13 +22,30 @@ function persistCreateSuccessFlash(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescription(value) {
|
||||
const normalized = normalizeChannelDescription(value);
|
||||
const bytes = new TextEncoder().encode(normalized).length;
|
||||
if (bytes > 200) {
|
||||
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
|
||||
function normalizeMetaText(value, max, label) {
|
||||
const normalized = String(value || '').trim();
|
||||
const length = Array.from(normalized).length;
|
||||
if (length > max) {
|
||||
return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` };
|
||||
}
|
||||
return { ok: true, normalized, bytes, error: '' };
|
||||
return { ok: true, normalized, length, error: '' };
|
||||
}
|
||||
|
||||
function renderAvatarPreview(slot, avatar, title) {
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', '82px');
|
||||
if (avatar?.ar) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
@@ -50,13 +68,21 @@ export function render({ navigate }) {
|
||||
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
|
||||
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
|
||||
|
||||
<label for="channel-name">Название канала</label>
|
||||
<label for="channel-name">Техническое имя канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<label for="channel-title">Человекочитаемое имя</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
|
||||
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
|
||||
<div id="channel-title-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<div id="channel-avatar-preview"></div>
|
||||
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
|
||||
|
||||
<label for="channel-description">Описание канала (необязательно)</label>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
|
||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
@@ -67,8 +93,13 @@ export function render({ navigate }) {
|
||||
`;
|
||||
|
||||
const nameEl = form.querySelector('#channel-name');
|
||||
const titleEl = form.querySelector('#channel-title');
|
||||
const descriptionEl = form.querySelector('#channel-description');
|
||||
const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
|
||||
const avatarBtn = form.querySelector('#channel-avatar-btn');
|
||||
const nameErrorEl = form.querySelector('#channel-name-error');
|
||||
const titleErrorEl = form.querySelector('#channel-title-error');
|
||||
const titleCounterEl = form.querySelector('#channel-title-counter');
|
||||
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
||||
const descriptionCounterEl = form.querySelector('#channel-description-counter');
|
||||
const errorEl = form.querySelector('#channel-create-error');
|
||||
@@ -76,38 +107,60 @@ export function render({ navigate }) {
|
||||
const cancelEl = form.querySelector('#cancel-create-channel');
|
||||
|
||||
let submitInFlight = false;
|
||||
let selectedAvatar = null;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
submitInFlight = !!busy;
|
||||
submitEl.disabled = submitInFlight;
|
||||
cancelEl.disabled = submitInFlight;
|
||||
nameEl.disabled = submitInFlight;
|
||||
titleEl.disabled = submitInFlight;
|
||||
descriptionEl.disabled = submitInFlight;
|
||||
avatarBtn.disabled = submitInFlight;
|
||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||
};
|
||||
|
||||
const updateValidation = () => {
|
||||
const nameCheck = validateChannelDisplayName(nameEl.value);
|
||||
const descriptionCheck = validateDescription(descriptionEl.value);
|
||||
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
|
||||
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
|
||||
|
||||
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
|
||||
titleErrorEl.textContent = titleCheck.error;
|
||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||
|
||||
const descLength = Number(descriptionCheck.bytes || 0);
|
||||
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
|
||||
|
||||
const ok = nameCheck.ok && descriptionCheck.ok;
|
||||
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
|
||||
submitEl.disabled = submitInFlight || !ok;
|
||||
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized);
|
||||
|
||||
return {
|
||||
ok,
|
||||
name: nameCheck.normalized,
|
||||
title: titleCheck.normalized,
|
||||
description: descriptionCheck.normalized,
|
||||
};
|
||||
};
|
||||
|
||||
nameEl.addEventListener('input', updateValidation);
|
||||
titleEl.addEventListener('input', updateValidation);
|
||||
descriptionEl.addEventListener('input', updateValidation);
|
||||
avatarBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
selectedAvatar = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
mode: 'avatar',
|
||||
historyPurpose: 'avatar',
|
||||
});
|
||||
updateValidation();
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -127,14 +180,25 @@ export function render({ navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await authService.addBlockCreateChannel({
|
||||
const createResult = await authService.addBlockCreateChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
channelName: normalizeChannelDisplayName(check.name),
|
||||
channelDescription: normalizeChannelDescription(check.description),
|
||||
channelDescription: '',
|
||||
channelType: CHANNEL_TYPE_PUBLIC,
|
||||
channelTypeVersion: 1,
|
||||
});
|
||||
if (check.title || check.description || selectedAvatar?.ar) {
|
||||
await authService.addBlockChannelMeta({
|
||||
login,
|
||||
storagePwd,
|
||||
channel: createResult.channel,
|
||||
title: check.title,
|
||||
description: check.description,
|
||||
avatar: selectedAvatar,
|
||||
});
|
||||
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
|
||||
}
|
||||
|
||||
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||
navigate('channels-list');
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
@@ -286,22 +287,144 @@ function resolveMessageTimestampMs(message) {
|
||||
);
|
||||
}
|
||||
|
||||
function openAboutChannelModal(channel) {
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
if (txId) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="about-channel-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">О канале</h3>
|
||||
<p><strong>${channel.displayName || channel.name}</strong></p>
|
||||
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
|
||||
<div class="modal-card stack channel-profile-card">
|
||||
<div class="channel-profile-modal-head">
|
||||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||
${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''}
|
||||
</div>
|
||||
<div id="about-channel-avatar-slot"></div>
|
||||
<p><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name)}</strong></p>
|
||||
<p class="meta-muted">${escapeHtml(`${channel.ownerName || 'автор'} / ${channel.name || 'channel'}`)}</p>
|
||||
<p class="meta-muted">${escapeHtml(channel.description || 'Описание не задано.')}</p>
|
||||
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86));
|
||||
|
||||
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#about-channel-edit')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
if (typeof onEdit === 'function') onEdit();
|
||||
});
|
||||
}
|
||||
|
||||
function openAboutChannelModal(channel, options = {}) {
|
||||
openChannelMetaDetailsModal({
|
||||
title: 'О канале',
|
||||
channel,
|
||||
canEdit: options.canEdit === true,
|
||||
onEdit: options.onEdit,
|
||||
});
|
||||
}
|
||||
|
||||
function openEditChannelModal({ channel, onSave }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
let selectedAvatar = channel?.avaAr ? {
|
||||
name: 'Аватар',
|
||||
ar: channel.avaAr,
|
||||
sha256: channel.avaSha256,
|
||||
size: channel.avaSize,
|
||||
purpose: 'avatar',
|
||||
} : null;
|
||||
|
||||
const renderAvatarPreview = () => {
|
||||
const slot = root.querySelector('#edit-channel-avatar-preview');
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
slot.append(createChannelAvatarElement({
|
||||
...channel,
|
||||
avaAr: selectedAvatar?.ar || '',
|
||||
displayTitle: root.querySelector('#edit-channel-title')?.value || channel.displayTitle,
|
||||
}, 86));
|
||||
};
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="edit-channel-modal">
|
||||
<div class="modal-card stack channel-profile-card">
|
||||
<h3 class="modal-title">Изменение канала</h3>
|
||||
<div id="edit-channel-avatar-preview"></div>
|
||||
<button class="secondary-btn" id="edit-channel-avatar" type="button">Выбрать аватар</button>
|
||||
<label class="meta-muted" for="edit-channel-title">Человекочитаемое имя</label>
|
||||
<input class="input" id="edit-channel-title" type="text" maxlength="50" value="${escapeHtml(channel.displayTitle || channel.name || '')}" />
|
||||
<label class="meta-muted" for="edit-channel-description">Описание</label>
|
||||
<textarea class="input" id="edit-channel-description" rows="4" maxlength="250">${escapeHtml(channel.description || '')}</textarea>
|
||||
<p class="meta-muted inline-error" id="edit-channel-error"></p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="edit-channel-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="edit-channel-save" type="button">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
renderAvatarPreview();
|
||||
|
||||
root.querySelector('#edit-channel-cancel')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#edit-channel-title')?.addEventListener('input', renderAvatarPreview);
|
||||
root.querySelector('#edit-channel-avatar')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
mode: 'avatar',
|
||||
historyPurpose: 'avatar',
|
||||
});
|
||||
if (!item) return;
|
||||
selectedAvatar = item;
|
||||
renderAvatarPreview();
|
||||
} catch (error) {
|
||||
const errorEl = root.querySelector('#edit-channel-error');
|
||||
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
|
||||
}
|
||||
});
|
||||
root.querySelector('#edit-channel-save')?.addEventListener('click', async () => {
|
||||
const saveBtn = root.querySelector('#edit-channel-save');
|
||||
const errorEl = root.querySelector('#edit-channel-error');
|
||||
const title = String(root.querySelector('#edit-channel-title')?.value || '').trim();
|
||||
const description = String(root.querySelector('#edit-channel-description')?.value || '').trim();
|
||||
if (Array.from(title).length > 50) {
|
||||
if (errorEl) errorEl.textContent = 'Название: максимум 50 символов.';
|
||||
return;
|
||||
}
|
||||
if (Array.from(description).length > 250) {
|
||||
if (errorEl) errorEl.textContent = 'Описание: максимум 250 символов.';
|
||||
return;
|
||||
}
|
||||
saveBtn.disabled = true;
|
||||
if (errorEl) errorEl.textContent = '';
|
||||
try {
|
||||
await onSave({ title, description, avatar: selectedAvatar });
|
||||
root.innerHTML = '';
|
||||
} catch (error) {
|
||||
saveBtn.disabled = false;
|
||||
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось изменить канал.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindSubmitOnPlainEnter(textarea, submit) {
|
||||
@@ -941,11 +1064,17 @@ async function loadFromApi(route, channelId) {
|
||||
return {
|
||||
channel: {
|
||||
name: payload.channel?.channelName || 'неизвестный канал',
|
||||
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||
description: String(payload.channel?.channelDescription || '').trim(),
|
||||
avaAr: String(payload.channel?.avaAr || '').trim(),
|
||||
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
|
||||
avaSize: Number(payload.channel?.avaSize || 0),
|
||||
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
|
||||
ownerName: ownerLogin || 'неизвестно',
|
||||
},
|
||||
posts,
|
||||
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||
reverseChannelMissingWarning,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
@@ -1033,6 +1162,48 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
||||
setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
return {
|
||||
kind: String(event?.kind || '').trim(),
|
||||
messageRef: event?.messageRef || null,
|
||||
timestampMs: Number(event?.createdAtMs || 0),
|
||||
title: String(event?.title || fallbackChannel?.displayTitle || fallbackChannel?.name || '').trim(),
|
||||
description: String(event?.description || '').trim(),
|
||||
avaAr: String(event?.avaAr || '').trim(),
|
||||
avaSha256: String(event?.avaSha256 || '').trim(),
|
||||
avaSize: Number(event?.avaSize || 0),
|
||||
ownerName: fallbackChannel?.ownerName || '',
|
||||
name: fallbackChannel?.name || '',
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMetaEventCard(event) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? `Создан канал ${event.title || event.name || ''}`.trim()
|
||||
: 'Изменён профиль канала';
|
||||
card.innerHTML = `
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<small>${escapeHtml(event.timestampMs ? new Date(event.timestampMs).toLocaleString('ru-RU') : '')}</small>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён',
|
||||
channel: {
|
||||
name: event.name,
|
||||
displayTitle: event.title,
|
||||
description: event.description,
|
||||
avaAr: event.avaAr,
|
||||
avaSha256: event.avaSha256,
|
||||
avaSize: event.avaSize,
|
||||
ownerName: event.ownerName,
|
||||
},
|
||||
});
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderPostCard(post, {
|
||||
navigate,
|
||||
selector,
|
||||
@@ -1303,10 +1474,34 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
const feed = document.createElement('div');
|
||||
feed.className = 'stack channel-feed';
|
||||
const postsByKey = new Map();
|
||||
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||
const feedItems = [
|
||||
...metaEvents.map((event) => ({
|
||||
type: 'meta',
|
||||
timestampMs: event.timestampMs,
|
||||
blockNumber: Number(event?.messageRef?.blockNumber || 0),
|
||||
event,
|
||||
})),
|
||||
...channelData.posts.map((post) => ({
|
||||
type: 'post',
|
||||
timestampMs: post.timestampMs,
|
||||
blockNumber: Number(post?.messageRef?.blockNumber || 0),
|
||||
post,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
const byTime = Number(a.timestampMs || 0) - Number(b.timestampMs || 0);
|
||||
if (byTime !== 0) return byTime;
|
||||
return Number(a.blockNumber || 0) - Number(b.blockNumber || 0);
|
||||
});
|
||||
|
||||
if (channelData.posts.length) {
|
||||
channelData.posts.forEach((post) => {
|
||||
const row = renderPostCard(post, {
|
||||
if (feedItems.length) {
|
||||
feedItems.forEach((item) => {
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
return;
|
||||
}
|
||||
const row = renderPostCard(item.post, {
|
||||
navigate,
|
||||
selector: channelData.selector,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
@@ -1315,9 +1510,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
});
|
||||
const key = messageRefKey(post.messageRef);
|
||||
const key = messageRefKey(item.post.messageRef);
|
||||
if (key) {
|
||||
postsByKey.set(key, post);
|
||||
postsByKey.set(key, item.post);
|
||||
}
|
||||
feed.append(row);
|
||||
});
|
||||
@@ -1565,6 +1760,25 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
const onEditChannelMeta = async ({ title, description, avatar }) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
}
|
||||
await authService.addBlockChannelMeta({
|
||||
login,
|
||||
storagePwd,
|
||||
channel: activeSelector,
|
||||
title,
|
||||
description,
|
||||
avatar,
|
||||
});
|
||||
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
|
||||
softHaptic(12);
|
||||
showToast('Профиль канала обновлён');
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(header);
|
||||
screen.append(statusBox);
|
||||
|
||||
@@ -1576,14 +1790,19 @@ export function render({ navigate, route }) {
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
activeSelector = apiData?.selector || null;
|
||||
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'} / ${apiData?.channel?.name || 'channel'}`;
|
||||
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
|
||||
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
|
||||
channelHeaderButton.textContent = titleLabel;
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(apiData.channel);
|
||||
openAboutChannelModal(apiData.channel, {
|
||||
canEdit: apiData?.isOwnChannel === true,
|
||||
onEdit: () => openEditChannelModal({
|
||||
channel: apiData.channel,
|
||||
onSave: onEditChannelMeta,
|
||||
}),
|
||||
});
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
@@ -652,11 +653,13 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
|
||||
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
|
||||
const channelName = summary?.channel?.channelName || '(без названия)';
|
||||
const displayTitle = String(summary?.channel?.displayName || channelName).trim();
|
||||
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
|
||||
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||
const isOwn = bucketKey === 'own';
|
||||
const title = `${ownerLogin} / ${channelName}`;
|
||||
const title = displayTitle || channelName;
|
||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
@@ -665,9 +668,12 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||||
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
|
||||
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
|
||||
avatar: avatarLetterFromName(channelName),
|
||||
avatar: avatarLetterFromName(displayTitle || channelName),
|
||||
avaAr: String(summary?.channel?.avaAr || '').trim(),
|
||||
title,
|
||||
technicalLabel,
|
||||
channelName,
|
||||
displayTitle,
|
||||
channelDescription,
|
||||
channelTypeCode,
|
||||
channelTypeVersion,
|
||||
@@ -968,6 +974,10 @@ function renderChannelMain(channel) {
|
||||
title.className = 'channel-row-title';
|
||||
title.textContent = channel.title;
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'channel-row-description';
|
||||
@@ -983,7 +993,7 @@ function renderChannelMain(channel) {
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title);
|
||||
main.prepend(title, technical);
|
||||
main.append(preview, meta);
|
||||
return main;
|
||||
}
|
||||
@@ -1012,7 +1022,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
avatar.textContent = channel.avatar;
|
||||
if (channel.avaAr) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
||||
avatar.append(img);
|
||||
} else {
|
||||
avatar.textContent = channel.avatar;
|
||||
}
|
||||
|
||||
const main = renderChannelMain(channel);
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||
const MSG_SUBTYPE_TEXT_REPOST = 30;
|
||||
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
@@ -698,6 +699,39 @@ function normalizeChannelDescription(value) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function validateChannelMetaTitle(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
|
||||
if (/[<>;\t\n\r\u0000]/u.test(text)) {
|
||||
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeChannelMetaDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim();
|
||||
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
|
||||
const cleanTitle = validateChannelMetaTitle(title);
|
||||
const cleanDescription = normalizeChannelMetaDescription(description);
|
||||
const rows = [];
|
||||
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
|
||||
if (avatar?.ar) {
|
||||
const size = Number(avatar.size || 0);
|
||||
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
||||
const ar = String(avatar.ar || '').trim();
|
||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
||||
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
||||
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
|
||||
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
|
||||
}
|
||||
if (cleanDescription) rows.push(cleanDescription);
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
function validatePersonalChannelName(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
||||
@@ -806,6 +840,23 @@ function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, this
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || '').trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber);
|
||||
@@ -2141,6 +2192,67 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async resolveChannelLineTail({ login, blockchainName, channel }) {
|
||||
const selector = channel || {};
|
||||
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
|
||||
const lineCode = Number(selector?.channelRootBlockNumber);
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', login);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
|
||||
.slice()
|
||||
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
|
||||
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
|
||||
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
|
||||
? latestMeta
|
||||
: latestMessage;
|
||||
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
|
||||
const latestLineStep = resolveLatestLineStep(latest);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
thisLineNumber = Number.isFinite(latestLineStep)
|
||||
? Math.max(0, latestLineStep + 1)
|
||||
: Math.max(1, latestBlockNumber === lineCode ? 1 : 0);
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
|
||||
return {
|
||||
ownerBlockchainName,
|
||||
lineCode,
|
||||
rootHashHex,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
@@ -2154,53 +2266,13 @@ export class AuthService {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
|
||||
const ownerBlockchainName = owner;
|
||||
const lineCode = root;
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', cleanLogin);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
||||
const latestLineStep = resolveLatestLineStep(latestMessage);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
// Для нового POST берём следующий шаг после последнего сообщения линии.
|
||||
thisLineNumber = Number.isFinite(latestLineStep)
|
||||
? Math.max(0, latestLineStep + 1)
|
||||
: 1;
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||
|
||||
const bodyBytes = makeTextPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
lineCode: tail.lineCode,
|
||||
prevLineNumber: tail.prevLineNumber,
|
||||
prevLineHashHex: tail.prevLineHashHex,
|
||||
thisLineNumber: tail.thisLineNumber,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
@@ -2216,9 +2288,50 @@ export class AuthService {
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
ownerBlockchainName: tail.ownerBlockchainName,
|
||||
channelRootBlockNumber: tail.lineCode,
|
||||
channelRootBlockHash: tail.rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
const selector = channel || {};
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const root = Number(selector?.channelRootBlockNumber);
|
||||
const metaText = composeChannelMetaText({ title, description, avatar });
|
||||
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
|
||||
lineCode: tail.lineCode,
|
||||
prevLineNumber: tail.prevLineNumber,
|
||||
prevLineHashHex: tail.prevLineHashHex,
|
||||
thisLineNumber: tail.thisLineNumber,
|
||||
text: metaText,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName: tail.ownerBlockchainName,
|
||||
channelRootBlockNumber: tail.lineCode,
|
||||
channelRootBlockHash: tail.rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3884,6 +3884,66 @@ textarea.input {
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.channel-system-event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 9px 11px;
|
||||
border-color: rgba(255, 210, 130, 0.22);
|
||||
background: rgba(89, 67, 31, 0.22);
|
||||
color: rgba(255, 235, 188, 0.92);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-system-event-card small {
|
||||
flex: 0 0 auto;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.channel-profile-card {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.channel-profile-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.channel-profile-edit-btn {
|
||||
width: 38px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.channel-profile-avatar {
|
||||
--channel-avatar-size: 72px;
|
||||
width: var(--channel-avatar-size);
|
||||
height: var(--channel-avatar-size);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, rgba(255, 214, 122, 0.22), rgba(56, 41, 22, 0.65));
|
||||
border: 1px solid rgba(255, 226, 155, 0.24);
|
||||
color: rgba(255, 236, 194, 0.95);
|
||||
font-size: calc(var(--channel-avatar-size) * 0.42);
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-profile-avatar img,
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.deleted-message-pill {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
@@ -4983,8 +5043,19 @@ textarea.input {
|
||||
|
||||
.channel-row-title {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.channel-row-technical {
|
||||
margin: -2px 0 0;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-row-message {
|
||||
|
||||
Reference in New Issue
Block a user