SHA256
Каналы: добавить профиль через TEXT_CHANNEL_META
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user