Merge branch 'Доделать-связи-и-профиль'

This commit is contained in:
AidarKC
2026-09-01 19:07:41 +04:00
12 changed files with 213 additions and 325 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.11.0 client.version=1.11.2
server.version=1.9.0 server.version=1.9.0
+9 -1
View File
@@ -30,6 +30,7 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
import { showToast } from '../services/channels-ux.js'; import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { userDisplayName } from '../services/user-display.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
@@ -127,7 +128,7 @@ function createChatHeaderParts(login, navigate) {
loginEl.setAttribute('role', 'heading'); loginEl.setAttribute('role', 'heading');
loginEl.setAttribute('aria-level', '1'); loginEl.setAttribute('aria-level', '1');
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`); loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
loginEl.textContent = cleanLogin; loginEl.innerHTML = `<span class="chat-header-display-name">${cleanLogin}</span><span class="chat-header-user-login">${cleanLogin}</span>`;
void loadProfileSnapshot(cleanLogin) void loadProfileSnapshot(cleanLogin)
.then((snapshot) => { .then((snapshot) => {
@@ -147,6 +148,13 @@ function createChatHeaderParts(login, navigate) {
title: cleanLogin, title: cleanLogin,
}); });
avatarSlot.replaceChildren(upgradedAvatar); avatarSlot.replaceChildren(upgradedAvatar);
if (loginEl.isConnected) {
const display = userDisplayName({ login: cleanLogin, firstName: snapshot?.firstName, lastName: snapshot?.lastName });
const nameNode = loginEl.querySelector('.chat-header-display-name');
const loginNode = loginEl.querySelector('.chat-header-user-login');
if (nameNode) nameNode.textContent = display;
if (loginNode) loginNode.textContent = cleanLogin;
}
}) })
.catch(() => {}); .catch(() => {});
+25 -63
View File
@@ -11,9 +11,9 @@ import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js'; import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js'; import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { formatRelativeTime } from '../services/channels-ux.js'; import { formatRelativeTime } from '../services/channels-ux.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200; const PREVIEW_MAX_LEN = 200;
@@ -22,71 +22,17 @@ const SVG_CHEVRON = `
<path d="M9 6l6 6-6 6"></path> <path d="M9 6l6 6-6 6"></path>
</svg> </svg>
`; `;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map();
const RELATION_ORDER = new Map([ const RELATION_ORDER = new Map([
['close_friend', 0], ['close_friend', 0],
['contact', 1], ['friend', 1],
['none', 2], ['contact', 2],
['none', 99],
]); ]);
const DM_BLOB_PREVIEW_CACHE = new Map();
async function loadDmAvatarSnapshot(login) { const DM_BLOB_PREVIEW_PENDING = new Map();
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, { className = '' } = {}) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'lg',
title,
className,
});
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: 'lg',
title,
className,
});
upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
function normalizeRelationFlag(value) { function normalizeRelationFlag(value) {
const clean = String(value || '').trim().toLowerCase(); const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'contact') return clean; if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
return 'none'; return 'none';
} }
@@ -98,6 +44,8 @@ function relationLabel(flag) {
switch (normalizeRelationFlag(flag)) { switch (normalizeRelationFlag(flag)) {
case 'close_friend': case 'close_friend':
return 'близкий друг'; return 'близкий друг';
case 'friend':
return 'друг';
case 'contact': case 'contact':
return 'контакт'; return 'контакт';
default: default:
@@ -225,6 +173,7 @@ export function render({ navigate, chrome }) {
const filterLabels = { const filterLabels = {
all: 'Чаты', all: 'Чаты',
close_friend: 'Близкие друзья', close_friend: 'Близкие друзья',
friend: 'Друзья',
contact: 'Контакты', contact: 'Контакты',
none: 'Новые', none: 'Новые',
}; };
@@ -237,6 +186,7 @@ export function render({ navigate, chrome }) {
items: [ items: [
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } }, { label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } }, { label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } }, { label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } }, { label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
], ],
@@ -338,7 +288,14 @@ function renderRow(item) {
const relationBadge = relationFlag === 'none' const relationBadge = relationFlag === 'none'
? 'не в контактах' ? 'не в контактах'
: relationLabel(relationFlag); : relationLabel(relationFlag);
const avatarEl = createDmAvatar(item.peerLogin); const avatarEl = renderUserAvatar({
login: item.peerLogin,
firstName: item.firstName,
lastName: item.lastName,
avatar: item.avatarAr ? { ar: String(item.avatarAr).trim() } : null,
size: 'lg',
title: `Профиль ${item.peerLogin}`,
});
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div'); const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default'; avatarWrap.className = 'dm-av dm-av--default';
@@ -362,7 +319,7 @@ function renderRow(item) {
const titleEl = row.querySelector('.dm-row-title'); const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message'); const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time'); const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = String(item.peerLogin || ''); if (titleEl) titleEl.textContent = userDisplayName(item);
if (previewEl) previewEl.textContent = 'Загрузка…'; if (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs); if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap); row.prepend(avatarWrap);
@@ -399,6 +356,11 @@ function renderRow(item) {
const next = { const next = {
id: peerLogin, id: peerLogin,
peerLogin, peerLogin,
firstName: String(dialog?.firstName || '').trim(),
lastName: String(dialog?.lastName || '').trim(),
avatarAr: String(dialog?.avatarAr || '').trim(),
accountRole: String(dialog?.accountRole || '').trim(),
shineStatus: String(dialog?.shineStatus || '').trim(),
relationFlag, relationFlag,
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''), lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
+9 -1
View File
@@ -5,6 +5,7 @@ import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { createForceGraph } from './network/force-graph.js'; import { createForceGraph } from './network/force-graph.js';
import { engineModelFromGraphModel } from './network/adapter.js'; import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu } from './network/node-menu.js'; import { openNodeMenu } from './network/node-menu.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'network-view', title: 'Связи' }; export const pageMeta = { id: 'network-view', title: 'Связи' };
@@ -99,6 +100,12 @@ function getMarkByLogin(allUsers) {
if (!login) return; if (!login) return;
map.set(normKey(login), { map.set(normKey(login), {
login, login,
firstName: String(row?.firstName || '').trim(),
lastName: String(row?.lastName || '').trim(),
displayName: userDisplayName({ login, firstName: row?.firstName, lastName: row?.lastName }),
relationType: String(row?.relationType || '').trim().toLowerCase(),
primaryConfirmed: Boolean(row?.primaryConfirmed),
shineConfirmed: Boolean(row?.shineConfirmed),
official: Boolean(row?.official), official: Boolean(row?.official),
shine: Boolean(row?.shine), shine: Boolean(row?.shine),
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')), officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
@@ -236,7 +243,7 @@ export function render({ navigate, route, chrome } = {}) {
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам. // Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
const FILTERS = { const FILTERS = {
all: { label: 'Все', pred: () => true }, all: { label: 'Все', pred: () => true },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' }, friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' || n.relationType === 'close_friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
}; };
const FILTER_ORDER = ['all', 'friends', 'shining']; const FILTER_ORDER = ['all', 'friends', 'shining'];
@@ -412,6 +419,7 @@ export function render({ navigate, route, chrome } = {}) {
const login = normalizeLogin(node.login); const login = normalizeLogin(node.login);
openNodeMenu({ openNodeMenu({
login, login,
displayName: String(node.name || '').trim(),
relationType: node.relationType, relationType: node.relationType,
point, point,
actions: [ actions: [
+8 -4
View File
@@ -10,7 +10,10 @@ function normLogin(value) {
const FAMILY_ROLES = ['parent', 'child', 'spouse', 'sibling']; const FAMILY_ROLES = ['parent', 'child', 'spouse', 'sibling'];
function relationTypeFromRole(role) { function relationTypeFromRelation(relation) {
const explicit = String(relation?.mark?.relationType || '').trim().toLowerCase();
if (explicit === 'close_friend' || explicit === 'friend' || explicit === 'contact') return explicit;
const role = relation?.role;
if (FAMILY_ROLES.includes(role)) return 'family'; if (FAMILY_ROLES.includes(role)) return 'family';
if (role === 'friend') return 'friend'; if (role === 'friend') return 'friend';
return 'contact'; return 'contact';
@@ -22,6 +25,7 @@ function relationTypeFromRole(role) {
// - взаимные дружеские связи ближе односторонних. // - взаимные дружеские связи ближе односторонних.
function deriveStrength(relation) { function deriveStrength(relation) {
if (relation.isRelative) return 0.9; if (relation.isRelative) return 0.9;
if (relation?.mark?.relationType === 'close_friend') return 0.9;
if (relation.role === 'contact') return 0.4; if (relation.role === 'contact') return 0.4;
if (relation.forward && relation.backward) return 0.8; if (relation.forward && relation.backward) return 0.8;
return 0.55; return 0.55;
@@ -38,7 +42,7 @@ export function engineModelFromGraphModel(graphModel) {
const focusNode = { const focusNode = {
id: focusLogin, id: focusLogin,
login: focusLogin, login: focusLogin,
name: '', name: centerMark?.displayName || centerMark?.login || focusLogin,
avatar: centerMark?.avatar || null, avatar: centerMark?.avatar || null,
relationType: 'self', relationType: 'self',
strength: 1, strength: 1,
@@ -58,9 +62,9 @@ export function engineModelFromGraphModel(graphModel) {
return { return {
id: login, id: login,
login, login,
name: '', name: r?.mark?.displayName || login,
avatar: r?.mark?.avatar || null, avatar: r?.mark?.avatar || null,
relationType: relationTypeFromRole(r?.role), relationType: relationTypeFromRelation(r),
strength: deriveStrength(r || {}), strength: deriveStrength(r || {}),
shining: Boolean(r?.mark?.shine), shining: Boolean(r?.mark?.shine),
tier: 1, tier: 1,
+2 -1
View File
@@ -86,6 +86,7 @@ const DOUBLE_TAP_MS = 320; // окно двойного тапа по фо
const RELATION_COLORS = { const RELATION_COLORS = {
family: 'rgba(255, 159, 94, 0.92)', family: 'rgba(255, 159, 94, 0.92)',
friend: 'rgba(120, 179, 255, 0.9)', friend: 'rgba(120, 179, 255, 0.9)',
close_friend: 'rgba(120, 179, 255, 0.98)',
business: 'rgba(190, 150, 255, 0.9)', business: 'rgba(190, 150, 255, 0.9)',
contact: 'rgba(170, 190, 220, 0.7)', contact: 'rgba(170, 190, 220, 0.7)',
}; };
@@ -593,7 +594,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function updateA11y() { function updateA11y() {
const focus = nodes.find((n) => n.isFocus); const focus = nodes.find((n) => n.isFocus);
const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus); const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus);
const rel = { family: 'семья', friend: 'друг', business: 'бизнес', contact: 'контакт' }; const rel = { family: 'семья', friend: 'друг', close_friend: 'близкий друг', business: 'бизнес', contact: 'контакт' };
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join(''); const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`; a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`;
} }
+2 -2
View File
@@ -31,7 +31,7 @@ export function relationLabelRu(relationType) {
* @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла * @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла
* @param {Array<{label:string, onClick:Function, disabled?:boolean}>} opts.actions - пункты меню * @param {Array<{label:string, onClick:Function, disabled?:boolean}>} opts.actions - пункты меню
*/ */
export function openNodeMenu({ login, relationType, point, actions = [] } = {}) { export function openNodeMenu({ login, displayName = '', relationType, point, actions = [] } = {}) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return; if (!(root instanceof HTMLElement)) return;
@@ -43,7 +43,7 @@ export function openNodeMenu({ login, relationType, point, actions = [] } = {})
<div class="fg-menu-overlay" id="fg-menu-overlay"> <div class="fg-menu-overlay" id="fg-menu-overlay">
<div class="fg-menu" id="fg-menu" role="menu"> <div class="fg-menu" id="fg-menu" role="menu">
<div class="fg-menu-head"> <div class="fg-menu-head">
<span class="fg-menu-login">${escapeHtml(login)}</span> <span class="fg-menu-login">${escapeHtml(displayName || login)}</span>${displayName && displayName !== login ? `<small class="fg-menu-secondary-login">${escapeHtml(login)}</small>` : ''}
<span class="fg-menu-rel">${escapeHtml(relationLabelRu(relationType))}</span> <span class="fg-menu-rel">${escapeHtml(relationLabelRu(relationType))}</span>
</div> </div>
${itemsHtml} ${itemsHtml}
+134 -247
View File
@@ -1,27 +1,15 @@
import { profile } from '../mock-data.js'; import { profile } from '../mock-data.js';
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { import {
PROFILE_GENDER_FEMALE,
PROFILE_GENDER_MALE,
loadProfileSnapshot, loadProfileSnapshot,
} from '../services/user-profile-params.js'; } from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js'; import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createDropdownMenu } from '../components/dropdown-menu.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' }; export const pageMeta = { id: 'profile-view', title: 'Профиль' };
function toggleText(enabled) {
return enabled ? 'Yes' : 'No';
}
function genderLabel(value) {
if (value === PROFILE_GENDER_MALE) return 'Мужской';
if (value === PROFILE_GENDER_FEMALE) return 'Женский';
return 'Не указан';
}
function escapeHtml(text) { function escapeHtml(text) {
return String(text || '') return String(text || '')
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -31,72 +19,57 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
function renderProfileInfoText(text) { function fieldMap(snapshot) {
const [intro, details] = String(text || '').split(/\n\n/, 2); const out = {};
if (!details) { (Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
return `<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>`; out[String(field?.key || '').trim()] = String(field?.value || '').trim();
});
return out;
} }
const [sectionTitle, ...items] = details.split('\n').filter(Boolean); function openTextModal(title, text) {
return `
<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>
<section class="profile-info-modal__section" aria-label="${escapeHtml(sectionTitle)}">
<p class="profile-info-modal__section-title">${escapeHtml(sectionTitle)}</p>
<ol class="profile-info-modal__list">
${items.map((item) => `<li>${escapeHtml(item.replace(/^\d+\)\s*/, ''))}</li>`).join('')}
</ol>
</section>
`;
}
function openProfileInfoModal({ title, text }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
if (!root) return; if (!root) return;
root.innerHTML = ` root.innerHTML = `
<div class="modal profile-info-modal" id="profile-info-modal" role="dialog" aria-modal="true" aria-labelledby="profile-info-title"> <div class="modal" id="profile-text-modal">
<section class="modal-card profile-info-modal__card stack"> <div class="modal-card stack">
<header class="profile-info-modal__header"> <h3>${escapeHtml(title)}</h3>
<div> <div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text || 'Не заполнено')}</div>
<p class="profile-info-modal__eyebrow">Информация</p> <button class="secondary-btn" id="profile-text-close">Закрыть</button>
<h3 class="modal-title profile-info-modal__title" id="profile-info-title">${escapeHtml(title)}</h3>
</div> </div>
<button class="profile-info-modal__close" type="button" id="profile-info-close-icon" aria-label="Закрыть" title="Закрыть">&times;</button> </div>`;
</header>
<div class="profile-info-modal__content">
${renderProfileInfoText(text)}
</div>
<footer class="profile-info-modal__footer">
<button class="secondary-btn profile-info-modal__confirm" type="button" id="profile-info-close">Готово</button>
</footer>
</section>
</div>
`;
const close = () => { root.innerHTML = ''; }; const close = () => { root.innerHTML = ''; };
root.querySelector('#profile-info-close')?.addEventListener('click', close); root.querySelector('#profile-text-close')?.addEventListener('click', close);
root.querySelector('#profile-info-close-icon')?.addEventListener('click', close); root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => { if (event.target?.id === 'profile-text-modal') close();
if (event.target?.id === 'profile-info-modal') close();
}); });
} }
function officialInfoText() { function statusBadges(accountRole, shineStatus) {
return 'Можно создавать несколько альтернативных или анонимных каналов. ' const role = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
+ 'Но для корректного учёта голосов на одного реального человека используется только один основной аккаунт.'; const shine = shineStatus === 'shining' ? 'Сияющий' : '';
return `<div class="row wrap-row">
${role ? `<span class="badge">${escapeHtml(role)}</span>` : ''}
${shine ? '<span class="badge is-yes-shine">Сияющий</span>' : ''}
${shineStatus === 'not_interested' ? '<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>' : ''}
</div>`;
} }
function shineInfoText() { function statsRows(stats = {}) {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n' return [
+ 'Пять принципов сияющих:\n' ['friends', 'Друзья', stats.friendsCount],
+ '1) сияющие не обманывают;\n' ['close_friends', 'Близкие друзья', stats.closeFriendsCount],
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n' ['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n' ['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n' ['shine_received', 'Считают сияющим', stats.shineReceivedCount],
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.'; ['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
];
} }
export function render({ navigate, chrome }) { export function render({ navigate, chrome }) {
const login = state.session.login || profile.login; const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack profile-screen'; screen.className = 'stack profile-screen';
@@ -106,15 +79,12 @@ export function render({ navigate, chrome }) {
<div class="header-left" aria-hidden="true"></div> <div class="header-left" aria-hidden="true"></div>
<div class="header-center"><h1 class="page-title">Профиль</h1></div> <div class="header-center"><h1 class="page-title">Профиль</h1></div>
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap"> <div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"> <button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
</button> </div>`;
</div> const menuButton = topbar.querySelector('.profile-head-menu-btn');
`; menuButton?.append(createOverflowDots());
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
profileMenuButton?.append(createOverflowDots());
const profileMenu = createDropdownMenu({ const profileMenu = createDropdownMenu({
anchorEl: profileMenuButton, anchorEl: menuButton,
className: 'profile-head-menu', className: 'profile-head-menu',
minWidth: 230, minWidth: 230,
items: [ items: [
@@ -123,197 +93,114 @@ export function render({ navigate, chrome }) {
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') }, { label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
], ],
}); });
chrome?.setTopbar(topbar); chrome?.setTopbar(topbar);
const card = document.createElement('div'); const status = document.createElement('div');
card.className = 'card stack profile-main-card'; status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'stack';
screen.append(status, body);
const topRow = document.createElement('div'); let current = null;
topRow.className = 'row';
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div data-profile-avatar-slot="true"></div>
<div class="profile-identity-lines" data-profile-identity="true">
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
</div>
`;
const badgesRow = document.createElement('div'); function renderProfile() {
badgesRow.className = 'row'; if (!current) return;
badgesRow.innerHTML = ` const { snapshot, user } = current;
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button> const fields = fieldMap(snapshot);
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button> const firstName = fields.first_name || '';
`; const lastName = fields.last_name || '';
const displayName = userDisplayName({ login, firstName, lastName });
const listWrap = document.createElement('div'); const avatar = snapshot?.avatar?.txId
listWrap.className = 'stack profile-param-list'; ? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
: null;
const accountRoleBtn = badgesRow.querySelector('[data-status="account_role"]'); const stats = {
const shineBtn = badgesRow.querySelector('[data-status="shine"]'); ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
const identityEl = topRow.querySelector('[data-profile-identity="true"]'); followingChannelsCount: Number(user?.followingChannelsCount || 0),
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]'); friendsCount: Number(user?.friendsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
let currentFields = []; primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
let currentAccountRole = ''; primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
let currentShineStatus = ''; shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
let currentGender = 'unknown'; shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
let currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
}; };
function syncIdentity() { body.innerHTML = '';
if (!identityEl) return; const identity = document.createElement('div');
const firstName = currentFields.find((field) => field.key === 'first_name')?.value || ''; identity.className = 'card row';
const lastName = currentFields.find((field) => field.key === 'last_name')?.value || ''; identity.style.gap = '12px';
const lines = buildIdentityLines({ login, firstName, lastName }); identity.style.alignItems = 'center';
identityEl.innerHTML = lines.map((line, idx) => ( identity.append(renderUserAvatar({ login, firstName, lastName, avatar, size: 'xl', className: 'profile-avatar' }));
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>` const identityText = document.createElement('div');
)).join(''); identityText.innerHTML = `<div class="profile-identity-line">${escapeHtml(displayName)}</div><div class="profile-identity-login">${escapeHtml(login)}</div>`;
identity.append(identityText);
body.append(identity);
const badges = document.createElement('div');
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
body.append(...badges.children);
if (fields.about) {
const about = document.createElement('div');
about.className = 'card profile-about';
about.style.whiteSpace = 'pre-wrap';
about.textContent = fields.about;
body.append(about);
} }
function updateAvatarUi() { const statsGrid = document.createElement('div');
if (!(avatarSlotEl instanceof HTMLElement)) return; statsGrid.className = 'profile-stats-grid';
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim(); statsRows(stats).forEach(([kind, label, value]) => {
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim(); const button = document.createElement('button');
avatarSlotEl.innerHTML = ''; button.type = 'button';
avatarSlotEl.append(renderUserAvatar({ button.className = 'card profile-stat-card';
login, button.dataset.profileList = kind;
firstName, button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
lastName, statsGrid.append(button);
avatar: currentAvatar?.txId
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
: null,
size: 'xl',
className: 'profile-avatar',
}));
}
function updateStatusesUi() {
if (accountRoleBtn) {
const label = currentAccountRole === 'primary' ? 'Основной аккаунт' : currentAccountRole === 'non_voting' ? 'Не учитывать мой голос' : 'Не указано';
accountRoleBtn.textContent = `Аккаунт: ${label}`;
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
accountRoleBtn.classList.add(currentAccountRole === 'primary' ? 'is-yes-official' : 'is-no');
}
if (shineBtn) {
const label = currentShineStatus === 'shining' ? 'Сияющий' : currentShineStatus === 'not_interested' ? 'Сияние неинтересно' : currentShineStatus === 'unknown' ? 'Неизвестно' : 'Не указано';
shineBtn.textContent = `Сияние: ${label}`;
shineBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
shineBtn.classList.add(currentShineStatus === 'shining' ? 'is-yes-shine' : currentShineStatus === 'not_interested' ? 'is-not-interested' : 'is-no');
}
}
function renderStats() {
const stats = [
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
];
stats.forEach((stat) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
listWrap.append(row);
}); });
body.append(statsGrid);
const detailRow = document.createElement('div');
detailRow.className = 'row wrap-row';
detailRow.innerHTML = '<button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button>';
body.append(detailRow);
} }
accountRoleBtn?.classList.add('profile-badge-trigger'); body.addEventListener('click', (event) => {
shineBtn?.classList.add('profile-badge-trigger'); if (!current) return;
accountRoleBtn?.addEventListener('click', () => { const el = event.target.closest('[data-profile-list],[data-profile-detail]');
openProfileInfoModal({ if (!el) return;
title: 'Основной аккаунт', const kind = el.dataset.profileList;
text: officialInfoText(), if (kind) {
}); navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
});
shineBtn?.addEventListener('click', () => {
openProfileInfoModal({
title: 'Справка о сияющих',
text: shineInfoText(),
});
});
function renderFields(fields) {
listWrap.innerHTML = '';
renderStats();
fields.forEach((field) => {
const row = document.createElement('div');
row.className = 'card profile-param-item row';
const value = String(field.value || '').trim() || 'не заполнено';
row.innerHTML = `<div class="profile-param-value"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
listWrap.append(row);
if (field.key === 'last_name') {
const genderRow = document.createElement('div');
genderRow.className = 'card profile-param-item row';
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: ${escapeHtml(genderLabel(currentGender))}</div>`;
listWrap.append(genderRow);
}
});
}
async function refreshProfileSnapshot() {
if (state.session.isLocalDemo) {
currentFields = [
{ key: 'first_name', label: 'Имя', value: 'Тестовый' },
{ key: 'last_name', label: 'Фамилия', value: 'Пользователь' },
{ key: 'address', label: 'Адрес', value: 'Локальный режим' },
{ key: 'website', label: 'Веб', value: '127.0.0.1' },
{ key: 'phone', label: 'Телефон', value: profile.phone },
];
currentAccountRole = '';
currentShineStatus = '';
currentGender = 'unknown';
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: 0,
followingUsersCount: 0,
followingChannelsCount: 0,
closeFriendsCount: 0,
};
syncIdentity();
updateAvatarUi();
updateStatusesUi();
renderFields(currentFields);
return; return;
} }
const fields = fieldMap(current.snapshot);
try { if (el.dataset.profileDetail === 'contacts') {
const [snapshot, user] = await Promise.all([ openTextModal('Контакты', [
loadProfileSnapshot(login), fields.web ? `Links: ${fields.web}` : '',
authService.getUser(login).catch(() => ({})), fields.phone ? `Телефон: ${fields.phone}` : '',
]); fields.address ? `Адрес: ${fields.address}` : '',
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : []; ].filter(Boolean).join('\n') || 'Не заполнено');
currentAccountRole = snapshot.accountRole || '';
currentShineStatus = snapshot.shineStatus || '';
currentGender = snapshot.gender || 'unknown';
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
currentStats = {
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
followingUsersCount: Number(user?.followingUsersCount || 0),
followingChannelsCount: Number(user?.followingChannelsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
};
syncIdentity();
updateAvatarUi();
updateStatusesUi();
renderFields(currentFields);
} catch (error) {
// ignore status row in profile-view
} }
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
});
async function refresh() {
if (state.session.isLocalDemo) {
status.textContent = 'Локальный тестовый режим.';
return;
}
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
current = { snapshot, user };
renderProfile();
status.textContent = '';
} }
card.append(topRow, badgesRow, listWrap); refresh().catch((error) => {
screen.append(card); status.className = 'status-line is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
updateAvatarUi(); });
refreshProfileSnapshot();
screen.cleanup = () => profileMenu.destroy(); screen.cleanup = () => profileMenu.destroy();
return screen; return screen;
+9 -2
View File
@@ -2,6 +2,7 @@ import { renderHeader } from '../components/header.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { authService } from '../state.js'; import { authService } from '../state.js';
import { navigateBack } from '../router.js'; import { navigateBack } from '../router.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'user-profile-list-view', title: 'Список' }; export const pageMeta = { id: 'user-profile-list-view', title: 'Список' };
@@ -47,9 +48,15 @@ export function render({ navigate, route }) {
rows.forEach((row) => { rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row'; const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' })); el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
const fullName = [row.firstName, row.lastName].filter(Boolean).join(' ') || row.login; const fullName = userDisplayName(row);
const t = document.createElement('div'); t.className = 'profile-list-row-text'; const t = document.createElement('div'); t.className = 'profile-list-row-text';
const marks = [row.relationType && row.relationType !== 'none' ? row.relationType : '', row.primaryConfirmed ? 'основной ✓' : '', row.shineConfirmed ? 'сияющий ✓' : ''].filter(Boolean).join(' · '); 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>`; 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); el.append(t); el.addEventListener('click', () => navigate(`SHiNE/${encodeURIComponent(row.login)}`)); body.append(el);
}); });
+6
View File
@@ -0,0 +1,6 @@
export function userDisplayName({ login = '', firstName = '', lastName = '' } = {}) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
const full = [first, last].filter(Boolean).join(' ').trim();
return full || String(login || '').trim() || 'unknown';
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const profileFieldDefs = [
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' }, { key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' }, { key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
{ key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' }, { key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' },
{ key: 'web', readKeys: ['web'], label: 'Веб', placeholder: 'Сайт или профиль' }, { key: 'web', readKeys: ['web'], label: 'Links', placeholder: 'Сайт, профиль или другая ссылка' },
{ key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' }, { key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' },
{ key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true }, { key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true },
{ key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true }, { key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true },
+5
View File
@@ -10683,3 +10683,8 @@ body.chat-topbar-overlay .composer-slot {
rgba(0, 0, 0, 0.96) 100% rgba(0, 0, 0, 0.96) 100%
); );
} }
/* DM header: human name is primary, login remains secondary. */
.chat-header-login-btn{display:flex;flex-direction:column;align-items:flex-start;line-height:1.1}
.chat-header-display-name{font-size:14px;font-weight:650}
.chat-header-user-login{font-size:11px;font-weight:400;opacity:.68;margin-top:2px}