SHA256
Добавил гостевой режим, единые shine-ссылки и пометку о нестабильности мнений
This commit is contained in:
@@ -18,12 +18,71 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import {
|
||||
extractLoginFromBlockchainName,
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
const pendingScrollByRoute = new Map();
|
||||
const messageAvatarSnapshotCache = new Map();
|
||||
const messageAvatarPendingByLogin = new Map();
|
||||
|
||||
async function loadMessageAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key);
|
||||
if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key);
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
messageAvatarSnapshotCache.set(key, snapshot || null);
|
||||
messageAvatarPendingByLogin.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
messageAvatarSnapshotCache.set(key, null);
|
||||
messageAvatarPendingByLogin.delete(key);
|
||||
return null;
|
||||
});
|
||||
messageAvatarPendingByLogin.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createMessageAvatar(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||
if (!avatarEl.isConnected) return;
|
||||
const upgraded = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
avatar: snapshot?.avatar?.txId
|
||||
? {
|
||||
ar: String(snapshot.avatar.txId || '').trim(),
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
avatarEl.replaceWith(upgraded);
|
||||
});
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
@@ -61,13 +120,6 @@ function looksLikeBlockchainName(value) {
|
||||
return /^[^-]+-\d+$/.test(raw);
|
||||
}
|
||||
|
||||
function extractLoginFromBlockchainName(value) {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^(.+)-\d+$/);
|
||||
if (!match) return '';
|
||||
return String(match[1] || '').trim();
|
||||
}
|
||||
|
||||
function makeReactionActionKey(messageRef) {
|
||||
const login = String(state.session.login || '').trim().toLowerCase();
|
||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||
@@ -148,11 +200,12 @@ function buildSelectorFromRoute(route, channelId) {
|
||||
|
||||
function buildThreadRoute(messageRef, selector) {
|
||||
if (!messageRef || !selector) return '';
|
||||
return [
|
||||
'm',
|
||||
encodeRoutePart(messageRef.blockchainName),
|
||||
messageRef.blockNumber,
|
||||
].join('/');
|
||||
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
|
||||
return makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: messageRef.blockchainName,
|
||||
messageBlockNumber: messageRef.blockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
@@ -497,10 +550,16 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
}
|
||||
|
||||
async function loadFromApi(route, channelId) {
|
||||
const currentSessionLogin = String(state.session.login || '').trim();
|
||||
const isAuthorized = !!currentSessionLogin;
|
||||
let cachedFeed = null;
|
||||
const ensureFeed = async () => {
|
||||
if (cachedFeed) return cachedFeed;
|
||||
cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
||||
if (!isAuthorized) {
|
||||
cachedFeed = {};
|
||||
return cachedFeed;
|
||||
}
|
||||
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
|
||||
return cachedFeed;
|
||||
};
|
||||
const getAllRows = async () => {
|
||||
@@ -517,34 +576,39 @@ async function loadFromApi(route, channelId) {
|
||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
||||
const allRows = await getAllRows();
|
||||
let channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
if (!channel) {
|
||||
let channel = null;
|
||||
if (isAuthorized) {
|
||||
const allRows = await getAllRows();
|
||||
channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
}
|
||||
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
|
||||
try {
|
||||
const ownerUser = await authService.getUser(routeOwnerRaw);
|
||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||
if (ownerBch) {
|
||||
channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
if (!channel) {
|
||||
channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
}
|
||||
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
|
||||
try {
|
||||
const ownerUser = await authService.getUser(routeOwnerRaw);
|
||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||
if (ownerBch) {
|
||||
channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
}
|
||||
} catch {
|
||||
// ignore fallback lookup failures
|
||||
}
|
||||
} catch {
|
||||
// ignore fallback lookup failures
|
||||
}
|
||||
}
|
||||
if (!channel && routeOwnerLoginFromBch) {
|
||||
if (!channel) {
|
||||
const ownerLoginForLookup = routeOwnerLoginFromBch || (!looksLikeBlockchainName(routeOwnerRaw) ? routeOwnerRaw : '');
|
||||
if (ownerLoginForLookup) {
|
||||
try {
|
||||
const ownerFeed = await authService.listSubscriptionsFeed(routeOwnerLoginFromBch, 500);
|
||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
|
||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||||
channel = ownerRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||
@@ -554,6 +618,7 @@ async function loadFromApi(route, channelId) {
|
||||
// ignore owner feed lookup failures
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
||||
throw new Error('Канал не найден.');
|
||||
}
|
||||
@@ -569,12 +634,12 @@ async function loadFromApi(route, channelId) {
|
||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||
}
|
||||
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
let reverseChannelMissingWarning = '';
|
||||
let mergedMessages = [...messages];
|
||||
|
||||
const currentLogin = String(state.session.login || '').trim();
|
||||
const currentLogin = currentSessionLogin;
|
||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||
const channelName = String(payload.channel?.channelName || '').trim();
|
||||
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
|
||||
@@ -600,7 +665,7 @@ async function loadFromApi(route, channelId) {
|
||||
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
||||
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
||||
};
|
||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login);
|
||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
|
||||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||||
mergedMessages = mergedMessages.concat(reverseMessages);
|
||||
} else {
|
||||
@@ -618,9 +683,9 @@ async function loadFromApi(route, channelId) {
|
||||
return aNum - bNum;
|
||||
})
|
||||
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
||||
const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase();
|
||||
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase();
|
||||
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
|
||||
const isSubscribed = followedRows.some((row) => (
|
||||
const isSubscribed = isAuthorized && followedRows.some((row) => (
|
||||
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
|
||||
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
|
||||
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
|
||||
@@ -682,17 +747,31 @@ function renderDemoFallback(screen, navigate, error) {
|
||||
screen.append(back);
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey) {
|
||||
function scrollChannelToBottom(screen, smooth = true) {
|
||||
const feed = screen.querySelector('.channel-feed');
|
||||
if (feed) {
|
||||
feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
||||
}
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
if (appScreen) {
|
||||
appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
||||
return;
|
||||
}
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
||||
const target = pendingScrollByRoute.get(routeKey);
|
||||
if (!target) return;
|
||||
if (!target && !forceBottom) return;
|
||||
|
||||
const doScroll = () => {
|
||||
if (!target && forceBottom) {
|
||||
scrollChannelToBottom(screen, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === '__LAST__') {
|
||||
const cards = screen.querySelectorAll('[data-message-key]');
|
||||
const last = cards[cards.length - 1];
|
||||
if (last) {
|
||||
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
scrollChannelToBottom(screen, true);
|
||||
pendingScrollByRoute.delete(routeKey);
|
||||
return;
|
||||
}
|
||||
@@ -724,9 +803,7 @@ function renderPostCard(post, {
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'channel-message-avatar';
|
||||
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
|
||||
const avatar = createMessageAvatar(post.authorLogin);
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
@@ -768,7 +845,7 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
const cleanLogin = String(post.authorLogin || '').trim();
|
||||
if (!cleanLogin) return;
|
||||
navigate(`user/${encodeRoutePart(cleanLogin)}`);
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
@@ -888,10 +965,8 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
}
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = channelData.isOwnChannel
|
||||
? 'primary-btn channel-main-action'
|
||||
: 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
|
||||
actionButton.className = 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = 'Подписаться на канал';
|
||||
|
||||
const feed = document.createElement('div');
|
||||
feed.className = 'stack channel-feed';
|
||||
@@ -921,16 +996,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
}
|
||||
|
||||
|
||||
if (channelData.isOwnChannel) {
|
||||
actionButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAddMessageModal({
|
||||
channelName: channelData.channel.name,
|
||||
navigate,
|
||||
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
|
||||
});
|
||||
});
|
||||
} else if (!channelData.isSubscribed) {
|
||||
if (!channelData.isSubscribed) {
|
||||
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
||||
}
|
||||
|
||||
@@ -939,13 +1005,15 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
backButton.textContent = 'Назад к каналам';
|
||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
||||
if (channelData.isOwnChannel) {
|
||||
screen.append(feed);
|
||||
} else if (!channelData.isSubscribed) {
|
||||
screen.append(actionButton, feed, backButton);
|
||||
} else {
|
||||
screen.append(feed, backButton);
|
||||
}
|
||||
|
||||
applyPendingScroll(screen, routeKey);
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
@@ -1121,14 +1189,40 @@ export function render({ navigate, route }) {
|
||||
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'}`;
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = channelRouteLabel;
|
||||
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(apiData.channel);
|
||||
};
|
||||
}
|
||||
if (apiData?.isOwnChannel) {
|
||||
const headerActions = header.querySelector('.header-actions');
|
||||
if (headerActions) {
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'icon-btn channel-header-add-btn';
|
||||
addBtn.textContent = 'Добавить сообщение';
|
||||
addBtn.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
onSubmit: async (bodyText) => {
|
||||
try {
|
||||
await onAddPost(bodyText);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
headerActions.append(addBtn);
|
||||
}
|
||||
}
|
||||
skeleton.remove();
|
||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||
onToggleLike: async (messageRef, action) => {
|
||||
|
||||
Reference in New Issue
Block a user