Compare commits

...
4 Commits
Author SHA256 Message Date
AidarKC 23969121e5 cripto utils 2026-09-20 11:20:32 +03:00
AidarKC 4da2d7129a 1 2026-09-19 10:18:45 +03:00
AidarKC 2be5ad0b7d Выровнять верхние панели UI 2026-09-18 00:00:42 +03:00
AidarKC f44ab75c64 Улучшить аватары и граф связей 2026-09-17 23:35:53 +03:00
21 changed files with 245 additions and 50 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.12.17
client.version=1.12.19
server.version=1.10.7
-1
View File
@@ -218,7 +218,6 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
'chat-view',
'channel-view',
'channel-thread-view',
'notifications-view',
]);
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
+12
View File
@@ -36,6 +36,7 @@ export function renderAvatar({
title = '',
alt = 'Аватар',
glow = false,
official = false,
} = {}) {
const wrap = document.createElement('div');
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
@@ -52,6 +53,15 @@ export function renderAvatar({
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
wrap.append(fallback);
if (official) {
const badge = document.createElement('img');
badge.className = 'avatar-official-badge';
badge.src = '/assets/shine-official-badge.svg?v=2';
badge.alt = '';
badge.setAttribute('aria-hidden', 'true');
wrap.append(badge);
}
const txId = String(avatar?.ar || '').trim();
if (!validateArweaveTxId(txId)) {
return wrap;
@@ -146,6 +156,7 @@ export function renderUserAvatar({
className = '',
title = '',
glow = false,
official = false,
} = {}) {
return renderAvatar({
initials: buildAvatarInitials({ login, firstName, lastName }),
@@ -155,5 +166,6 @@ export function renderUserAvatar({
title,
alt: 'Аватар',
glow,
official,
});
}
+1 -1
View File
@@ -1426,7 +1426,7 @@ export function render({ navigate, route, chrome }) {
iconNode: createOverflowDots(),
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
className: 'chat-header-icon-btn chat-header-menu-btn topbar-overflow-action--raised',
menu: {
minWidth: 230,
items: () => [
+1 -1
View File
@@ -251,7 +251,7 @@ export function render({ navigate, chrome }) {
iconNode: createOverflowDots(),
title: 'Меню чатов',
ariaLabel: 'Меню чатов',
className: 'messages-topbar-menu-btn',
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
menu: {
minWidth: 210,
items: [
+44 -14
View File
@@ -709,14 +709,23 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
const centerKey = normKey(snap?.centerLogin);
const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey);
if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 });
if (ownCenter) centerNodeByKey.set(centerKey, {
...ownCenter,
isHistoryCenter: true,
clusterOwnerId: centerKey,
keepVisible: true,
tier: 1,
});
snapNodes.forEach((rawNode) => {
const key = normKey(rawNode?.id);
if (!key) return;
// Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы
// принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля.
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key });
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) {
// Общий узел принадлежит последнему кластеру, возле которого он был перерисован.
nodeMap.set(key, { ...rawNode, id: key, clusterOwnerId: centerKey });
}
if (key === centerKey) return;
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
@@ -755,9 +764,11 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
id: key,
login: node?.login || node?.id || key,
tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1),
isHistoryCenter: centerKeys.has(key),
fixedLayout: true,
keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible),
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
clusterOwnerId: normKey(node?.clusterOwnerId || key),
edgeParents: edgeParentsByChild.get(key) || [],
}));
@@ -1098,11 +1109,15 @@ export function render({ navigate, route, chrome } = {}) {
window.setTimeout(() => inputEl.focus(), 0);
}
function persistManualNodePosition(nodeId, point) {
const key = normKey(nodeId);
const x = Number(point?.x);
const y = Number(point?.y);
if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return;
function persistManualNodePositions(rows) {
const positions = new Map();
(Array.isArray(rows) ? rows : []).forEach((row) => {
const key = normKey(row?.id || row?.login);
const x = Number(row?.x);
const y = Number(row?.y);
if (key && Number.isFinite(x) && Number.isFinite(y)) positions.set(key, { x, y });
});
if (!positions.size) return;
// Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все
// упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag.
@@ -1110,12 +1125,13 @@ export function render({ navigate, route, chrome } = {}) {
const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] });
let changed = false;
model.nodes = model.nodes.map((node) => {
if (normKey(node?.id) !== key) return node;
const point = positions.get(normKey(node?.id));
if (!point) return node;
changed = true;
return {
...node,
layoutX: x,
layoutY: y,
layoutX: point.x,
layoutY: point.y,
fixedLayout: true,
};
});
@@ -1149,10 +1165,19 @@ export function render({ navigate, route, chrome } = {}) {
}
void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY });
},
// тап по центру — полноценный профиль
// Короткий тап по текущему центру сразу открывает его меню.
onCenterTap: (node) => {
const routeTo = profileInfoRoute(node.login);
if (routeTo) navigate(routeTo);
const rect = node?.el?.getBoundingClientRect?.();
openNodeMenu({
login: normalizeLogin(node?.login),
displayName: String(node?.name || '').trim(),
relationType: node?.relationType,
point: rect ? { x: rect.left + rect.width / 2, y: rect.top, rect } : undefined,
actions: [
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(node?.login); if (r) navigate(r); } },
{ label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(normalizeLogin(node?.login))}`) },
],
});
},
// долгое нажатие — контекстное меню (вне масштабируемого холста)
onNodeLongPress: (node, point) => {
@@ -1171,7 +1196,12 @@ export function render({ navigate, route, chrome } = {}) {
// Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра
// в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot.
onNodeMoveEnd: (node, point) => {
persistManualNodePosition(node?.id || node?.login, point);
persistManualNodePositions([{ id: node?.id || node?.login, ...point }]);
},
// Центр и узлы, которые в последний раз были нарисованы возле него,
// сохраняются одной пачкой, чтобы общий узел не вернулся в старый кластер при rebuild.
onGroupMoveEnd: (movedNodes) => {
persistManualNodePositions(movedNodes);
},
});
}
+48 -7
View File
@@ -219,13 +219,14 @@ function hash01(str) {
* @param {Object} opts
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
* @param {Function} [opts.onCenterTap] - тап по текущему центральному узлу (node) => void
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
* @param {Function} [opts.onGroupMoveEnd] - перемещение исторического кластера (nodes[]) => void
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
*/
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) {
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onGroupMoveEnd, onNodeHover, onDiveChange } = {}) {
// Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges');
@@ -658,6 +659,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.relationType = src.relationType;
node.shining = Boolean(src.shining);
node.official = Boolean(src.official);
node.isHistoryCenter = Boolean(src.isHistoryCenter);
node.clusterOwnerId = String(src.clusterOwnerId || src.id || '');
node.keepVisible = Boolean(src.keepVisible);
node.alwaysVisible = Boolean(src.alwaysVisible);
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
@@ -1472,6 +1475,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let nodeDragActive = false;
let nodeDragStartX = 0;
let nodeDragStartY = 0;
let groupDragActive = false;
let groupDragNodes = [];
let groupDragStarts = new Map();
let longTimer = 0;
let longFired = false;
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
@@ -1602,8 +1608,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
downNode = nodeFromEvent(ev);
nodeDragActive = false;
groupDragActive = false;
groupDragNodes = [];
groupDragStarts = new Map();
nodeDragStartX = Number(downNode?.x) || 0;
nodeDragStartY = Number(downNode?.y) || 0;
if (downNode?.isHistoryCenter) {
const ownerId = String(downNode.clusterOwnerId || downNode.id);
groupDragNodes = nodes.filter((node) => String(node.clusterOwnerId || node.id) === ownerId);
groupDragStarts = new Map(groupDragNodes.map((node) => [String(node.id), { x: Number(node.x) || 0, y: Number(node.y) || 0 }]));
}
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
if (downNode && typeof onNodeLongPress === 'function') {
@@ -1655,15 +1669,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
moved = true;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту.
nodeDragActive = Boolean(downNode && !downNode.isFocus);
if (nodeDragActive && cssBloom) endCssBloom();
// Исторический центр тащит свой кластер; обычный узел — только себя; пустой фон — всю карту.
groupDragActive = Boolean(downNode?.isHistoryCenter && groupDragNodes.length);
nodeDragActive = Boolean(downNode && !groupDragActive);
if ((nodeDragActive || groupDragActive) && cssBloom) endCssBloom();
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
cancelTween(); // жест прерывает анимацию центрирования
dragging = true;
}
if (moved && groupDragActive) {
const zx = dx / Math.max(0.001, zoom);
const zy = dy / Math.max(0.001, zoom);
groupDragNodes.forEach((node) => {
const start = groupDragStarts.get(String(node.id));
if (!start) return;
const nx = start.x + zx;
const ny = start.y + zy;
node.x = nx; node.y = ny;
node.tx = nx; node.ty = ny;
node.bfx = nx; node.bfy = ny;
node.vx = 0; node.vy = 0;
node.fixedLayout = true;
});
renderNodes();
renderEdges();
return;
}
if (moved && nodeDragActive && downNode) {
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
@@ -1708,9 +1741,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const wasMoved = moved;
const wasLong = longFired;
const movedNode = nodeDragActive ? downNode : null;
const movedGroup = groupDragActive ? [...groupDragNodes] : [];
pointerId = null;
dragging = false;
nodeDragActive = false;
groupDragActive = false;
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
@@ -1719,6 +1754,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
renderEdges();
} else if (wasMoved && movedGroup.length) {
if (typeof onGroupMoveEnd === 'function') {
onGroupMoveEnd(movedGroup.map((node) => ({ id: node.id, login: node.login, x: node.x, y: node.y })));
}
renderEdges();
} else if (wasMoved) {
// после pan даём физике чуть устаканиться и уснуть
wake();
@@ -1866,8 +1906,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
const finalX = node.isFocus ? 0 : node.tx;
const finalY = node.isFocus ? 0 : node.ty;
// Ручно сдвинутый текущий центр не должен прыгать обратно в (0,0) при setModel.
const finalX = node.tx;
const finalY = node.ty;
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)
+6 -2
View File
@@ -320,11 +320,15 @@ function renderItem(item, activeTab, navigate) {
export function render({ navigate, chrome } = {}) {
const screen = document.createElement('section');
screen.className = 'stack notifications-screen';
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
chrome?.setTopbar(createTopBar({
center: tabs,
className: 'notifications-topbar',
}));
const list = document.createElement('div');
list.className = 'stack notifications-list';
let payloadCache = null;
@@ -415,7 +419,7 @@ export function render({ navigate, chrome } = {}) {
observer?.disconnect();
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
};
screen.append(tabs,list);
screen.append(list);
void load();
return screen;
}
+2 -1
View File
@@ -84,7 +84,7 @@ export function render({ navigate, chrome }) {
iconNode: createOverflowDots(),
title: 'Меню профиля',
ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn',
className: 'profile-head-menu-btn topbar-overflow-action--raised',
menu: {
className: 'profile-head-menu',
minWidth: 250,
@@ -176,6 +176,7 @@ export function render({ navigate, chrome }) {
size: 'xl',
className: 'user-profile-hero-avatar',
glow: shining,
official,
}));
status.textContent = '';
+1
View File
@@ -250,6 +250,7 @@ export function render({ navigate, route, chrome }) {
size: 'xl',
className: 'user-profile-hero-avatar',
glow: shining,
official,
}));
addMenu = body.querySelector('.user-profile-add-menu');
+2 -2
View File
@@ -1,7 +1,7 @@
const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
import { argon2idAsync } from '../vendor/noble/hashes-argon2-1.8.0.bundle.mjs';
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from '../vendor/noble/curves-ed25519-1.8.1.bundle.mjs';
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
function getCryptoApi() {
@@ -12,7 +12,7 @@ import {
edwardsToMontgomeryPriv,
edwardsToMontgomeryPub,
x25519,
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519';
} from '../vendor/noble/curves-ed25519-1.5.0.bundle.mjs';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const PAIRING_HASH_PREFIX = 'sha256$';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
View File
@@ -269,3 +269,23 @@
pointer-events: none;
z-index: -1;
}
/* Единый знак официального пользователя. Левый и нижний края зафиксированы,
а сам знак растёт вправо и вверх вместе с аватаром. */
.avatar.avatar-image.avatar-framed > .avatar-official-badge {
position: absolute;
left: -2%;
bottom: -1%;
width: 32%;
height: 32%;
min-width: 18px;
min-height: 18px;
object-fit: contain;
object-position: center;
display: block;
opacity: 1;
border-radius: 0;
pointer-events: none;
z-index: 4;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
}
@@ -26,3 +26,12 @@
0 0 5px rgba(92, 190, 255, 0.72),
0 0 10px rgba(72, 145, 255, 0.34);
}
/* Явный вариант для шапок, где overflow-кнопку нужно выровнять по эталонной
* позиции экрана «Связи». Поднимаем кнопку вместе с зоной нажатия и якорем
* выпадающего меню, не меняя базовый TopBar для остальных экранов. */
.topbar-overflow-action--raised {
position: relative;
top: -2px;
}
+8
View File
@@ -97,6 +97,14 @@
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
}
.topbar-slot .topbar__action {
min-width: 40px;
padding-block: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.topbar-slot .topbar__action svg,
.topbar-slot .topbar__back svg {
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
+28 -13
View File
@@ -186,14 +186,32 @@
/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */
.notifications-screen .notification-feed-tabs {
/* Уведомления: переключатели лент занимают место заголовка в закреплённом
* TopBar и повторяют стеклянные чипы экрана «Связи». */
.notifications-topbar {
grid-template-columns: minmax(0, 1fr);
}
.notifications-topbar .topbar__left,
.notifications-topbar .topbar__right {
display: none;
}
.notifications-topbar .topbar__center {
width: 100%;
}
.notifications-topbar .notification-feed-tabs {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 12px 4px;
padding: 0;
margin: 0;
background: transparent;
border: 0;
@@ -201,25 +219,22 @@
}
.notifications-screen .notification-feed-tabs .notification-tab-btn {
min-width: 92px;
.notifications-topbar .notification-feed-tabs .notification-tab-btn {
flex: 1 1 0;
min-width: 0;
padding-inline: 8px;
text-align: center;
}
/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */
.notifications-screen .notification-feed-tabs.app-top-tabs {
/* Высота панели остаётся стандартной: перенос фильтров не увеличивает TopBar. */
.notifications-topbar .notification-feed-tabs.app-top-tabs {
min-height: var(--app-primary-tab-min-height);
padding: 0;
margin: 0;
}
.notifications-screen .notification-feed-tabs .notification-tab-btn {
min-width: 92px;
}
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
* обводку различение сохраняется фоном и лёгкой реакцией на нажатие. */
.notifications-screen .notifications-list > .notification-card,
+20 -2
View File
@@ -799,8 +799,26 @@
.user-profile-screen .user-profile-hero-avatar.avatar-glow::before {
inset: -12%;
opacity: 0.5;
inset: -7%;
border: 2px solid rgba(145, 239, 255, 0.94);
background: transparent;
box-shadow:
0 0 3px rgba(155, 244, 255, 0.96),
0 0 9px rgba(112, 222, 255, 0.74),
0 0 17px rgba(86, 190, 255, 0.38),
inset 0 0 4px rgba(176, 248, 255, 0.72);
filter: none;
transform: none;
opacity: 1;
}
/* На крупном аватаре профиля официальный знак на 20% компактнее базового.
* Левый и нижний края остаются на месте, поэтому сокращение приходится на
* верхнюю и правую стороны знака. */
.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-official-badge {
width: 25.6%;
height: 25.6%;
}
+4 -4
View File
@@ -210,10 +210,10 @@
position: absolute;
left: -2%;
bottom: -1%;
width: 16%;
height: 16%;
min-width: 9px;
min-height: 9px;
width: 32%;
height: 32%;
min-width: 18px;
min-height: 18px;
object-fit: contain;
object-position: center;
display: block;