diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 9ded35e..6063c91 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -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,
diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js
index 96963ac..fa8b0b0 100644
--- a/shine-UI/js/pages/chat-view.js
+++ b/shine-UI/js/pages/chat-view.js
@@ -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 = '
Пользователь не в контактах. Можно писать ему сразу (MVP).
';
- 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';
diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js
index 838fceb..b7011ae 100644
--- a/shine-UI/js/pages/contact-search-view.js
+++ b/shine-UI/js/pages/contact-search-view.js
@@ -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 }) {
Поиск...
';
-
- try {
- const logins = await authService.searchUsers(query);
- holder.innerHTML = '';
- if (!logins.length) {
- holder.innerHTML = 'Пользователи не найдены.
';
- return;
- }
-
- logins.forEach((login) => {
- const row = document.createElement('article');
- row.className = 'list-item';
- row.innerHTML = `
-
${profile.avatarInitials}
-
-
${displayLogin}
+
+
${String(login || '').trim() || 'unknown'}
@@ -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) => (
+ `
${escapeHtml(line)}
`
+ )).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();
diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js
new file mode 100644
index 0000000..15c5663
--- /dev/null
+++ b/shine-UI/js/pages/user-profile-view.js
@@ -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 `
+
+
${escapeHtml(buildAvatarInitials(card))}
+
+ ${lines.map((line, idx) => (
+ `
${escapeHtml(line)}
`
+ )).join('')}
+
+
+ `;
+}
+
+function renderReadOnlyBadges(card) {
+ return `
+
+ Официальный: ${card.official ? 'Yes' : 'No'}
+ Сияющий: ${card.shine ? 'Yes' : 'No'}
+
+ `;
+}
+
+function renderRelations(flags) {
+ return `
+
+
Вы подписаны:${boolText(flags.outFollow)}
+
Подписан на вас:${boolText(flags.inFollow)}
+
Вы добавили в друзья:${boolText(flags.outFriend)}
+
Добавил вас в друзья:${boolText(flags.inFriend)}
+
Вы добавили в контакты:${boolText(flags.outContact)}
+
Добавил вас в контакты:${boolText(flags.inContact)}
+
+ `;
+}
+
+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 `
+
+ ${rows.map((row) => `
+
+
${row.label}: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}
+
+ `).join('')}
+
+ `;
+}
+
+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 = `
+
+ ${renderIdentity(card)}
+
+ ${renderReadOnlyBadges(card)}
+ ${renderRelations(flags)}
+ ${renderReadOnlyParams(card)}
+
+
+
+
+
+ `;
+
+ 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;
+}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 78a339e..c554d7a 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -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';
}
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index 141714e..8f3d344 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -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);
diff --git a/shine-UI/js/services/user-connections.js b/shine-UI/js/services/user-connections.js
new file mode 100644
index 0000000..c0c5fef
--- /dev/null
+++ b/shine-UI/js/services/user-connections.js
@@ -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,
+ };
+}
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 92cb1b3..b970259 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -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);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
index b0e1d42..d3510ea 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
@@ -31,16 +31,24 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
- List
out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
- List in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
+ List inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
+ List outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
+ List 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;
}
}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java
index 86d4ce5..120e5f7 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java
@@ -9,6 +9,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private String login;
private List outFriends = new ArrayList<>();
private List inFriends = new ArrayList<>();
+ private List outContacts = new ArrayList<>();
+ private List inContacts = new ArrayList<>();
+ private List outFollows = new ArrayList<>();
+ private List 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 outFriends) { this.outFriends = outFriends; }
public List getInFriends() { return inFriends; }
public void setInFriends(List inFriends) { this.inFriends = inFriends; }
+ public List getOutContacts() { return outContacts; }
+ public void setOutContacts(List outContacts) { this.outContacts = outContacts; }
+ public List getInContacts() { return inContacts; }
+ public void setInContacts(List inContacts) { this.inContacts = inContacts; }
+ public List getOutFollows() { return outFollows; }
+ public void setOutFollows(List outFollows) { this.outFollows = outFollows; }
+ public List getInFollows() { return inFollows; }
+ public void setInFollows(List inFollows) { this.inFollows = inFollows; }
}