Каналы: добавить профиль через TEXT_CHANNEL_META

This commit is contained in:
AidarKC
2026-08-01 01:57:41 +04:00
parent 24afcba20a
commit 110ff5e12e
31 changed files with 1312 additions and 191 deletions
+80 -16
View File
@@ -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');
+233 -14
View File
@@ -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();
+21 -4
View File
@@ -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);