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 }) {
Профиль
`; + 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'; diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index c1b4711..ed2d3c3 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -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 ? `${item.unread}` : ''} `; - 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; } diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index a9a6d07..4ace200 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -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 = `
${(name[0] || '?').toUpperCase()}
${name}
`; return n; } -function showAddCloseFriendModal({ onAdded }) { - const root = document.getElementById('modal-root'); - root.innerHTML = ` - - `; - - 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 = '

Поиск...

'; - - 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 = ` -
${(login[0] || '?').toUpperCase()}
-
${login}

Пользователь

-
Добавить
- `; - 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 = `

Ошибка поиска: ${e.message || 'unknown'}

`; - } - }); +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 = ''; + + 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; } diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index d9fa079..5a96063 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -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 = `
${profile.avatarInitials}
-
-

${displayLogin}

+
+
@@ -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; } }