SHA256
Добавил гостевой режим, единые shine-ссылки и пометку о нестабильности мнений
This commit is contained in:
+26
-10
@@ -136,6 +136,16 @@ let pwaUpdateCheckAttempted = false;
|
||||
let uiVersionCheckInFlight = false;
|
||||
let uiVersionPeriodicIntervalId = null;
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
'entry-settings-view',
|
||||
'network-view',
|
||||
'channels-list',
|
||||
'channel-view',
|
||||
'channel-thread-view',
|
||||
'user',
|
||||
'contact-search-view',
|
||||
]);
|
||||
|
||||
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
||||
setClientErrorSentNotifier((payload) => {
|
||||
@@ -671,7 +681,7 @@ function renderApp() {
|
||||
const route = getRoute();
|
||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
|
||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
|
||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) {
|
||||
navigate('start-view');
|
||||
return;
|
||||
}
|
||||
@@ -1025,18 +1035,24 @@ async function init() {
|
||||
}
|
||||
});
|
||||
|
||||
await tryAutoLogin();
|
||||
await hydrateMessagesFromStore();
|
||||
startConnectionMonitor();
|
||||
startPeriodicUiVersionCheck();
|
||||
await ensureSessionRuntimeStarted();
|
||||
|
||||
// Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"),
|
||||
// а сетевые/авторизационные шаги выполняем фоном.
|
||||
if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
||||
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
renderApp();
|
||||
} else {
|
||||
renderApp();
|
||||
}
|
||||
renderApp();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await tryAutoLogin();
|
||||
await hydrateMessagesFromStore();
|
||||
startConnectionMonitor();
|
||||
startPeriodicUiVersionCheck();
|
||||
await ensureSessionRuntimeStarted();
|
||||
} finally {
|
||||
renderApp();
|
||||
}
|
||||
})();
|
||||
|
||||
window.addEventListener('popstate', renderApp);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
||||
@@ -27,6 +28,35 @@ function getTotalUnreadMessages() {
|
||||
return total;
|
||||
}
|
||||
|
||||
function navigateWithGuestRules(pageId, navigate) {
|
||||
if (state.session.isAuthorized) {
|
||||
navigate(pageId);
|
||||
return;
|
||||
}
|
||||
if (pageId === 'messages-list') {
|
||||
openAuthRequiredModal({
|
||||
title: 'Личные сообщения недоступны',
|
||||
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pageId === 'profile-view') {
|
||||
openAuthRequiredModal({
|
||||
title: 'Профиль недоступен',
|
||||
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pageId === 'notifications-view') {
|
||||
openAuthRequiredModal({
|
||||
title: 'Уведомления недоступны',
|
||||
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(pageId);
|
||||
}
|
||||
|
||||
export function renderToolbar(currentPageId, navigate) {
|
||||
const root = document.createElement('nav');
|
||||
root.className = 'toolbar';
|
||||
@@ -63,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
if (item.pageId === 'channels-list') {
|
||||
installChannelsHoldSwitcher(btn, navigate);
|
||||
} else {
|
||||
btn.addEventListener('click', () => navigate(item.pageId));
|
||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||
}
|
||||
root.append(btn);
|
||||
});
|
||||
@@ -156,3 +186,4 @@ function installChannelsHoldSwitcher(button, navigate) {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,66 @@ 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, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
const pendingThreadScroll = new Map();
|
||||
const threadAvatarSnapshotCache = new Map();
|
||||
const threadAvatarPendingByLogin = new Map();
|
||||
|
||||
async function loadThreadAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key);
|
||||
if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key);
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
threadAvatarSnapshotCache.set(key, snapshot || null);
|
||||
threadAvatarPendingByLogin.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
threadAvatarSnapshotCache.set(key, null);
|
||||
threadAvatarPendingByLogin.delete(key);
|
||||
return null;
|
||||
});
|
||||
threadAvatarPendingByLogin.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createThreadAvatar(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 loadThreadAvatarSnapshot(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 logThreadRuntimeError(stage, error, context = {}) {
|
||||
const message = String(error?.message || error || 'thread runtime error');
|
||||
@@ -55,13 +110,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();
|
||||
@@ -200,32 +248,31 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
|
||||
|
||||
function buildThreadRouteFromTarget(target, selector) {
|
||||
if (!target) return '';
|
||||
return [
|
||||
'm',
|
||||
encodeRoutePart(target.blockchainName),
|
||||
target.blockNumber,
|
||||
].join('/');
|
||||
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
||||
return makeShineMessageRoute({
|
||||
ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName),
|
||||
messageBlockchainName: target.blockchainName,
|
||||
messageBlockNumber: target.blockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
||||
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
||||
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||
return [
|
||||
'channel',
|
||||
encodeRoutePart(selector.short.ownerBlockchainName),
|
||||
encodeRoutePart(selector.short.channelName),
|
||||
].join('/');
|
||||
return makeShineChannelRoute({
|
||||
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
||||
ownerBlockchainName: ownerBch,
|
||||
channelName: selector.short.channelName,
|
||||
});
|
||||
}
|
||||
|
||||
const ownerBch = String(selector?.channel?.ownerBlockchainName || '').trim();
|
||||
const label = String(resolvedChannelLabel || '').trim();
|
||||
const slashIndex = label.indexOf('/');
|
||||
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
|
||||
if (!ownerBch || !channelName) return '';
|
||||
return [
|
||||
'channel',
|
||||
encodeRoutePart(ownerBch),
|
||||
encodeRoutePart(channelName),
|
||||
].join('/');
|
||||
return makeShineChannelRoute({
|
||||
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
||||
ownerBlockchainName: ownerBch,
|
||||
channelName,
|
||||
});
|
||||
}
|
||||
|
||||
function buildTargetFromNode(node) {
|
||||
@@ -443,9 +490,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'channel-message-avatar';
|
||||
avatar.textContent = String(author || 'A').trim().charAt(0).toUpperCase() || 'A';
|
||||
const avatar = createThreadAvatar(author);
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
@@ -591,7 +636,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
event.stopPropagation();
|
||||
const login = String(node?.authorLogin || '').trim();
|
||||
if (!login) return;
|
||||
handlers.navigate(`user/${encodeRoutePart(login)}`);
|
||||
handlers.navigate(makeProfileRoute(login));
|
||||
});
|
||||
card.addEventListener('click', () => {
|
||||
handlers.onOpenThread(target);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
softHaptic,
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
@@ -43,12 +44,14 @@ function normalizeLoginInput(value) {
|
||||
}
|
||||
|
||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
||||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||
if (ownerBch && channelName) {
|
||||
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`;
|
||||
}
|
||||
return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`;
|
||||
return makeShineChannelRoute({
|
||||
ownerLogin,
|
||||
ownerBlockchainName: ownerBch,
|
||||
channelName: channelName || fallbackId,
|
||||
});
|
||||
}
|
||||
|
||||
function avatarLetterFromName(name = '') {
|
||||
@@ -408,7 +411,7 @@ function openChannelFinderModal({ navigate }) {
|
||||
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
||||
<h3 class="modal-title">Поиск каналов</h3>
|
||||
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
|
||||
<input id="channels-find-input" class="input" placeholder="Например: aid" autocomplete="off" />
|
||||
<input id="channels-find-input" class="input" placeholder="Например: aidar" autocomplete="off" />
|
||||
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
||||
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
||||
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
||||
@@ -463,8 +466,12 @@ function openChannelFinderModal({ navigate }) {
|
||||
openBtn.textContent = 'Просмотреть';
|
||||
openBtn.addEventListener('click', () => {
|
||||
close();
|
||||
const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim();
|
||||
navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`);
|
||||
const route = makeShineChannelRoute({
|
||||
ownerLogin: String(item.ownerLogin || '').trim(),
|
||||
ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
|
||||
channelName: String(item.channelName || '').trim(),
|
||||
});
|
||||
if (route) navigate(route);
|
||||
});
|
||||
|
||||
row.style.display = 'flex';
|
||||
@@ -582,7 +589,11 @@ function openChannelFinderModal({ navigate }) {
|
||||
function mapMockGroups() {
|
||||
const mapRow = (channel) => ({
|
||||
...channel,
|
||||
route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`,
|
||||
route: makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
ownerBlockchainName: String(channel.ownerName || ''),
|
||||
channelName: String(channel.channelName || channel.title || channel.id),
|
||||
}),
|
||||
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal'
|
||||
? 'my'
|
||||
: 'feed',
|
||||
@@ -683,6 +694,9 @@ function toListModel(groups) {
|
||||
function renderEmptyState(activeTab, navigate) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||
if (!state.session.isAuthorized) {
|
||||
return wrap;
|
||||
}
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
if (activeTab === 'feed') {
|
||||
@@ -763,7 +777,14 @@ function renderDemoFallback(container, navigate, error, onRetry) {
|
||||
<span class="channel-row-time">—</span>
|
||||
</div>
|
||||
`;
|
||||
row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
|
||||
row.addEventListener('click', () => {
|
||||
const route = channel.route || makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
ownerBlockchainName: String(channel.ownerName || ''),
|
||||
channelName: String(channel.channelName || channel.id),
|
||||
});
|
||||
if (route) navigate(route);
|
||||
});
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
@@ -996,35 +1017,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
|
||||
const main = renderChannelMain(channel, activeTab);
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channel-row-controls';
|
||||
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.type = 'button';
|
||||
menuButton.className = 'channel-menu-trigger';
|
||||
menuButton.textContent = '…';
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(menuButton);
|
||||
listState.revealedCounters.add(channel.id);
|
||||
|
||||
if (listState.openMenuId === channel.id) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
return;
|
||||
}
|
||||
|
||||
listState.openMenuId = channel.id;
|
||||
openChannelMenu({
|
||||
listState,
|
||||
channel,
|
||||
anchorEl: menuButton,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
|
||||
rerenderList,
|
||||
});
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
|
||||
@@ -1035,10 +1031,45 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
controls.append(menuButton, time, count);
|
||||
if (!isGuest) {
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.type = 'button';
|
||||
menuButton.className = 'channel-menu-trigger';
|
||||
menuButton.textContent = '…';
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(menuButton);
|
||||
listState.revealedCounters.add(channel.id);
|
||||
|
||||
if (listState.openMenuId === channel.id) {
|
||||
closeChannelMenu(listState);
|
||||
rerenderList();
|
||||
return;
|
||||
}
|
||||
|
||||
listState.openMenuId = channel.id;
|
||||
openChannelMenu({
|
||||
listState,
|
||||
channel,
|
||||
anchorEl: menuButton,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
|
||||
rerenderList,
|
||||
});
|
||||
rerenderList();
|
||||
});
|
||||
controls.append(menuButton);
|
||||
}
|
||||
controls.append(time, count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
|
||||
row.addEventListener('click', () => {
|
||||
const route = channel.route || makeShineChannelRoute({
|
||||
ownerLogin: String(channel.ownerName || 'channel'),
|
||||
ownerBlockchainName: String(channel.ownerName || ''),
|
||||
channelName: String(channel.channelName || channel.id),
|
||||
});
|
||||
if (route) navigate(route);
|
||||
});
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
@@ -1057,9 +1088,9 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
||||
}
|
||||
|
||||
if (tab === 'my') {
|
||||
button.textContent = 'Создать канал';
|
||||
button.textContent = 'Найти канал';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => navigate('add-channel-view');
|
||||
button.onclick = () => openChannelFinderModal({ navigate });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1072,8 +1103,20 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
closeChannelMenu(listState);
|
||||
renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
setChannelsFeed(null, {});
|
||||
listState.channels = [];
|
||||
renderListContent({
|
||||
screen,
|
||||
container: contentEl,
|
||||
listState,
|
||||
navigate,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!state.session.login) throw new Error('not_authorized');
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
const groups = mapApiFeed(feed, listState.notificationsState);
|
||||
|
||||
@@ -1109,6 +1152,7 @@ export function render({ navigate, route }) {
|
||||
const createSuccessFlash = pullCreateSuccessFlash();
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const listState = {
|
||||
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
|
||||
? String(route?.params?.mode).trim()
|
||||
@@ -1119,10 +1163,16 @@ export function render({ navigate, route }) {
|
||||
channels: [],
|
||||
menuCleanup: null,
|
||||
};
|
||||
if (isGuest && listState.activeTab === 'my') {
|
||||
listState.activeTab = 'feed';
|
||||
}
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topBarEl = document.createElement('div');
|
||||
topBarEl.className = 'channels-top-bar';
|
||||
|
||||
const tabsEl = document.createElement('div');
|
||||
tabsEl.className = 'channels-tabs';
|
||||
const tabLabels = {
|
||||
@@ -1130,6 +1180,7 @@ export function render({ navigate, route }) {
|
||||
my: 'Мои каналы',
|
||||
};
|
||||
TAB_ORDER.forEach((tabKey) => {
|
||||
if (isGuest && tabKey === 'my') return;
|
||||
const tabBtn = document.createElement('button');
|
||||
tabBtn.type = 'button';
|
||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
||||
@@ -1142,6 +1193,15 @@ export function render({ navigate, route }) {
|
||||
tabsEl.append(tabBtn);
|
||||
});
|
||||
|
||||
const topActionBtn = document.createElement('button');
|
||||
topActionBtn.type = 'button';
|
||||
topActionBtn.className = 'secondary-btn channels-top-action-btn';
|
||||
topActionBtn.textContent = 'Создать канал';
|
||||
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
if (isGuest) topActionBtn.style.display = 'none';
|
||||
|
||||
topBarEl.append(tabsEl, topActionBtn);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
|
||||
@@ -1169,6 +1229,9 @@ export function render({ navigate, route }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
const showCreate = !isGuest && listState.activeTab === 'my';
|
||||
topActionBtn.style.display = showCreate ? '' : 'none';
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
listState,
|
||||
@@ -1202,7 +1265,7 @@ export function render({ navigate, route }) {
|
||||
rerenderList();
|
||||
}, { passive: true });
|
||||
|
||||
screen.append(tabsEl, contentEl, bottomCta);
|
||||
screen.append(topBarEl, contentEl, bottomCta);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
|
||||
@@ -1,7 +1,62 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
||||
const searchAvatarSnapshotCache = new Map();
|
||||
const searchAvatarPendingByLogin = new Map();
|
||||
|
||||
async function loadSearchAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (searchAvatarSnapshotCache.has(key)) return searchAvatarSnapshotCache.get(key);
|
||||
if (searchAvatarPendingByLogin.has(key)) return searchAvatarPendingByLogin.get(key);
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
searchAvatarSnapshotCache.set(key, snapshot || null);
|
||||
searchAvatarPendingByLogin.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
searchAvatarSnapshotCache.set(key, null);
|
||||
searchAvatarPendingByLogin.delete(key);
|
||||
return null;
|
||||
});
|
||||
searchAvatarPendingByLogin.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createSearchAvatar(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
className: 'avatar',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
void loadSearchAvatarSnapshot(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: 'avatar',
|
||||
title,
|
||||
});
|
||||
avatarEl.replaceWith(upgraded);
|
||||
});
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
@@ -44,16 +99,17 @@ export function render({ navigate }) {
|
||||
matches.forEach((login) => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item dm-dialog-card';
|
||||
const avatarEl = createSearchAvatar(login);
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
||||
<div>
|
||||
<strong>${login}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
||||
</div>
|
||||
<div class="meta-muted">Профиль</div>
|
||||
`;
|
||||
row.prepend(avatarEl);
|
||||
row.addEventListener('click', () => {
|
||||
navigate(`user/${encodeURIComponent(login)}/contact-search-view`);
|
||||
navigate(makeProfileRoute(login));
|
||||
});
|
||||
resultsList.append(row);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,61 @@ import {
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
const dmAvatarPendingByLogin = new Map();
|
||||
|
||||
async function loadDmAvatarSnapshot(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) return null;
|
||||
const key = cleanLogin.toLowerCase();
|
||||
if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key);
|
||||
if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key);
|
||||
const pending = loadProfileSnapshot(cleanLogin)
|
||||
.then((snapshot) => {
|
||||
dmAvatarSnapshotCache.set(key, snapshot || null);
|
||||
dmAvatarPendingByLogin.delete(key);
|
||||
return snapshot || null;
|
||||
})
|
||||
.catch(() => {
|
||||
dmAvatarSnapshotCache.set(key, null);
|
||||
dmAvatarPendingByLogin.delete(key);
|
||||
return null;
|
||||
});
|
||||
dmAvatarPendingByLogin.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createDmAvatar(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
title,
|
||||
});
|
||||
if (!cleanLogin) return avatarEl;
|
||||
void loadDmAvatarSnapshot(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',
|
||||
title,
|
||||
});
|
||||
upgraded.classList.add('avatar');
|
||||
avatarEl.replaceWith(upgraded);
|
||||
});
|
||||
return avatarEl;
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
const value = Number(ts || 0);
|
||||
@@ -40,8 +93,9 @@ export function render({ navigate }) {
|
||||
function renderRow(item) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item dm-dialog-card';
|
||||
const avatarEl = createDmAvatar(item.id);
|
||||
avatarEl.classList.add('avatar');
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${item.initials}</div>
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-title-wrap">
|
||||
<strong class="dm-row-title">${item.name}</strong>
|
||||
@@ -54,6 +108,7 @@ export function render({ navigate }) {
|
||||
<span class="meta-muted dm-row-time">${item.time}</span>
|
||||
</div>
|
||||
`;
|
||||
row.prepend(avatarEl);
|
||||
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
||||
return row;
|
||||
}
|
||||
@@ -73,7 +128,6 @@ export function render({ navigate }) {
|
||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||
return {
|
||||
id: login,
|
||||
initials: (login[0] || '?').toUpperCase(),
|
||||
name: preview?.name || login,
|
||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
@@ -96,7 +150,6 @@ export function render({ navigate }) {
|
||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||
return {
|
||||
id: login,
|
||||
initials: (login[0] || '?').toUpperCase(),
|
||||
name: login,
|
||||
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
@@ -14,6 +16,14 @@ function normalizeLogin(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function createDebounced(fn, delayMs = 2000) {
|
||||
let timer = 0;
|
||||
return (...args) => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => fn(...args), delayMs);
|
||||
};
|
||||
}
|
||||
|
||||
function normKey(value) {
|
||||
return normalizeLogin(value).toLowerCase();
|
||||
}
|
||||
@@ -507,6 +517,7 @@ let persistedCenterHistory = [];
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||
if (!keepHistory) {
|
||||
persistedCenterLogin = '';
|
||||
persistedCenterHistory = [];
|
||||
@@ -533,7 +544,7 @@ export function render({ navigate, route }) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) return '';
|
||||
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
||||
return `user/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
|
||||
return makeProfileRoute(cleanLogin);
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
@@ -551,6 +562,15 @@ export function render({ navigate, route }) {
|
||||
persistedCenterHistory = [...centerHistory];
|
||||
}
|
||||
|
||||
function syncLinksUrl(login, { push = false } = {}) {
|
||||
const clean = normalizeLogin(login);
|
||||
if (!clean) return;
|
||||
const nextPath = `/${makeProfileLinksRoute(clean)}`;
|
||||
if (window.location.pathname === nextPath) return;
|
||||
if (push) window.history.pushState({}, '', nextPath);
|
||||
else window.history.replaceState({}, '', nextPath);
|
||||
}
|
||||
|
||||
function setBackButtonState(backBtn) {
|
||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||
backBtn.disabled = centerHistory.length === 0;
|
||||
@@ -568,13 +588,8 @@ export function render({ navigate, route }) {
|
||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||
</div>
|
||||
<div class="meta-muted" id="network-search-meta">Введите логин и нажмите «Искать».</div>
|
||||
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</div>
|
||||
<div class="stack" id="network-search-results"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="ghost-btn" type="button" id="network-search-profile" disabled>Показать профиль</button>
|
||||
<button class="primary-btn" type="button" id="network-search-graph" disabled>Показать связи</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" id="network-search-ok" disabled>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -585,9 +600,6 @@ export function render({ navigate, route }) {
|
||||
const runBtn = root.querySelector('#network-search-run');
|
||||
const metaEl = root.querySelector('#network-search-meta');
|
||||
const resultsEl = root.querySelector('#network-search-results');
|
||||
const profileBtn = root.querySelector('#network-search-profile');
|
||||
const graphBtn = root.querySelector('#network-search-graph');
|
||||
const okBtn = root.querySelector('#network-search-ok');
|
||||
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
|
||||
root.innerHTML = '';
|
||||
return;
|
||||
@@ -607,10 +619,6 @@ export function render({ navigate, route }) {
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
|
||||
});
|
||||
const hasSelected = Boolean(selectedLogin);
|
||||
if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected;
|
||||
if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected;
|
||||
if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected;
|
||||
};
|
||||
|
||||
const renderCandidates = (logins) => {
|
||||
@@ -661,6 +669,8 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
closeBtn?.addEventListener('click', close);
|
||||
runBtn?.addEventListener('click', () => { void runSearch(); });
|
||||
const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000);
|
||||
inputEl.addEventListener('input', debouncedSearch);
|
||||
inputEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -672,23 +682,11 @@ export function render({ navigate, route }) {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const button = target.closest('[data-candidate]');
|
||||
if (!(button instanceof HTMLElement)) return;
|
||||
applySelection(String(button.dataset.candidate || ''));
|
||||
});
|
||||
profileBtn?.addEventListener('click', () => {
|
||||
if (!selectedLogin) return;
|
||||
const routeTo = profileInfoRoute(selectedLogin);
|
||||
if (!routeTo) return;
|
||||
const nextLogin = String(button.dataset.candidate || '');
|
||||
applySelection(nextLogin);
|
||||
if (!nextLogin) return;
|
||||
close();
|
||||
navigate(routeTo);
|
||||
});
|
||||
graphBtn?.addEventListener('click', () => {
|
||||
if (!selectedLogin) return;
|
||||
close();
|
||||
void load(selectedLogin, { pushHistory: true });
|
||||
});
|
||||
okBtn?.addEventListener('click', () => {
|
||||
if (!selectedLogin) return;
|
||||
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
|
||||
void load(nextLogin, { pushHistory: true });
|
||||
});
|
||||
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
@@ -765,6 +763,7 @@ export function render({ navigate, route }) {
|
||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||
centerHistory.push(prevCenter);
|
||||
}
|
||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||
|
||||
const model = buildGraphModel(graph, targetCenter);
|
||||
const layout = layoutNodes(model);
|
||||
@@ -839,13 +838,22 @@ export function render({ navigate, route }) {
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
if (keepHistory && centerLogin) {
|
||||
if (routeLogin) {
|
||||
centerLogin = routeLogin;
|
||||
centerHistory = [];
|
||||
persistHistory();
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
} else if (keepHistory && centerLogin) {
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
} else {
|
||||
centerLogin = normalizeLogin(state.session.login || '');
|
||||
centerHistory = [];
|
||||
persistHistory();
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
if (centerLogin) {
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
} else {
|
||||
window.setTimeout(() => openSearchModal(), 0);
|
||||
}
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -29,6 +30,40 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function openProfileInfoModal({ title, text }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="profile-info-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#profile-info-close')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'profile-info-modal') close();
|
||||
});
|
||||
}
|
||||
|
||||
function officialInfoText() {
|
||||
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
||||
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
|
||||
}
|
||||
|
||||
function shineInfoText() {
|
||||
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
||||
+ 'Пять принципов сияющих:\n'
|
||||
+ '1) сияющие не обманывают;\n'
|
||||
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
||||
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
||||
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
||||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const login = state.session.login || profile.login;
|
||||
|
||||
@@ -39,14 +74,22 @@ export function render({ navigate }) {
|
||||
topActions.className = 'profile-top-actions';
|
||||
topActions.innerHTML = `
|
||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button>
|
||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="wallet">Кошелёк</button>
|
||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
||||
`;
|
||||
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
screen.append(topActions);
|
||||
|
||||
const bottomActions = document.createElement('div');
|
||||
bottomActions.className = 'profile-bottom-actions';
|
||||
bottomActions.innerHTML = `
|
||||
<button class="ghost-btn profile-top-action-btn" type="button" data-bottom-action="wallet">Кошелёк</button>
|
||||
<button class="ghost-btn profile-top-action-btn profile-links-two-line" type="button" data-bottom-action="links">Показать\nсвязи</button>
|
||||
`;
|
||||
bottomActions.querySelector('[data-bottom-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||
bottomActions.querySelector('[data-bottom-action="links"]')?.addEventListener('click', () => navigate(makeProfileLinksRoute(login)));
|
||||
screen.append(bottomActions);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack profile-main-card';
|
||||
|
||||
@@ -126,6 +169,21 @@ export function render({ navigate }) {
|
||||
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
||||
}
|
||||
|
||||
officialBtn?.classList.add('profile-badge-trigger');
|
||||
shineBtn?.classList.add('profile-badge-trigger');
|
||||
officialBtn?.addEventListener('click', () => {
|
||||
openProfileInfoModal({
|
||||
title: 'Официальный канал',
|
||||
text: officialInfoText(),
|
||||
});
|
||||
});
|
||||
shineBtn?.addEventListener('click', () => {
|
||||
openProfileInfoModal({
|
||||
title: 'Справка о сияющих',
|
||||
text: shineInfoText(),
|
||||
});
|
||||
});
|
||||
|
||||
function renderFields(fields) {
|
||||
listWrap.innerHTML = '';
|
||||
fields.forEach((field) => {
|
||||
|
||||
@@ -32,13 +32,19 @@ export function render({ navigate }) {
|
||||
registerButton.textContent = 'Зарегистрироваться';
|
||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||
|
||||
const guestViewButton = document.createElement('button');
|
||||
guestViewButton.className = 'ghost-btn';
|
||||
guestViewButton.type = 'button';
|
||||
guestViewButton.textContent = 'Только просмотр';
|
||||
guestViewButton.addEventListener('click', () => navigate('network-view'));
|
||||
|
||||
const settingsButton = document.createElement('button');
|
||||
settingsButton.className = 'ghost-btn';
|
||||
settingsButton.type = 'button';
|
||||
settingsButton.textContent = 'Настройки';
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, settingsButton);
|
||||
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
||||
screen.append(logo, title, actions);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
loadUserProfileCard,
|
||||
} from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
|
||||
import { navigateBack } from '../router.js';
|
||||
|
||||
@@ -20,6 +21,40 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function openProfileInfoModal({ title, text }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="profile-info-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#profile-info-close')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'profile-info-modal') close();
|
||||
});
|
||||
}
|
||||
|
||||
function officialInfoText() {
|
||||
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
||||
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
|
||||
}
|
||||
|
||||
function shineInfoText() {
|
||||
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
||||
+ 'Пять принципов сияющих:\n'
|
||||
+ '1) сияющие не обманывают;\n'
|
||||
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
||||
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
||||
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
||||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||||
}
|
||||
|
||||
function genderText(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'male') return 'Мужской';
|
||||
@@ -141,8 +176,8 @@ function renderIdentity(card) {
|
||||
function renderReadOnlyBadges(card) {
|
||||
return `
|
||||
<div class="row wrap-row">
|
||||
<span class="badge ${card.official ? 'is-yes-official' : 'is-no'}">Официальный: ${card.official ? 'Yes' : 'No'}</span>
|
||||
<span class="badge ${card.shine ? 'is-yes-shine' : 'is-no'}">Сияющий: ${card.shine ? 'Yes' : 'No'}</span>
|
||||
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button>
|
||||
<button class="badge profile-badge-trigger ${card.shine ? 'is-yes-shine' : 'is-no'}" type="button" data-profile-info="shine">Сияющий: ${card.shine ? 'Yes' : 'No'}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -157,7 +192,7 @@ function renderRelations(flags) {
|
||||
const hasOpinion = opinionItems.length > 0;
|
||||
|
||||
return `
|
||||
<div class="card stack user-relations-list">
|
||||
<div class="card stack user-relations-list" data-profile-relations="true">
|
||||
${rows.map((row) => `
|
||||
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
|
||||
<span class="user-rel-text">${escapeHtml(row.text)}</span>
|
||||
@@ -174,7 +209,7 @@ function renderRelations(flags) {
|
||||
</div>
|
||||
<div class="user-rel-row">
|
||||
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
|
||||
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить связи' : 'Добавить связь'}</button>
|
||||
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -191,7 +226,7 @@ function openOpinionMenuModal({ flags, onApply }) {
|
||||
];
|
||||
const rowsHtml = items
|
||||
.filter((item) => item.kind !== activeKind)
|
||||
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Добавить: ${item.title}</button>`)
|
||||
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Высказать: ${item.title}</button>`)
|
||||
.join('');
|
||||
const removeHtml = activeKind
|
||||
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
|
||||
@@ -200,7 +235,7 @@ function openOpinionMenuModal({ flags, onApply }) {
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="user-opinion-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${activeKind ? 'Изменить связи' : 'Добавить связь'}</h3>
|
||||
<h3 class="modal-title">${activeKind ? 'Изменить мнение' : 'Добавить мнение'}</h3>
|
||||
<div class="stack">${rowsHtml}${removeHtml}</div>
|
||||
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
|
||||
</div>
|
||||
@@ -262,11 +297,13 @@ export function render({ navigate, route }) {
|
||||
renderHeader({
|
||||
title: 'Профиль пользователя',
|
||||
leftAction: { label: '←', onClick: () => navigateBack() },
|
||||
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
|
||||
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
|
||||
}),
|
||||
status,
|
||||
body,
|
||||
);
|
||||
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
|
||||
linksHeaderBtn?.classList.add('profile-links-header-btn');
|
||||
|
||||
let currentCard = null;
|
||||
let currentFlags = null;
|
||||
@@ -285,7 +322,7 @@ export function render({ navigate, route }) {
|
||||
contactBtn.disabled = Boolean(isSelf);
|
||||
friendBtn.disabled = Boolean(isSelf);
|
||||
followBtn.disabled = Boolean(isSelf);
|
||||
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить связи' : 'Добавить связь';
|
||||
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
|
||||
opinionBtn.disabled = Boolean(isSelf);
|
||||
}
|
||||
|
||||
@@ -321,6 +358,10 @@ export function render({ navigate, route }) {
|
||||
body.prepend(identityCard);
|
||||
|
||||
syncActionButtons();
|
||||
if (String(route?.params?.section || '').toLowerCase() === 'links') {
|
||||
const rel = body.querySelector('[data-profile-relations="true"]');
|
||||
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
status.className = 'status-line is-available';
|
||||
status.textContent = 'Профиль обновлён.';
|
||||
} catch (error) {
|
||||
@@ -418,6 +459,17 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
if (mode === 'set') {
|
||||
const opinionVisible = Boolean(
|
||||
currentFlags?.outKnownPerson
|
||||
|| currentFlags?.outShineConfirmed
|
||||
|| currentFlags?.outShineSeen,
|
||||
);
|
||||
if (!opinionVisible) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 350));
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
|
||||
@@ -429,6 +481,22 @@ export function render({ navigate, route }) {
|
||||
body.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const infoBtn = target.closest('[data-profile-info]');
|
||||
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
|
||||
if (infoKind === 'official') {
|
||||
openProfileInfoModal({
|
||||
title: 'Официальный канал',
|
||||
text: officialInfoText(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (infoKind === 'shine') {
|
||||
openProfileInfoModal({
|
||||
title: 'Справка о сияющих',
|
||||
text: shineInfoText(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const actionBtn = target.closest('[data-relation-action]');
|
||||
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
||||
if (!kind) return;
|
||||
|
||||
+73
-77
@@ -1,3 +1,5 @@
|
||||
import { parseShineRootSegment } from './services/shine-routes.js';
|
||||
|
||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
||||
|
||||
export const PRE_AUTH_PAGES = [
|
||||
@@ -20,9 +22,7 @@ export function getRoute() {
|
||||
.replace(/^index\.html$/i, '')
|
||||
.replace(/^index\.html\//i, '')
|
||||
.replace(/\/+$/, '');
|
||||
if (!raw) {
|
||||
return { pageId: '', params: {} };
|
||||
}
|
||||
if (!raw) return { pageId: '', params: {} };
|
||||
|
||||
const segments = raw.split('/').filter(Boolean);
|
||||
const pageId = segments[0] || '';
|
||||
@@ -36,6 +36,73 @@ export function getRoute() {
|
||||
}
|
||||
};
|
||||
|
||||
const shineLogin = parseShineRootSegment(pageId);
|
||||
if (shineLogin) {
|
||||
const section = decodePart(segments[1] || '').toLowerCase();
|
||||
if (!section) {
|
||||
return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } };
|
||||
}
|
||||
if (section === 'links') {
|
||||
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
|
||||
}
|
||||
if (section === 'channels') {
|
||||
const sub = decodePart(segments[2] || '').toLowerCase();
|
||||
if (sub === 'owned') {
|
||||
return {
|
||||
pageId: 'channels-list',
|
||||
params: { mode: 'my', login: shineLogin, scope: 'owned' },
|
||||
};
|
||||
}
|
||||
if (sub === 'following') {
|
||||
return {
|
||||
pageId: 'channels-list',
|
||||
params: { mode: 'feed', login: shineLogin, scope: 'following' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
pageId: 'channels-list',
|
||||
params: { mode: 'feed', login: shineLogin, scope: 'all' },
|
||||
};
|
||||
}
|
||||
if (section === 'msg') {
|
||||
return {
|
||||
pageId: 'channel-thread-view',
|
||||
params: {
|
||||
messageBlockchainName: decodePart(segments[2]),
|
||||
messageBlockNumber: segments[3] || '',
|
||||
messageBlockHash: '',
|
||||
channelOwnerBlockchainName: '',
|
||||
channelRootBlockNumber: '',
|
||||
channelRootBlockHash: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (section === 'channel') {
|
||||
const ownerBlockchainName = decodePart(segments[2] || '');
|
||||
const channelName = decodePart(segments[3] || '');
|
||||
const messageBlockNumber = segments[4] || '';
|
||||
if (ownerBlockchainName && channelName && messageBlockNumber) {
|
||||
return {
|
||||
pageId: 'channel-thread-view',
|
||||
params: {
|
||||
ownerBlockchainName,
|
||||
channelName,
|
||||
messageBlockNumber,
|
||||
messageBlockHash: '',
|
||||
messageBlockchainName: '',
|
||||
channelOwnerBlockchainName: ownerBlockchainName,
|
||||
channelRootBlockNumber: '',
|
||||
channelRootBlockHash: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
pageId: 'channel-view',
|
||||
params: { ownerBlockchainName, channelName, channelId: '' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (pageId === 'chat-view') {
|
||||
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||
}
|
||||
@@ -55,41 +122,6 @@ export function getRoute() {
|
||||
return { pageId, params: { channelId: dynamicId || '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'channel') {
|
||||
// Короткий формат:
|
||||
// /channel/{ownerBlockchainName}/{channelName}
|
||||
// /channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber}
|
||||
const ownerBlockchainName = decodePart(segments[1] || '');
|
||||
const channelName = decodePart(segments[2] || '');
|
||||
const messageBlockNumber = segments[3] || '';
|
||||
|
||||
if (ownerBlockchainName && channelName && messageBlockNumber) {
|
||||
return {
|
||||
pageId: 'channel-thread-view',
|
||||
params: {
|
||||
ownerBlockchainName,
|
||||
channelName,
|
||||
messageBlockNumber,
|
||||
messageBlockHash: '',
|
||||
// поддержка старого контракта страницы треда
|
||||
messageBlockchainName: '',
|
||||
channelOwnerBlockchainName: ownerBlockchainName,
|
||||
channelRootBlockNumber: '',
|
||||
channelRootBlockHash: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pageId: 'channel-view',
|
||||
params: {
|
||||
ownerBlockchainName,
|
||||
channelName,
|
||||
channelId: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pageId === 'channel-thread-view') {
|
||||
return {
|
||||
pageId,
|
||||
@@ -104,50 +136,16 @@ export function getRoute() {
|
||||
};
|
||||
}
|
||||
|
||||
if (pageId === 'm') {
|
||||
return {
|
||||
pageId: 'channel-thread-view',
|
||||
params: {
|
||||
messageBlockchainName: decodePart(segments[1]),
|
||||
messageBlockNumber: segments[2] || '',
|
||||
messageBlockHash: '',
|
||||
channelOwnerBlockchainName: '',
|
||||
channelRootBlockNumber: '',
|
||||
channelRootBlockHash: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pageId === 'device-session-view') {
|
||||
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'user') {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
login: dynamicId ? decodeURIComponent(dynamicId) : '',
|
||||
fromPage: segments[2] ? decodeURIComponent(segments[2]) : 'messages-list',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pageId === 'network-view') {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
mode: segments[1] ? decodePart(segments[1]) : '',
|
||||
},
|
||||
};
|
||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'channels-list') {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
mode: segments[1] ? decodePart(segments[1]) : '',
|
||||
},
|
||||
};
|
||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||
}
|
||||
|
||||
return { pageId, params: {} };
|
||||
@@ -185,9 +183,7 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'language-view' ||
|
||||
pageId === 'app-log-view' ||
|
||||
pageId === 'pwa-diagnostics-view'
|
||||
) {
|
||||
return 'profile-view';
|
||||
}
|
||||
) return 'profile-view';
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||
if (pageId === 'user') return 'messages-list';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function openAuthRequiredModal({
|
||||
title = 'Нужен вход',
|
||||
text = 'Эта часть доступна после входа в систему.',
|
||||
startRoute = 'start-view',
|
||||
} = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
window.alert(`${title}\n\n${text}`);
|
||||
return;
|
||||
}
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="auth-required-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="auth-required-close">Закрыть</button>
|
||||
<button class="primary-btn" type="button" id="auth-required-start">На старт</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#auth-required-close')?.addEventListener('click', close);
|
||||
root.querySelector('#auth-required-start')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(startRoute);
|
||||
});
|
||||
root.querySelector('#auth-required-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'auth-required-modal') close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function encodeRoutePart(value = '') {
|
||||
return encodeURIComponent(String(value || '').trim());
|
||||
}
|
||||
|
||||
function decodeRoutePart(value = '') {
|
||||
try {
|
||||
return decodeURIComponent(String(value || ''));
|
||||
} catch {
|
||||
return String(value || '');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLogin(value = '') {
|
||||
return String(value || '').trim().replace(/^@+/, '');
|
||||
}
|
||||
|
||||
export function extractLoginFromBlockchainName(value = '') {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^(.+)-\d+$/);
|
||||
if (!match) return normalizeLogin(raw);
|
||||
return normalizeLogin(String(match[1] || ''));
|
||||
}
|
||||
|
||||
export function makeProfileRoute(login = '') {
|
||||
const clean = normalizeLogin(login);
|
||||
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile-view';
|
||||
}
|
||||
|
||||
export function makeProfileLinksRoute(login = '') {
|
||||
const clean = normalizeLogin(login);
|
||||
return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network-view/keep-history';
|
||||
}
|
||||
|
||||
export function makeProfileChannelsRoute(login = '', scope = 'all') {
|
||||
const clean = normalizeLogin(login);
|
||||
if (!clean) return 'channels-list/feed';
|
||||
const normalizedScope = String(scope || '').trim().toLowerCase();
|
||||
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
|
||||
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
|
||||
return `shine.${encodeRoutePart(clean)}/channels`;
|
||||
}
|
||||
|
||||
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
|
||||
const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName);
|
||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
||||
const chName = String(channelName || '').trim();
|
||||
const msgNo = String(messageBlockNumber || '').trim();
|
||||
if (!cleanOwnerLogin || !ownerBch || !chName) return '';
|
||||
if (msgNo) return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
|
||||
return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||
}
|
||||
|
||||
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
||||
const cleanOwnerLogin = normalizeLogin(ownerLogin);
|
||||
const msgBch = String(messageBlockchainName || '').trim();
|
||||
const msgNo = String(messageBlockNumber || '').trim();
|
||||
if (!cleanOwnerLogin || !msgBch || !msgNo) return '';
|
||||
return `shine.${encodeRoutePart(cleanOwnerLogin)}/msg/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
|
||||
}
|
||||
|
||||
export function parseShineRootSegment(segment = '') {
|
||||
const raw = String(segment || '').trim();
|
||||
if (!raw.toLowerCase().startsWith('shine.')) return '';
|
||||
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
|
||||
}
|
||||
@@ -47,7 +47,23 @@ function toToggleMap(snapshot) {
|
||||
}
|
||||
|
||||
function readArray(payload, key) {
|
||||
const value = payload?.[key];
|
||||
const aliases = {
|
||||
outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
|
||||
inKnownPersons: ['inKnownPersons', 'inKnownPerson', 'in_known_persons'],
|
||||
outShineConfirmed: ['outShineConfirmed', 'outShineConfident', 'out_shine_confirmed'],
|
||||
inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
|
||||
outShineSeen: ['outShineSeen', 'out_shine_seen'],
|
||||
inShineSeen: ['inShineSeen', 'in_shine_seen'],
|
||||
};
|
||||
const keys = aliases[key] || [key];
|
||||
let value = null;
|
||||
for (const oneKey of keys) {
|
||||
const candidate = payload?.[oneKey];
|
||||
if (Array.isArray(candidate)) {
|
||||
value = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) ? uniqueLogins(value) : null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user