merge: add-web-push into main

# Conflicts:
#	shine-UI/js/router.js
#	shine-UI/js/services/auth-service.js
#	shine-UI/js/state.js
This commit is contained in:
AidarKC
2026-04-14 22:35:28 +03:00
35 changed files with 2051 additions and 336 deletions
+96
View File
@@ -0,0 +1,96 @@
import { renderHeader } from '../components/header.js';
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
function formatTime(ts) {
try {
return new Date(ts).toLocaleTimeString('ru-RU');
} catch {
return String(ts || '');
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Лог приложения',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const controls = document.createElement('div');
controls.className = 'card row';
controls.style.justifyContent = 'space-between';
controls.style.gap = '8px';
controls.innerHTML = `
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
`;
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка лога...';
const list = document.createElement('div');
list.className = 'stack';
function renderEntries() {
const entries = getAppLogEntries();
list.innerHTML = '';
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Лог пока пуст.';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Записей: 0';
return;
}
entries.slice().reverse().forEach((entry) => {
const row = document.createElement('div');
row.className = 'card stack';
const level = String(entry.level || 'info').toUpperCase();
const source = String(entry.source || 'ui');
const details = String(entry.details || '').trim();
const head = document.createElement('div');
head.className = 'meta-muted';
head.textContent = `[${formatTime(entry.ts)}] ${level} ${source}`;
const message = document.createElement('div');
message.textContent = entry.message || '';
row.append(head, message);
if (details) {
const detailsNode = document.createElement('pre');
detailsNode.className = 'meta-muted';
detailsNode.style.whiteSpace = 'pre-wrap';
detailsNode.style.margin = '0';
detailsNode.textContent = details;
row.append(detailsNode);
}
list.append(row);
});
status.className = 'status-line is-available';
status.textContent = `Записей: ${entries.length}`;
}
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
controls.querySelector('[data-action="clear"]').addEventListener('click', () => {
clearAppLogEntries();
renderEntries();
});
screen.append(controls, status, list);
renderEntries();
return screen;
}
+13 -18
View File
@@ -31,26 +31,17 @@ export function render({ navigate, route }) {
renderHeader({
title: `Чат: ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{
label: 'Позвонить',
onClick: () => {
const confirmed = window.confirm('Позвонить этому пользователю?');
if (!confirmed) return;
window.alert('Функция пока не реализована');
},
}],
})
);
const isContact = state.contacts.includes(chatId);
if (!isContact) {
const warning = document.createElement('div');
warning.className = 'card stack';
warning.innerHTML = '<p class="meta-muted">Пользователь не в контактах. Можно писать ему сразу (MVP).</p>';
const btn = document.createElement('button');
btn.className = 'primary-btn';
btn.type = 'button';
btn.textContent = 'Добавить в контакты';
btn.addEventListener('click', () => {
state.contacts = [...state.contacts, chatId];
warning.remove();
});
warning.append(btn);
screen.append(warning);
}
const wrap = document.createElement('div');
wrap.className = 'chat-wrap';
@@ -75,7 +66,11 @@ export function render({ navigate, route }) {
renderLog(log, chatId);
try {
await authService.sendDirectMessage(chatId, text);
await authService.sendDirectMessage({
toLogin: chatId,
text,
storagePwd: state.session.storagePwdInMemory,
});
} catch (e) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
renderLog(log, chatId);
+13 -41
View File
@@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import { authService, ensureChat, setContacts, state } from '../state.js';
import { authService } from '../state.js';
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
@@ -26,10 +25,7 @@ export function render({ navigate }) {
const resultsList = document.createElement('div');
resultsList.className = 'stack';
let latestMatches = [];
const renderResults = (matches, query) => {
latestMatches = matches;
resultsList.innerHTML = '';
resultsCard.hidden = false;
@@ -56,6 +52,9 @@ export function render({ navigate }) {
</div>
<div class="meta-muted">Профиль</div>
`;
row.addEventListener('click', () => {
navigate(`user-profile-view/${encodeURIComponent(login)}/contact-search-view`);
});
resultsList.append(row);
});
};
@@ -65,51 +64,24 @@ export function render({ navigate }) {
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
const query = input.value.trim();
if (!query) {
renderResults([], '');
return;
}
try {
const logins = await authService.searchUsers(input.value.trim());
renderResults(logins, input.value);
const logins = await authService.searchUsers(query);
renderResults((logins || []).slice(0, 5), query);
} catch (e) {
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
resultsCard.hidden = false;
}
});
const addButton = document.createElement('button');
addButton.className = 'ghost-btn';
addButton.type = 'button';
addButton.textContent = 'Открыть чат';
addButton.addEventListener('click', () => {
if (!latestMatches.length) {
status.textContent = 'Сначала выполните поиск.';
resultsCard.hidden = false;
return;
}
const login = latestMatches[0];
const exists = directMessages.some((item) => item.id === login);
if (!exists) {
directMessages.unshift({
id: login,
name: login,
initials: (login[0] || '?').toUpperCase(),
lastMessage: 'Диалог создан. Пользователь пока не в контактах.',
time: 'сейчас',
unread: 0,
});
}
if (!state.contacts.includes(login)) {
setContacts([...state.contacts, login]);
}
ensureChat(login);
navigate(`chat-view/${login}`);
});
const controls = document.createElement('div');
controls.className = 'contact-search-actions';
controls.append(searchButton, addButton);
controls.append(searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack';
+55 -5
View File
@@ -1,5 +1,7 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import { getChatMessages } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
@@ -16,8 +18,11 @@ export function render({ navigate }) {
const list = document.createElement('div');
list.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка списка сообщений...';
directMessages.forEach((item) => {
function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
@@ -33,10 +38,55 @@ export function render({ navigate }) {
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
</div>
`;
row.addEventListener('click', () => navigate(`chat-view/${item.id}`));
list.append(row);
});
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
return row;
}
screen.append(list);
async function loadList() {
try {
const relations = await loadCurrentRelations();
const contacts = relations.outContacts || [];
list.innerHTML = '';
if (!contacts.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ваш список контактов пока пуст';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Нет контактов.';
return;
}
const rows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
return {
id: login,
initials: (login[0] || '?').toUpperCase(),
name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: preview?.time || '—',
unread: Number(preview?.unread || 0),
};
});
rows.forEach((item) => list.append(renderRow(item)));
status.className = 'status-line is-available';
status.textContent = `Загружено диалогов: ${rows.length}`;
} catch (error) {
list.innerHTML = '';
const fail = document.createElement('div');
fail.className = 'card meta-muted';
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
list.append(fail);
status.className = 'status-line is-unavailable';
status.textContent = 'Список недоступен.';
}
}
screen.append(status, list);
loadList();
return screen;
}
+112 -70
View File
@@ -6,70 +6,16 @@ export const pageMeta = { id: 'network-view', title: 'Связи' };
function makeNode(name, cls = '') {
const n = document.createElement('div');
n.className = `node ${cls}`.trim();
n.dataset.nodeLogin = name;
n.innerHTML = `<div class="node-dot">${(name[0] || '?').toUpperCase()}</div><div class="node-label">${name}</div>`;
return n;
}
function showAddCloseFriendModal({ onAdded }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="close-friend-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">Добавить близкого друга</h3>
<input class="input" id="close-friend-query" placeholder="Логин или начало логина" maxlength="80" />
<div class="row" style="gap:8px;">
<button class="primary-btn" id="close-friend-search">Поиск</button>
<button class="ghost-btn" id="close-friend-back">Назад</button>
</div>
<div class="stack" id="close-friend-results"></div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#close-friend-back').addEventListener('click', close);
root.querySelector('#close-friend-search').addEventListener('click', async () => {
const query = root.querySelector('#close-friend-query').value.trim();
const holder = root.querySelector('#close-friend-results');
holder.innerHTML = '<p class="meta-muted">Поиск...</p>';
try {
const logins = await authService.searchUsers(query);
holder.innerHTML = '';
if (!logins.length) {
holder.innerHTML = '<p class="meta-muted">Пользователи не найдены.</p>';
return;
}
logins.forEach((login) => {
const row = document.createElement('article');
row.className = 'list-item';
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.addEventListener('click', async () => {
const yes = window.confirm(`Добавить ${login} в близкие друзья?`);
if (!yes) return;
try {
await authService.addCloseFriend(login);
close();
if (typeof onAdded === 'function') await onAdded();
} catch (e) {
window.alert(`Ошибка добавления: ${e.message || 'unknown'}`);
}
});
holder.append(row);
});
} catch (e) {
holder.innerHTML = `<p class="meta-muted">Ошибка поиска: ${e.message || 'unknown'}</p>`;
}
});
function unique(list) {
return [...new Set((Array.isArray(list) ? list : []).filter(Boolean))];
}
export function render() {
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -81,16 +27,99 @@ export function render() {
note.className = 'meta-muted';
note.textContent = 'Загрузка связей...';
const load = async (centerLogin) => {
let activeMenu = null;
let centerLogin = state.session.login || '';
function closeNodeMenu() {
if (!activeMenu) return;
activeMenu.remove();
activeMenu = null;
}
function openNodeMenu(node, login) {
closeNodeMenu();
const menu = document.createElement('div');
menu.className = 'node-menu card';
menu.innerHTML = '<button class="ghost-btn" type="button">Показать информацию о пользователе</button>';
const rect = node.getBoundingClientRect();
const boardRect = board.getBoundingClientRect();
const x = rect.left + rect.width / 2 - boardRect.left;
const y = rect.bottom - boardRect.top + 8;
menu.style.left = `${Math.max(8, Math.min(x - 120, boardRect.width - 248))}px`;
menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`;
const btn = menu.querySelector('button');
btn.addEventListener('click', () => {
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
closeNodeMenu();
});
board.append(menu);
activeMenu = menu;
}
function bindNodeInteraction(node, login, onLongPress) {
let timerId = 0;
let startX = 0;
let startY = 0;
let longPressTriggered = false;
const clearTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
timerId = 0;
}
};
node.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
startX = event.clientX;
startY = event.clientY;
longPressTriggered = false;
clearTimer();
timerId = window.setTimeout(async () => {
longPressTriggered = true;
closeNodeMenu();
await onLongPress(login);
}, 500);
});
node.addEventListener('pointermove', (event) => {
if (!timerId) return;
const dx = Math.abs(event.clientX - startX);
const dy = Math.abs(event.clientY - startY);
if (dx > 8 || dy > 8) clearTimer();
});
node.addEventListener('pointerleave', clearTimer);
node.addEventListener('pointercancel', clearTimer);
node.addEventListener('pointerup', (event) => {
if (event.button !== 0) return;
clearTimer();
if (longPressTriggered) return;
openNodeMenu(node, login);
});
}
async function load(nextCenterLogin = '') {
const targetCenter = nextCenterLogin || centerLogin || state.session.login;
centerLogin = targetCenter;
closeNodeMenu();
note.textContent = 'Загрузка связей...';
try {
const graph = await authService.getUserConnectionsGraph(centerLogin || state.session.login);
const graph = await authService.getUserConnectionsGraph(targetCenter);
board.innerHTML = '';
const center = makeNode(graph.login || state.session.login, 'center');
const center = makeNode(graph.login || targetCenter, 'center');
center.style.left = '50%';
center.style.top = '50%';
board.append(center);
const all = [...new Set([...(graph.outFriends || []), ...(graph.inFriends || [])])];
const all = unique([...(graph.outFriends || []), ...(graph.inFriends || [])]);
const left = all.slice(0, Math.ceil(all.length / 2));
const right = all.slice(Math.ceil(all.length / 2));
@@ -99,7 +128,7 @@ export function render() {
const y = 15 + ((idx + 1) * 70) / (Math.max(total, 1) + 1);
node.style.left = side === 'left' ? '20%' : '80%';
node.style.top = `${y}%`;
node.addEventListener('click', () => load(name));
bindNodeInteraction(node, name, load);
board.append(node);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
@@ -121,20 +150,33 @@ export function render() {
right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length)));
board.prepend(svg);
note.textContent = 'Нажмите на узел, чтобы перестроить связи вокруг выбранного пользователя.';
note.textContent = 'Тап по узлу: информация о пользователе. Долгое нажатие: центрировать граф.';
} catch (e) {
note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`;
}
}
const outsideTapHandler = (event) => {
if (!activeMenu) return;
if (!(event.target instanceof Node)) return;
if (activeMenu.contains(event.target)) return;
closeNodeMenu();
};
document.addEventListener('pointerdown', outsideTapHandler, true);
screen.cleanup = () => {
document.removeEventListener('pointerdown', outsideTapHandler, true);
};
const addBtn = document.createElement('button');
addBtn.className = 'primary-btn';
addBtn.type = 'button';
addBtn.textContent = 'Добавить близкого друга';
addBtn.addEventListener('click', () => showAddCloseFriendModal({ onAdded: () => load() }));
board.addEventListener('pointerdown', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('.node')) return;
if (target.closest('.node-menu')) return;
closeNodeMenu();
});
load();
screen.append(renderHeader({ title: 'Связи' }), addBtn, board, note);
screen.append(renderHeader({ title: 'Связи' }), board, note);
return screen;
}
+24 -3
View File
@@ -6,6 +6,7 @@ import {
saveProfileParamBlock,
saveProfileToggle,
} from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -19,9 +20,17 @@ function showLocalErrorAlert(prefix, error) {
window.alert(`${prefix}: ${message}${stack}`);
}
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function render({ navigate }) {
const login = state.session.login || profile.login;
const displayLogin = String(login || '').toUpperCase();
const screen = document.createElement('section');
screen.className = 'stack';
@@ -44,8 +53,8 @@ export function render({ navigate }) {
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${profile.avatarInitials}</div>
<div>
<h2 style="font-size:22px; margin-bottom:2px;" data-profile-login="true">${displayLogin}</h2>
<div class="profile-identity-lines" data-profile-identity="true">
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
</div>
<button class="primary-btn" type="button" data-reload="true">Обновить</button>
@@ -71,6 +80,17 @@ export function render({ navigate }) {
let currentFields = [];
let currentToggles = [];
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
function syncIdentity() {
if (!identityEl) return;
const firstName = currentFields.find((field) => field.key === 'first_name')?.value || '';
const lastName = currentFields.find((field) => field.key === 'last_name')?.value || '';
const lines = buildIdentityLines({ login, firstName, lastName });
identityEl.innerHTML = lines.map((line, idx) => (
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
)).join('');
}
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
@@ -123,6 +143,7 @@ export function render({ navigate }) {
currentFields = snapshot.fields;
currentToggles = snapshot.toggles;
syncIdentity();
renderFields(currentFields);
updateTogglesUi();
+2
View File
@@ -19,11 +19,13 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-app-log').addEventListener('click', () => navigate('app-log-view'));
screen.append(card);
return screen;
+243
View File
@@ -0,0 +1,243 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
buildAvatarInitials,
buildIdentityLines,
loadRelationsForPair,
loadUserProfileCard,
} from '../services/user-connections.js';
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function boolText(flag) {
return flag ? 'Да' : 'Нет';
}
function relationButtonLabel(kind, flags) {
if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
if (kind === 'friend') return flags.outFriend ? 'Убрать из друзей' : 'Добавить в друзья';
return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
}
function relationNextState(kind, flags) {
if (kind === 'follow') return !flags.outFollow;
if (kind === 'friend') return !flags.outFriend;
return !flags.outContact;
}
function relationConfirmLabel(kind) {
if (kind === 'follow') return 'подписку';
if (kind === 'friend') return 'дружбу';
return 'контакт';
}
function renderIdentity(card) {
const lines = buildIdentityLines({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
});
return `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${escapeHtml(buildAvatarInitials(card))}</div>
<div class="profile-identity-lines">
${lines.map((line, idx) => (
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
)).join('')}
</div>
</div>
`;
}
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>
</div>
`;
}
function renderRelations(flags) {
return `
<div class="card stack user-relations-list">
<div class="user-rel-row"><span>Вы подписаны:</span><strong>${boolText(flags.outFollow)}</strong></div>
<div class="user-rel-row"><span>Подписан на вас:</span><strong>${boolText(flags.inFollow)}</strong></div>
<div class="user-rel-row"><span>Вы добавили в друзья:</span><strong>${boolText(flags.outFriend)}</strong></div>
<div class="user-rel-row"><span>Добавил вас в друзья:</span><strong>${boolText(flags.inFriend)}</strong></div>
<div class="user-rel-row"><span>Вы добавили в контакты:</span><strong>${boolText(flags.outContact)}</strong></div>
<div class="user-rel-row"><span>Добавил вас в контакты:</span><strong>${boolText(flags.inContact)}</strong></div>
</div>
`;
}
function renderReadOnlyParams(card) {
const rows = [
{ label: 'Имя', value: card.firstName },
{ label: 'Фамилия', value: card.lastName },
{ label: 'Адрес', value: card.address },
{ label: 'Web', value: card.web },
{ label: 'Телефон', value: card.phone },
];
return `
<div class="card stack profile-param-list">
${rows.map((row) => `
<div class="card profile-param-item row">
<div class="profile-param-value"><b>${row.label}</b>: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}</div>
</div>
`).join('')}
</div>
`;
}
export function render({ navigate, route }) {
const requestedLogin = String(route.params.login || '').trim();
const fromPage = String(route.params.fromPage || 'messages-list').trim() || 'messages-list';
const sessionLogin = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'stack';
screen.append(
renderHeader({
title: 'Профиль пользователя',
leftAction: { label: '←', onClick: () => navigate(fromPage) },
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
}),
status,
body,
);
let currentCard = null;
let currentFlags = null;
let isBusy = false;
function syncActionButtons() {
const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]');
if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
followBtn.textContent = relationButtonLabel('follow', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
followBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
contactBtn.disabled = Boolean(isSelf);
}
async function refresh() {
if (!requestedLogin) {
status.className = 'status-line is-unavailable';
status.textContent = 'Не передан login пользователя.';
return;
}
isBusy = true;
status.className = 'status-line';
status.textContent = 'Загрузка профиля...';
try {
const card = await loadUserProfileCard(requestedLogin);
const flags = await loadRelationsForPair({
currentLogin: sessionLogin,
targetLogin: card.login,
});
currentCard = card;
currentFlags = flags;
body.innerHTML = `
<div class="card stack">
${renderIdentity(card)}
</div>
${renderReadOnlyBadges(card)}
${renderRelations(flags)}
${renderReadOnlyParams(card)}
<div class="stack">
<button class="primary-btn" type="button" data-relation-action="follow"></button>
<button class="ghost-btn" type="button" data-relation-action="friend"></button>
<button class="ghost-btn" type="button" data-relation-action="contact"></button>
</div>
`;
syncActionButtons();
status.className = 'status-line is-available';
status.textContent = 'Профиль обновлён.';
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`;
window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`);
} finally {
isBusy = false;
}
}
async function onRelationAction(kind) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm(
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
'Будет отправлен AddBlock CONNECTION.',
);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind,
enabled: nextEnabled,
storagePwd: state.session.storagePwdInMemory,
});
await refresh();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
body.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const kind = target.dataset.relationAction;
if (!kind) return;
onRelationAction(kind);
});
refresh();
return screen;
}