Добавить состояние чтения каналов

This commit is contained in:
AidarKC
2026-09-09 18:59:54 +03:00
parent 2d059e9ff5
commit 8e86872aa7
29 changed files with 2538 additions and 231 deletions
+52 -49
View File
@@ -234,13 +234,6 @@ function buildThreadRoute(messageRef, selector) {
});
}
function buildChannelSettingsKey(ownerBlockchainName, channelName) {
const ownerBch = String(ownerBlockchainName || '').trim();
const name = String(channelName || '').trim();
if (!ownerBch || !name) return '';
return `${ownerBch}/${name}`;
}
function getChannelScrollRoot() {
return document.getElementById('app-screen');
}
@@ -286,7 +279,9 @@ function scrollChannelToUnreadLine(screen, unreadCount = 0, smooth = false) {
function createChannelReadTracker({
screen,
routeKey,
settingKey,
ownerBlockchainName,
channelName,
initializeIfMissing = false,
unreadCount,
messagesCount,
initialSeenCount,
@@ -295,7 +290,9 @@ function createChannelReadTracker({
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
const canWrite = !!(settingKey && login && storagePwd);
const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim();
const cleanChannelName = String(channelName || '').trim();
const canWrite = !!(cleanOwnerBlockchainName && cleanChannelName && login && storagePwd);
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
@@ -303,6 +300,7 @@ function createChannelReadTracker({
let desiredSeenCount = safeInitialSeenCount;
let persistedSeenCount = safeInitialSeenCount;
let initialPersistPending = !!initializeIfMissing;
let inFlight = false;
let disposed = false;
let rafId = 0;
@@ -327,7 +325,7 @@ function createChannelReadTracker({
const flush = async () => {
if (disposed || !canWrite) return;
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
if (next <= persistedSeenCount) return;
if (next <= persistedSeenCount && !initialPersistPending) return;
if (inFlight) {
queueFlush(120);
return;
@@ -335,16 +333,16 @@ function createChannelReadTracker({
inFlight = true;
try {
await authService.upsertUserSetting({
await authService.setChannelReadState({
login,
settingType: 1,
settingKey,
ownerBlockchainName: cleanOwnerBlockchainName,
channelName: cleanChannelName,
readCount: next,
timeMs: Date.now(),
valueText: '',
valueNum: next,
storagePwd,
});
persistedSeenCount = next;
initialPersistPending = false;
if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount);
} catch (error) {
if (typeof onPersistError === 'function') onPersistError(error);
@@ -399,6 +397,8 @@ function createChannelReadTracker({
// Opening a channel must NOT mark the whole channel as read.
// Only cards actually crossed by the viewport tracker advance desiredSeenCount.
// Exception: if the server has no row yet, persist the current client baseline once.
if (initialPersistPending) queueFlush(80);
window.setTimeout(() => measure(), 120);
const cleanup = () => {
@@ -1457,6 +1457,7 @@ async function loadFromApi(route, channelId) {
const isAuthorized = !!currentSessionLogin;
let unreadCount = 0;
let messagesCount = 0;
let readStateInitialized = false;
let cachedFeed = null;
const ensureFeed = async () => {
if (cachedFeed) return cachedFeed;
@@ -1571,6 +1572,7 @@ async function loadFromApi(route, channelId) {
}
unreadCount = Number(channel?.unreadCount || 0);
messagesCount = Number(channel?.messagesCount || 0);
readStateInitialized = !!channel?.readStateInitialized;
selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
@@ -1664,6 +1666,7 @@ async function loadFromApi(route, channelId) {
messagesCount,
isOwnChannel,
isSubscribed,
readStateInitialized,
selector,
};
}
@@ -1793,12 +1796,12 @@ function renderChannelMetaEventCard(event) {
function likeCategoryCounts(post) {
const total = Math.max(0, Number(post?.likesCount || 0));
const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0)));
const official = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0)));
const shining = Math.max(0, Math.min(official, Number(post?.shiningLikesCount || 0)));
return {
shining,
official: Math.max(0, primary - shining),
others: Math.max(0, total - primary),
official,
all: total,
total,
};
}
@@ -1815,7 +1818,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
<div class="channel-likes-tabs" role="tablist">
<button type="button" class="ui-button" data-like-tab="shining">Сияющие <span data-like-tab-count="shining"></span></button>
<button type="button" class="ui-button" data-like-tab="official">Официальные <span data-like-tab-count="official"></span></button>
<button type="button" class="ui-button" data-like-tab="others">Остальные <span data-like-tab-count="others"></span></button>
<button type="button" class="ui-button" data-like-tab="all">Все <span data-like-tab-count="all"></span></button>
</div>
<div class="channel-likes-modal__status">Загрузка...</div>
<div class="channel-likes-user-list"></div>
@@ -1831,7 +1834,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); });
modal?.addEventListener('click', (event) => event.stopPropagation());
let activeTab = ['shining', 'official', 'others'].includes(initialTab) ? initialTab : 'shining';
let activeTab = ['shining', 'official', 'all'].includes(initialTab) ? initialTab : 'shining';
let payload = null;
const renderTab = () => {
@@ -1841,7 +1844,14 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
if (!payload) return;
const rows = Array.isArray(payload?.[activeTab]) ? payload[activeTab] : [];
const shiningRows = Array.isArray(payload?.shining) ? payload.shining : [];
const officialOnlyRows = Array.isArray(payload?.official) ? payload.official : [];
const otherRows = Array.isArray(payload?.others) ? payload.others : [];
const rows = activeTab === 'shining'
? shiningRows
: activeTab === 'official'
? [...shiningRows, ...officialOnlyRows]
: [...shiningRows, ...officialOnlyRows, ...otherRows];
list.innerHTML = '';
rows.forEach((row) => {
const userButton = document.createElement('button');
@@ -1869,7 +1879,7 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
});
list.append(userButton);
});
status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.';
status.textContent = rows.length ? '' : 'В этом списке пока никого нет.';
};
overlay.querySelectorAll('[data-like-tab]').forEach((button) => {
@@ -1882,11 +1892,19 @@ function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining
(async () => {
try {
payload = await authService.getMessageLikes(messageRef, 1000);
payload = await authService.getMessageLikes(messageRef);
if (!overlay.isConnected) return;
['shining', 'official', 'others'].forEach((key) => {
const shiningCount = Array.isArray(payload?.shining) ? payload.shining.length : 0;
const officialOnlyCount = Array.isArray(payload?.official) ? payload.official.length : 0;
const otherCount = Array.isArray(payload?.others) ? payload.others.length : 0;
const tabCounts = {
shining: shiningCount,
official: shiningCount + officialOnlyCount,
all: shiningCount + officialOnlyCount + otherCount,
};
Object.entries(tabCounts).forEach(([key, value]) => {
const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`);
if (countEl) countEl.textContent = String(Array.isArray(payload?.[key]) ? payload[key].length : 0);
if (countEl) countEl.textContent = String(value);
});
renderTab();
} catch (error) {
@@ -1909,25 +1927,16 @@ function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) {
<div class="channel-like-popup__counts">
<button type="button" class="ui-button channel-like-count" data-like-list="shining"><b>${counts.shining}</b><span>Сияющие</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="official"><b>${counts.official}</b><span>Официальные</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="others"><b>${counts.others}</b><span>Остальные</span></button>
<button type="button" class="ui-button channel-like-count" data-like-list="all"><b>${counts.all}</b><span>Все</span></button>
</div>
<div class="channel-like-popup__total">Всего лайков: <b>${counts.total}</b></div>
<button type="button" class="ui-button channel-like-popup__action">${post.reactionState === 'liked' ? 'Убрать свой лайк' : 'Добавить свой лайк'}</button>
<button type="button" class="ui-button channel-like-popup__close">Закрыть</button>
</section>
`;
document.body.append(layer);
const popup = layer.querySelector('.channel-like-popup');
const rect = anchor.getBoundingClientRect();
const width = Math.min(330, Math.max(270, window.innerWidth - 24));
const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
const estimatedHeight = 255;
const top = rect.bottom + estimatedHeight < window.innerHeight - 8
? rect.bottom + 8
: Math.max(8, rect.top - estimatedHeight - 8);
popup.style.width = `${width}px`;
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
const close = () => layer.remove();
layer.addEventListener('click', (event) => { if (event.target === layer) close(); });
@@ -2378,10 +2387,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const tracker = createChannelReadTracker({
screen,
routeKey,
settingKey: buildChannelSettingsKey(
channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelData.channel?.name || channelData.channel?.channelName,
),
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
channelName: channelData.channel?.name || channelData.channel?.channelName,
initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized),
unreadCount,
messagesCount,
initialSeenCount: readCount,
@@ -2532,18 +2540,13 @@ export function render({ navigate, route, chrome }) {
});
if (disposed) return;
const readSettingKey = buildChannelSettingsKey(
apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
apiData.channel?.name || apiData.channel?.channelName,
);
try {
await authService.upsertUserSetting({
await authService.setChannelReadState({
login,
settingType: 1,
settingKey: readSettingKey,
ownerBlockchainName: apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
channelName: apiData.channel?.name || apiData.channel?.channelName,
readCount: Math.max(0, Number(apiData.messagesCount || 0)),
timeMs: Date.now(),
valueText: '',
valueNum: Math.max(0, Number(apiData.messagesCount || 0)),
storagePwd,
});
} catch (readStateError) {
+47 -29
View File
@@ -30,6 +30,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
@@ -102,52 +121,51 @@ export function render({ navigate, chrome }) {
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль';
body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div>
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
<div class="user-profile-channel-metrics">
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
<div class="user-profile-actions-wrap">
<div class="user-profile-actions" aria-label="Действия со своим профилем">
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль"><img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк"><img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки"><img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true"></button>
</div>
</div>
<div class="user-profile-detail-links" aria-label="Дополнительная информация о профиле">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>`;
</div>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
+103 -32
View File
@@ -13,32 +13,93 @@ function parseAvatar(raw) {
}
const TITLES = {
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт',
friends: 'Друзья', close_friends: 'Друзья', primary_received: 'Подтвердили аккаунт',
primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие',
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
};
export function render({navigate, route, chrome}) {
const login = String(route?.params?.login || '').trim();
const kind = String(route?.params?.kind || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack';
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...';
chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } }));
screen.append(
status,
body,
);
function friendTabsHtml(activeKind) {
return `
<div class="profile-list-tabs" role="tablist" aria-label="Тип друзей">
<button type="button" class="profile-list-tab${activeKind === 'friends' ? ' is-active' : ''}" data-friend-kind="friends" role="tab" aria-selected="${activeKind === 'friends'}">Друзья</button>
<button type="button" class="profile-list-tab${activeKind === 'close_friends' ? ' is-active' : ''}" data-friend-kind="close_friends" role="tab" aria-selected="${activeKind === 'close_friends'}">Близкие друзья</button>
</div>`;
}
(async () => {
export function render({ navigate, route, chrome }) {
const login = String(route?.params?.login || '').trim();
const initialKind = String(route?.params?.kind || '').trim();
let activeKind = initialKind;
const isFriendsScreen = initialKind === 'friends' || initialKind === 'close_friends';
const screen = document.createElement('section');
screen.className = 'stack';
const body = document.createElement('div');
body.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка...';
chrome?.setTopbar(createTopBar({
title: TITLES[initialKind] || 'Список',
back: { label: '←', onClick: () => navigateBack() },
}));
if (isFriendsScreen) {
const tabs = document.createElement('div');
tabs.innerHTML = friendTabsHtml(activeKind);
screen.append(tabs.firstElementChild);
}
screen.append(status, body);
let loadGeneration = 0;
function renderRelationRows(rows) {
rows.forEach((row) => {
const el = document.createElement('button');
el.type = 'button';
el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({
login: row.login,
firstName: row.firstName,
lastName: row.lastName,
avatar: parseAvatar(row.avatarAr),
size: 'md',
}));
const fullName = userDisplayName(row);
const t = document.createElement('div');
t.className = 'profile-list-row-text';
const marks = [
row.relationType && row.relationType !== 'none' ? ({ contact: 'контакт', friend: 'друг', close_friend: 'близкий друг' }[row.relationType] || row.relationType) : '',
row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '',
row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '',
row.primaryConfirmed ? 'основной подтверждён ✓' : '',
row.shineConfirmed ? 'сияющий подтверждён ✓' : '',
].filter(Boolean).join(' · ');
t.innerHTML = `<b>${fullName}</b><small>@${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
el.append(t);
el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`));
body.append(el);
});
}
async function load(kind) {
const generation = ++loadGeneration;
status.className = 'status-line';
status.textContent = 'Загрузка...';
body.replaceChildren();
try {
if (kind === 'channels_owned' || kind === 'channels_following') {
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
if (generation !== loadGeneration) return;
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
const el = document.createElement('button');
el.type = 'button';
el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
const t = document.createElement('div'); t.className = 'profile-list-row-text';
const t = document.createElement('div');
t.className = 'profile-list-row-text';
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
el.append(t);
el.addEventListener('click', () => navigate(`channel/${encodeURIComponent(row.ownerBlockchainName)}/${Number(row.rootBlockNumber || 0)}/${encodeURIComponent(row.rootBlockHashHex || '')}/about`));
@@ -47,25 +108,35 @@ export function render({navigate, route, chrome}) {
status.textContent = rows.length ? '' : 'Список пуст.';
return;
}
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
if (generation !== loadGeneration) return;
const rows = Array.isArray(payload?.users) ? payload.users : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
const fullName = userDisplayName(row);
const t = document.createElement('div'); t.className = 'profile-list-row-text';
const marks = [
row.relationType && row.relationType !== 'none' ? ({contact:'контакт',friend:'друг',close_friend:'близкий друг'}[row.relationType] || row.relationType) : '',
row.accountRole === 'primary' ? 'основной' : row.accountRole === 'non_voting' ? 'голос не учитывается' : '',
row.shineStatus === 'shining' ? 'сияющий' : row.shineStatus === 'not_interested' ? 'сияние неинтересно' : '',
row.primaryConfirmed ? 'основной подтверждён ✓' : '',
row.shineConfirmed ? 'сияющий подтверждён ✓' : '',
].filter(Boolean).join(' · ');
t.innerHTML = `<b>${fullName}</b><small>${String(row.login || '')}${marks ? ` · ${marks}` : ''}</small>`;
el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el);
});
renderRelationRows(rows);
status.textContent = rows.length ? '' : 'Список пуст.';
} catch (e) { status.className = 'status-line is-unavailable'; status.textContent = `Ошибка: ${e.message || 'unknown'}`; }
})();
} catch (e) {
if (generation !== loadGeneration) return;
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка: ${e.message || 'unknown'}`;
}
}
if (isFriendsScreen) {
screen.addEventListener('click', (event) => {
const tab = event.target.closest('[data-friend-kind]');
if (!tab) return;
const nextKind = String(tab.dataset.friendKind || '');
if (!nextKind || nextKind === activeKind) return;
activeKind = nextKind;
screen.querySelectorAll('[data-friend-kind]').forEach((button) => {
const selected = button.dataset.friendKind === activeKind;
button.classList.toggle('is-active', selected);
button.setAttribute('aria-selected', selected ? 'true' : 'false');
});
void load(activeKind);
});
}
void load(activeKind);
return screen;
}
+47 -29
View File
@@ -38,6 +38,25 @@ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
</button>`;
}
function friendsMetricHtml(stats = {}) {
const friends = Number(stats.friendsCount || 0);
const closeFriends = Number(stats.closeFriendsCount || 0);
return `
<button
type="button"
class="user-profile-metric is-social-metric is-friends-combined"
data-profile-list="friends"
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
>
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
<span class="user-profile-metric-label">Друзья</span>
</button>`;
}
function hasContacts(card) {
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
}
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
@@ -174,54 +193,53 @@ export function render({ navigate, route, chrome }) {
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const displayName = fullName || card.login || 'Профиль';
const about = String(card.about || '').trim();
const spiritualPath = String(card.spiritualPath || '').trim();
const contactsVisible = hasContacts(card);
const title = header.querySelector('.topbar__title');
if (title) title.textContent = card.login;
body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div>
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
<div class="user-profile-channel-metrics">
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
<div class="user-profile-identity">
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
<div class="user-profile-login">@${escapeHtml(card.login)}</div>
</div>
<div class="user-profile-social-metrics">
${friendsMetricHtml(stats)}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
</div>
<section class="user-profile-about-card" aria-label="О себе">
<div class="user-profile-about-title">О себе</div>
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
</section>
${(contactsVisible || spiritualPath) ? `
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
${contactsVisible ? `<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Контакты</span></button>` : '<span></span>'}
${spiritualPath ? `<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel"><span class="user-profile-detail-tab-label">Духовный путь</span></button>` : '<span></span>'}
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
${!isSelf ? `
<div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">
${addIconHtml()}
</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи">
<img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение">
<img src="/assets/icon_lichnye.png" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
</div>
</div>` : ''}
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>`;
</div>` : ''}`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
+52 -2
View File
@@ -1595,13 +1595,13 @@ export class AuthService {
return response.payload || {};
}
async getMessageLikes(message, limit = 1000) {
async getMessageLikes(message) {
const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(),
blockNumber: Number(message?.blockNumber),
blockHash: String(message?.blockHash || '').trim(),
};
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage, limit });
const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage });
if (response.status !== 200) throw opError('GetMessageLikes', response);
return response.payload || {};
}
@@ -3056,6 +3056,56 @@ export class AuthService {
return response.payload || {};
}
async setChannelReadState({
login,
ownerBlockchainName,
channelName,
readCount,
timeMs,
storagePwd,
}) {
const cleanLogin = String(login || '').trim();
const cleanOwnerBch = String(ownerBlockchainName || '').trim();
const cleanChannelName = String(channelName || '').trim();
const cleanReadCount = Math.max(0, Math.trunc(Number(readCount || 0)));
const cleanTimeMs = Math.trunc(Number(timeMs));
if (!cleanLogin || !cleanOwnerBch || !cleanChannelName) {
throw new Error('Не переданы login/ownerBlockchainName/channelName');
}
if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) {
throw new Error('Не передан корректный timeMs');
}
if (!storagePwd) throw new Error('Не передан storagePwd для подписи SetChannelReadState.');
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
const privateKey = await importPkcs8Ed25519(clientPrivPkcs8);
const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
const preimage = [
'SHiNe/ChannelReadState:',
escapeUserSettingPart(cleanLogin),
escapeUserSettingPart(cleanOwnerBch),
escapeUserSettingPart(cleanChannelName),
String(cleanTimeMs),
String(cleanReadCount),
].join('|');
const signature = await signBase64(privateKey, preimage);
const response = await this.ws.request('SetChannelReadState', {
login: cleanLogin,
owner_bch_name: cleanOwnerBch,
channel_name: cleanChannelName,
read_count: cleanReadCount,
time_ms: cleanTimeMs,
client_key: clientKey,
signature,
});
if (response.status !== 200) throw opError('SetChannelReadState', response);
return response.payload || {};
}
async upsertUserSetting({
login,
settingType,
+3
View File
@@ -696,6 +696,9 @@
.channel-like-popup {
position: fixed;
z-index: 1601;
left: 50%;
top: clamp(72px, 18vh, 180px);
transform: translateX(-50%);
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
+162
View File
@@ -1805,3 +1805,165 @@
.profiles-actions { margin-top: 4px; }
.profiles-close-all { margin-top: 4px; }
/* ===== 2026-09-09: profile layout — identity, social row, about card ===== */
.user-profile-identity {
width: min(88%, 340px);
margin: 12px auto 0;
text-align: center;
}
.user-profile-identity .user-profile-full-name {
width: 100%;
margin: 0;
color: rgba(244, 248, 255, 0.96);
font-size: clamp(17px, 4.8vw, 20px);
font-weight: 700;
line-height: 1.2;
}
.user-profile-login {
margin-top: 4px;
color: rgba(199, 211, 229, 0.62);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
overflow-wrap: anywhere;
}
.user-profile-social-metrics {
width: min(92%, 360px);
margin: 22px auto 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
justify-items: center;
gap: 4px;
}
.user-profile-social-metrics .user-profile-metric {
width: 100%;
min-height: 58px;
gap: 5px;
justify-content: flex-start;
}
.user-profile-social-metrics .user-profile-metric-circle,
.user-profile-metric-value-combined {
width: auto;
min-width: 0;
height: auto;
min-height: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: rgba(240, 246, 255, 0.95);
font-size: clamp(17px, 4.7vw, 20px);
font-weight: 800;
line-height: 1.1;
white-space: nowrap;
}
.user-profile-social-metrics .user-profile-metric-label {
position: static;
width: 100%;
min-height: 0;
transform: none;
color: rgba(203, 216, 236, 0.72);
font-size: 11px;
line-height: 1.15;
text-align: center;
}
.user-profile-about-card {
width: min(88%, 344px);
margin: 20px auto 0;
}
.user-profile-about-title {
margin: 0 0 7px 3px;
color: rgba(224, 233, 247, 0.82);
font-size: 12px;
font-weight: 650;
line-height: 1.2;
}
.user-profile-about-field {
min-height: 68px;
padding: 13px 14px;
border: 1px solid rgba(210, 220, 234, 0.14);
border-radius: 14px;
background: rgba(162, 172, 186, 0.16);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: rgba(236, 242, 251, 0.9);
font-size: 13px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.user-profile-about-field.is-empty {
color: rgba(199, 210, 226, 0.46);
}
.user-profile-detail-links.user-profile-detail-links--below-about {
width: min(88%, 344px);
margin: 10px auto 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 10px;
}
.user-profile-detail-links--below-about > :first-child {
justify-self: start;
}
.user-profile-detail-links--below-about > :last-child {
justify-self: end;
}
.user-profile-detail-links--below-about button {
width: auto;
min-width: 0;
padding-left: 6px;
padding-right: 6px;
}
.profile-list-tabs {
width: min(92%, 360px);
margin: 4px auto 10px;
padding: 3px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 3px;
border: 1px solid rgba(205, 221, 244, 0.12);
border-radius: 14px;
background: rgba(14, 25, 43, 0.24);
}
.profile-list-tab {
min-height: 36px;
padding: 7px 10px;
border: 0;
border-radius: 11px;
background: transparent;
color: rgba(207, 219, 237, 0.72);
font: inherit;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.profile-list-tab.is-active {
background: rgba(156, 177, 204, 0.18);
color: rgba(246, 249, 255, 0.97);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@media (max-height: 700px) {
.user-profile-identity { margin-top: 8px; }
.user-profile-social-metrics { margin-top: 16px; }
.user-profile-about-card { margin-top: 15px; }
}