12 -04-2026
Сделал отдельную ветку для ai
This commit is contained in:
parent
ad45e005f5
commit
1ee2a1cf62
@ -39,6 +39,7 @@ import * as languageView from './pages/language-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as channelsList from './pages/channels-list.js';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
@ -70,6 +71,7 @@ const routes = {
|
||||
'messages-list': messagesList,
|
||||
'contact-search-view': contactSearchView,
|
||||
'chat-view': chatView,
|
||||
'user-profile-view': userProfileView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'add-channel-view': addChannelView,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import { addChatMessage, getChatMessages, authService, state } from '../state.js';
|
||||
import { addChatMessage, getChatMessages, authService } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 follows = relations.outFollows || [];
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!follows.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 = follows.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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
243
shine-UI/js/pages/user-profile-view.js
Normal file
243
shine-UI/js/pages/user-profile-view.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -19,18 +19,28 @@ export function getRoute() {
|
||||
return { pageId: '', params: {} };
|
||||
}
|
||||
|
||||
const [pageId, dynamicId] = raw.split('/');
|
||||
const [pageId, dynamicId, extraId] = raw.split('/');
|
||||
|
||||
if (pageId === 'chat-view') {
|
||||
return { pageId, params: { chatId: dynamicId || '' } };
|
||||
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'channel-view') {
|
||||
return { pageId, params: { channelId: dynamicId || '' } };
|
||||
return { pageId, params: { channelId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'device-session-view') {
|
||||
return { pageId, params: { sessionId: dynamicId || '' } };
|
||||
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||
}
|
||||
|
||||
if (pageId === 'user-profile-view') {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
login: dynamicId ? decodeURIComponent(dynamicId) : '',
|
||||
fromPage: extraId ? decodeURIComponent(extraId) : 'messages-list',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { pageId, params: {} };
|
||||
@ -57,6 +67,7 @@ export function resolveToolbarActive(pageId) {
|
||||
return 'profile-view';
|
||||
}
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'user-profile-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
@ -20,6 +20,13 @@ import {
|
||||
} from './key-vault.js';
|
||||
|
||||
const BCH_SUFFIX = '001';
|
||||
const ZERO_HASH_HEX = '0'.repeat(64);
|
||||
|
||||
const CONNECTION_SUBTYPES = Object.freeze({
|
||||
friend: { on: 10, off: 11 },
|
||||
contact: { on: 20, off: 21 },
|
||||
follow: { on: 30, off: 31 },
|
||||
});
|
||||
|
||||
function normalizeServerUrl(url) {
|
||||
const value = (url || '').trim();
|
||||
@ -88,6 +95,10 @@ function int64Bytes(value) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function uint8Bytes(value) {
|
||||
return new Uint8Array([Number(value) & 0xff]);
|
||||
}
|
||||
|
||||
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
||||
const keyBytes = utf8Bytes(String(key || ''));
|
||||
const valueBytes = utf8Bytes(String(value || ''));
|
||||
@ -107,6 +118,39 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
|
||||
);
|
||||
}
|
||||
|
||||
function makeConnectionBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
}) {
|
||||
const cleanBchName = String(toBlockchainName || '').trim();
|
||||
if (!cleanBchName) throw new Error('Пустой toBlockchainName для CONNECTION');
|
||||
const toBchBytes = utf8Bytes(cleanBchName);
|
||||
if (!toBchBytes.length || toBchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName должен быть 1..255 байт UTF-8');
|
||||
}
|
||||
|
||||
const prevHashBytes = hexToBytes(prevLineHashHex);
|
||||
const toBlockHashBytes = hexToBytes(toBlockHashHex);
|
||||
if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта');
|
||||
if (toBlockHashBytes.length !== 32) throw new Error('toBlockHash должен быть 32 байта');
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
prevHashBytes,
|
||||
int32Bytes(thisLineNumber),
|
||||
uint8Bytes(toBchBytes.length),
|
||||
toBchBytes,
|
||||
int32Bytes(toBlockNumber),
|
||||
toBlockHashBytes,
|
||||
);
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||
@ -368,6 +412,14 @@ export class AuthService {
|
||||
throw opError('GetUserParam', response);
|
||||
}
|
||||
|
||||
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
|
||||
const cleanKind = String(kind || '').trim().toLowerCase();
|
||||
const kinds = CONNECTION_SUBTYPES[cleanKind];
|
||||
if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
|
||||
const subType = enabled ? kinds.on : kinds.off;
|
||||
return this.addBlockConnection({ login, toLogin, subType, storagePwd });
|
||||
}
|
||||
|
||||
async addBlockUserParam({ login, param, value, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
const cleanParam = (param || '').trim();
|
||||
@ -382,7 +434,7 @@ export class AuthService {
|
||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : '0'.repeat(64),
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
|
||||
};
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
@ -395,7 +447,7 @@ export class AuthService {
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || '0'.repeat(64));
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
|
||||
|
||||
// Для USER_PARAM отправляем старт новой line-цепочки:
|
||||
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
|
||||
@ -403,7 +455,7 @@ export class AuthService {
|
||||
const bodyBytes = makeUserParamBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: '0'.repeat(64),
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
key: cleanParam,
|
||||
value: cleanValue,
|
||||
@ -450,6 +502,94 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
const cleanToLogin = (toLogin || '').trim();
|
||||
const cleanSubType = Number(subType);
|
||||
if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
|
||||
if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
||||
if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
|
||||
throw new Error('Нельзя создать связь на самого себя.');
|
||||
}
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
|
||||
};
|
||||
|
||||
const targetUser = await this.getUser(cleanToLogin);
|
||||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
|
||||
}
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
|
||||
|
||||
// Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
|
||||
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
|
||||
// target для user-связей указывает на HEADER пользователя (blockNumber=0).
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
|
||||
const preimage = concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(3),
|
||||
int16Bytes(cleanSubType),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
if (Number.isFinite(knownNum) && knownHash.length === 64) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async reportClientError(details) {
|
||||
try {
|
||||
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
||||
|
||||
215
shine-UI/js/services/user-connections.js
Normal file
215
shine-UI/js/services/user-connections.js
Normal file
@ -0,0 +1,215 @@
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from './user-profile-params.js';
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normKey(value) {
|
||||
return normalizeLogin(value).toLowerCase();
|
||||
}
|
||||
|
||||
function uniqueLogins(list) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
(Array.isArray(list) ? list : []).forEach((item) => {
|
||||
const login = normalizeLogin(item);
|
||||
if (!login) return;
|
||||
const key = normKey(login);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(login);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function listContainsLogin(list, login) {
|
||||
const targetKey = normKey(login);
|
||||
if (!targetKey) return false;
|
||||
return uniqueLogins(list).some((value) => normKey(value) === targetKey);
|
||||
}
|
||||
|
||||
function toFieldMap(snapshot) {
|
||||
const map = {};
|
||||
(snapshot?.fields || []).forEach((field) => {
|
||||
map[field.key] = String(field.value || '').trim();
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function toToggleMap(snapshot) {
|
||||
const map = {};
|
||||
(snapshot?.toggles || []).forEach((toggle) => {
|
||||
map[toggle.key] = Boolean(toggle.enabled);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function readArray(payload, key) {
|
||||
const value = payload?.[key];
|
||||
return Array.isArray(value) ? uniqueLogins(value) : null;
|
||||
}
|
||||
|
||||
function feedOwnerLogins(feedPayload) {
|
||||
const rows = Array.isArray(feedPayload?.followedUsersChannels) ? feedPayload.followedUsersChannels : [];
|
||||
const owners = rows
|
||||
.map((row) => normalizeLogin(row?.channel?.ownerLogin))
|
||||
.filter(Boolean);
|
||||
return uniqueLogins(owners);
|
||||
}
|
||||
|
||||
async function buildRelationsModel(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) {
|
||||
return {
|
||||
outFriends: [],
|
||||
inFriends: [],
|
||||
outContacts: [],
|
||||
inContacts: [],
|
||||
outFollows: [],
|
||||
inFollows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const graph = await authService.getUserConnectionsGraph(cleanLogin);
|
||||
|
||||
let outContacts = readArray(graph, 'outContacts');
|
||||
let outFollows = readArray(graph, 'outFollows');
|
||||
|
||||
const isCurrentSessionLogin = normKey(cleanLogin) === normKey(state.session.login);
|
||||
|
||||
if (outContacts === null && isCurrentSessionLogin) {
|
||||
try {
|
||||
const contacts = await authService.listContacts();
|
||||
outContacts = uniqueLogins(contacts?.contacts || []);
|
||||
} catch {
|
||||
outContacts = [];
|
||||
}
|
||||
}
|
||||
if (outContacts === null) outContacts = [];
|
||||
|
||||
if (outFollows === null) {
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(cleanLogin, 200);
|
||||
outFollows = feedOwnerLogins(feed);
|
||||
} catch {
|
||||
outFollows = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outFriends: readArray(graph, 'outFriends') || [],
|
||||
inFriends: readArray(graph, 'inFriends') || [],
|
||||
outContacts,
|
||||
inContacts: readArray(graph, 'inContacts') || [],
|
||||
outFollows,
|
||||
inFollows: readArray(graph, 'inFollows') || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIdentityLines({ login, firstName, lastName }) {
|
||||
const lines = [];
|
||||
const first = String(firstName || '').trim();
|
||||
const last = String(lastName || '').trim();
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
|
||||
if (first) lines.push(first);
|
||||
if (last) lines.push(last);
|
||||
lines.push(cleanLogin || 'unknown');
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function buildAvatarInitials({ login, firstName, lastName }) {
|
||||
const first = String(firstName || '').trim();
|
||||
const last = String(lastName || '').trim();
|
||||
if (first || last) {
|
||||
const a = (first[0] || '').toUpperCase();
|
||||
const b = (last[0] || '').toUpperCase();
|
||||
const initials = `${a}${b}`.trim();
|
||||
if (initials) return initials;
|
||||
}
|
||||
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export async function loadCurrentRelations() {
|
||||
const login = normalizeLogin(state.session.login);
|
||||
if (!login) {
|
||||
return {
|
||||
outFriends: [],
|
||||
inFriends: [],
|
||||
outContacts: [],
|
||||
inContacts: [],
|
||||
outFollows: [],
|
||||
inFollows: [],
|
||||
};
|
||||
}
|
||||
return buildRelationsModel(login);
|
||||
}
|
||||
|
||||
export function relationFlagsForTarget(relations, targetLogin) {
|
||||
return {
|
||||
outFriend: listContainsLogin(relations?.outFriends, targetLogin),
|
||||
inFriend: listContainsLogin(relations?.inFriends, targetLogin),
|
||||
outContact: listContainsLogin(relations?.outContacts, targetLogin),
|
||||
inContact: listContainsLogin(relations?.inContacts, targetLogin),
|
||||
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
|
||||
inFollow: listContainsLogin(relations?.inFollows, targetLogin),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadUserProfileCard(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) throw new Error('Пустой login');
|
||||
|
||||
const [user, snapshot] = await Promise.all([
|
||||
authService.getUser(cleanLogin),
|
||||
loadProfileSnapshot(cleanLogin),
|
||||
]);
|
||||
|
||||
if (!user?.exists) throw new Error('Пользователь не найден');
|
||||
|
||||
const canonicalLogin = normalizeLogin(user.login || cleanLogin);
|
||||
const fields = toFieldMap(snapshot);
|
||||
const toggles = toToggleMap(snapshot);
|
||||
|
||||
return {
|
||||
login: canonicalLogin,
|
||||
blockchainName: normalizeLogin(user.blockchainName),
|
||||
firstName: fields.first_name || '',
|
||||
lastName: fields.last_name || '',
|
||||
address: fields.address || '',
|
||||
web: fields.web || '',
|
||||
phone: fields.phone || '',
|
||||
official: Boolean(toggles.official),
|
||||
shine: Boolean(toggles.shine),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRelationsForPair({ currentLogin, targetLogin }) {
|
||||
const cleanCurrent = normalizeLogin(currentLogin);
|
||||
const cleanTarget = normalizeLogin(targetLogin);
|
||||
const currentRelations = await buildRelationsModel(cleanCurrent);
|
||||
let flags = relationFlagsForTarget(currentRelations, cleanTarget);
|
||||
|
||||
if (!flags.inContact || !flags.inFollow) {
|
||||
try {
|
||||
const targetRelations = await buildRelationsModel(cleanTarget);
|
||||
const backFlags = relationFlagsForTarget(targetRelations, cleanCurrent);
|
||||
flags = {
|
||||
...flags,
|
||||
inContact: flags.inContact || backFlags.outContact,
|
||||
inFollow: flags.inFollow || backFlags.outFollow,
|
||||
};
|
||||
} catch {
|
||||
// ignore fallback failures for incoming direction
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...flags,
|
||||
source: currentRelations,
|
||||
};
|
||||
}
|
||||
@ -97,6 +97,24 @@
|
||||
background: rgba(83, 216, 251, 0.11);
|
||||
}
|
||||
|
||||
.badge.is-no {
|
||||
border-color: rgba(170, 180, 205, 0.3);
|
||||
color: #c5cedd;
|
||||
background: rgba(152, 164, 190, 0.14);
|
||||
}
|
||||
|
||||
.badge.is-yes-official {
|
||||
border-color: rgba(132, 244, 161, 0.5);
|
||||
color: #ddffe7;
|
||||
background: rgba(132, 244, 161, 0.2);
|
||||
}
|
||||
|
||||
.badge.is-yes-shine {
|
||||
border-color: rgba(183, 122, 255, 0.6);
|
||||
color: #f4e7ff;
|
||||
background: rgba(176, 102, 255, 0.22);
|
||||
}
|
||||
|
||||
.badge.profile-toggle-btn.is-no {
|
||||
border-color: rgba(170, 180, 205, 0.3);
|
||||
color: #c5cedd;
|
||||
@ -160,6 +178,22 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-identity-lines {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.profile-identity-line {
|
||||
line-height: 1.2;
|
||||
color: #eef3ff;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.profile-identity-login {
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.profile-param-item {
|
||||
padding: 10px;
|
||||
gap: 6px;
|
||||
@ -568,7 +602,7 @@
|
||||
|
||||
.contact-search-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@ -746,6 +780,25 @@
|
||||
color: #d6e2ff;
|
||||
}
|
||||
|
||||
.node-menu {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
min-width: 240px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.user-relations-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-rel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #d8e3ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@ -31,16 +31,24 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
|
||||
List<String> out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
||||
List<String> inFollows = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
||||
|
||||
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(canonicalLogin);
|
||||
resp.setOutFriends(out);
|
||||
resp.setInFriends(in);
|
||||
resp.setOutFriends(outFriends);
|
||||
resp.setInFriends(inFriends);
|
||||
resp.setOutContacts(outContacts);
|
||||
resp.setInContacts(inContacts);
|
||||
resp.setOutFollows(outFollows);
|
||||
resp.setInFollows(inFollows);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> outFriends = new ArrayList<>();
|
||||
private List<String> inFriends = new ArrayList<>();
|
||||
private List<String> outContacts = new ArrayList<>();
|
||||
private List<String> inContacts = new ArrayList<>();
|
||||
private List<String> outFollows = new ArrayList<>();
|
||||
private List<String> inFollows = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
@ -16,4 +20,12 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
|
||||
public List<String> getInFriends() { return inFriends; }
|
||||
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
|
||||
public List<String> getOutContacts() { return outContacts; }
|
||||
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
|
||||
public List<String> getInContacts() { return inContacts; }
|
||||
public void setInContacts(List<String> inContacts) { this.inContacts = inContacts; }
|
||||
public List<String> getOutFollows() { return outFollows; }
|
||||
public void setOutFollows(List<String> outFollows) { this.outFollows = outFollows; }
|
||||
public List<String> getInFollows() { return inFollows; }
|
||||
public void setInFollows(List<String> inFollows) { this.inFollows = inFollows; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user