Аватары: общий компонент, кэш txId и avatar.ar в графе связей
This commit is contained in:
parent
667c5310bf
commit
4c1aeeeac8
@ -1,2 +1,2 @@
|
||||
client.version=1.2.5
|
||||
server.version=1.2.5
|
||||
client.version=1.2.6
|
||||
server.version=1.2.6
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<title>Shine UI Demo</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260426025000';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.5';
|
||||
window.__SHINE_BUILD_HASH__ = '20260426102000';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.6';
|
||||
</script>
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
|
||||
88
shine-UI/js/components/avatar-image.js
Normal file
88
shine-UI/js/components/avatar-image.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { state } from '../state.js';
|
||||
import { validateArweaveTxId } from '../services/arweave-file-service.js';
|
||||
import { getCachedAvatarObjectUrl } from '../services/arweave-avatar-cache-service.js';
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function pickSizeClass(size) {
|
||||
const raw = String(size || '').trim().toLowerCase();
|
||||
if (raw === 'large') return 'large';
|
||||
if (raw === 'node') return 'node-dot';
|
||||
if (raw === 'small') return '';
|
||||
return raw || '';
|
||||
}
|
||||
|
||||
export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {}) {
|
||||
const first = String(firstName || '').trim();
|
||||
const last = String(lastName || '').trim();
|
||||
if (first || last) {
|
||||
const initials = `${(first[0] || '').toUpperCase()}${(last[0] || '').toUpperCase()}`.trim();
|
||||
if (initials) return initials;
|
||||
}
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
return (cleanLogin[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
export function renderUserAvatar({
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
avatar = null,
|
||||
size = 'large',
|
||||
className = '',
|
||||
title = '',
|
||||
} = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
const classes = ['avatar', 'avatar-image'];
|
||||
const sizeClass = pickSizeClass(size);
|
||||
if (sizeClass) classes.push(sizeClass);
|
||||
const extraClass = String(className || '').trim();
|
||||
if (extraClass) classes.push(...extraClass.split(/\s+/g));
|
||||
wrap.className = classes.join(' ');
|
||||
if (title) wrap.title = String(title);
|
||||
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'avatar-fallback';
|
||||
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
|
||||
wrap.append(fallback);
|
||||
|
||||
const txId = String(avatar?.ar || '').trim();
|
||||
if (!validateArweaveTxId(txId)) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар';
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.hidden = true;
|
||||
wrap.append(img);
|
||||
|
||||
const gateway = state?.entrySettings?.arweaveServer;
|
||||
void getCachedAvatarObjectUrl({ gateway, txId })
|
||||
.then((objectUrl) => {
|
||||
img.onload = () => {
|
||||
fallback.hidden = true;
|
||||
img.hidden = false;
|
||||
if (objectUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
img.hidden = true;
|
||||
fallback.hidden = false;
|
||||
if (objectUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
img.src = objectUrl;
|
||||
})
|
||||
.catch(() => {
|
||||
img.hidden = true;
|
||||
fallback.hidden = false;
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
@ -334,7 +334,7 @@ export function openAvatarWizard({
|
||||
jwk: walletCtx?.jwk,
|
||||
file: optimized.file,
|
||||
tags: [
|
||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
|
||||
],
|
||||
});
|
||||
uploadedTxId = String(uploaded.id || '').trim();
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
@ -56,11 +57,20 @@ function getMarkByLogin(allUsers) {
|
||||
shine: Boolean(row?.shine),
|
||||
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
|
||||
shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')),
|
||||
avatar: normalizeAvatar(row),
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function normalizeAvatar(row) {
|
||||
const txFromAvatar = String(row?.avatar?.ar || '').trim();
|
||||
if (txFromAvatar) return { ar: txFromAvatar };
|
||||
const txFallback = String(row?.avatarTxId || '').trim();
|
||||
if (txFallback) return { ar: txFallback };
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRelativeGender(map, rows) {
|
||||
(Array.isArray(rows) ? rows : []).forEach((row) => {
|
||||
const login = normalizeLogin(row?.login);
|
||||
@ -108,12 +118,23 @@ function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = nul
|
||||
const metaText = metaParts.join(', ');
|
||||
node.title = metaText ? `${login}\n${metaText}` : login;
|
||||
|
||||
const officialBadge = mark?.official ? '<span class="node-badge-official" aria-hidden="true">ОФ</span>' : '';
|
||||
node.innerHTML = `
|
||||
${officialBadge}
|
||||
<span class="node-dot">${(login[0] || '?').toUpperCase()}</span>
|
||||
<span class="node-label">${login}</span>
|
||||
`;
|
||||
if (mark?.official) {
|
||||
const officialBadge = document.createElement('span');
|
||||
officialBadge.className = 'node-badge-official';
|
||||
officialBadge.setAttribute('aria-hidden', 'true');
|
||||
officialBadge.textContent = 'ОФ';
|
||||
node.append(officialBadge);
|
||||
}
|
||||
node.append(renderUserAvatar({
|
||||
login,
|
||||
avatar: mark?.avatar || null,
|
||||
size: 'node',
|
||||
title: login,
|
||||
}));
|
||||
const label = document.createElement('span');
|
||||
label.className = 'node-label';
|
||||
label.textContent = login;
|
||||
node.append(label);
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ import {
|
||||
saveProfileToggle,
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
import { openAvatarWizard } from '../components/avatar-wizard.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@ -105,10 +105,7 @@ export function render({ navigate }) {
|
||||
topRow.innerHTML = `
|
||||
<div class="row" style="gap:12px; align-items:center;">
|
||||
<div class="profile-avatar-block">
|
||||
<div class="avatar large profile-avatar" data-profile-avatar="true">
|
||||
<span data-avatar-fallback="true">${profile.avatarInitials}</span>
|
||||
<img src="" alt="Аватар" data-avatar-image="true" hidden />
|
||||
</div>
|
||||
<div data-profile-avatar-slot="true"></div>
|
||||
<button class="ghost-btn profile-avatar-change-btn" type="button" data-change-avatar="true">Сменить аватар</button>
|
||||
</div>
|
||||
<div class="profile-identity-lines" data-profile-identity="true">
|
||||
@ -154,8 +151,7 @@ export function render({ navigate }) {
|
||||
let currentGender = PROFILE_GENDER_UNKNOWN;
|
||||
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||
const avatarImageEl = topRow.querySelector('[data-avatar-image="true"]');
|
||||
const avatarFallbackEl = topRow.querySelector('[data-avatar-fallback="true"]');
|
||||
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
||||
|
||||
function openGenderPickerModal(initialGender) {
|
||||
const root = document.getElementById('modal-root');
|
||||
@ -219,56 +215,19 @@ export function render({ navigate }) {
|
||||
)).join('');
|
||||
}
|
||||
|
||||
function buildAvatarFallbackText() {
|
||||
function updateAvatarUi() {
|
||||
if (!(avatarSlotEl instanceof HTMLElement)) return;
|
||||
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
|
||||
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
|
||||
const fromNames = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
if (fromNames.trim()) return fromNames;
|
||||
|
||||
const loginFirst = String(login || '').trim().charAt(0).toUpperCase();
|
||||
if (loginFirst) return loginFirst;
|
||||
|
||||
return String(profile.avatarInitials || '??').trim().slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function showAvatarFallback() {
|
||||
if (avatarImageEl instanceof HTMLImageElement) {
|
||||
avatarImageEl.hidden = true;
|
||||
avatarImageEl.removeAttribute('src');
|
||||
}
|
||||
if (avatarFallbackEl instanceof HTMLElement) {
|
||||
avatarFallbackEl.hidden = false;
|
||||
avatarFallbackEl.textContent = buildAvatarFallbackText();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvatarUi() {
|
||||
if (!(avatarImageEl instanceof HTMLImageElement)) return;
|
||||
const txId = String(currentAvatar?.txId || '').trim();
|
||||
if (!txId) {
|
||||
showAvatarFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
let avatarUrl = '';
|
||||
try {
|
||||
avatarUrl = buildArweaveDataUrl({
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
txId,
|
||||
});
|
||||
} catch {
|
||||
showAvatarFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
avatarImageEl.onload = () => {
|
||||
if (avatarFallbackEl instanceof HTMLElement) avatarFallbackEl.hidden = true;
|
||||
avatarImageEl.hidden = false;
|
||||
};
|
||||
avatarImageEl.onerror = () => {
|
||||
showAvatarFallback();
|
||||
};
|
||||
avatarImageEl.src = avatarUrl;
|
||||
avatarSlotEl.innerHTML = '';
|
||||
avatarSlotEl.append(renderUserAvatar({
|
||||
login,
|
||||
firstName,
|
||||
lastName,
|
||||
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
|
||||
size: 'large',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
}
|
||||
|
||||
function updateToggleButton(button, prefix, enabled) {
|
||||
@ -846,7 +805,7 @@ export function render({ navigate }) {
|
||||
card.append(topRow, badgesRow, status, listWrap, relativesCard);
|
||||
screen.append(card);
|
||||
|
||||
showAvatarFallback();
|
||||
updateAvatarUi();
|
||||
refreshProfileSnapshot();
|
||||
|
||||
return screen;
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
buildAvatarInitials,
|
||||
buildIdentityLines,
|
||||
loadRelationsForPair,
|
||||
loadUserProfileCard,
|
||||
} from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
|
||||
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
|
||||
|
||||
@ -54,16 +54,31 @@ function renderIdentity(card) {
|
||||
lastName: card.lastName,
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="row" style="gap:12px; align-items:center;">
|
||||
<div class="avatar large">${escapeHtml(buildAvatarInitials(card))}</div>
|
||||
<div class="profile-identity-lines">
|
||||
${lines.map((line, idx) => (
|
||||
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
|
||||
)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.style.gap = '12px';
|
||||
row.style.alignItems = 'center';
|
||||
|
||||
row.append(renderUserAvatar({
|
||||
login: card.login,
|
||||
firstName: card.firstName,
|
||||
lastName: card.lastName,
|
||||
avatar: card.avatar,
|
||||
size: 'large',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
|
||||
const identityLines = document.createElement('div');
|
||||
identityLines.className = 'profile-identity-lines';
|
||||
lines.forEach((line, idx) => {
|
||||
const lineEl = document.createElement('div');
|
||||
lineEl.className = `profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}`;
|
||||
lineEl.textContent = line;
|
||||
identityLines.append(lineEl);
|
||||
});
|
||||
row.append(identityLines);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderReadOnlyBadges(card) {
|
||||
@ -174,9 +189,6 @@ export function render({ navigate, route }) {
|
||||
currentFlags = flags;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="card stack">
|
||||
${renderIdentity(card)}
|
||||
</div>
|
||||
${renderReadOnlyBadges(card)}
|
||||
${renderRelations(flags)}
|
||||
${renderReadOnlyParams(card)}
|
||||
@ -186,6 +198,10 @@ export function render({ navigate, route }) {
|
||||
<button class="ghost-btn" type="button" data-relation-action="contact"></button>
|
||||
</div>
|
||||
`;
|
||||
const identityCard = document.createElement('div');
|
||||
identityCard.className = 'card stack';
|
||||
identityCard.append(renderIdentity(card));
|
||||
body.prepend(identityCard);
|
||||
|
||||
syncActionButtons();
|
||||
status.className = 'status-line is-available';
|
||||
|
||||
156
shine-UI/js/services/arweave-avatar-cache-service.js
Normal file
156
shine-UI/js/services/arweave-avatar-cache-service.js
Normal file
@ -0,0 +1,156 @@
|
||||
import { buildArweaveDataUrl, validateArweaveTxId } from './arweave-file-service.js';
|
||||
|
||||
const DB_NAME = 'shine-ui-avatar-cache';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'avatars';
|
||||
const MAX_ITEMS = 200;
|
||||
const MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB недоступен'));
|
||||
return;
|
||||
}
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'txId' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Ошибка открытия IndexedDB'));
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore(mode, runner) {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, mode);
|
||||
const store = tx.objectStore(STORE);
|
||||
const result = runner(store, tx, resolve, reject);
|
||||
if (result !== undefined) resolve(result);
|
||||
tx.onerror = () => reject(tx.error || new Error('Ошибка транзакции IndexedDB'));
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function getRecord(txId) {
|
||||
return withStore('readonly', (store, _tx, resolve, reject) => {
|
||||
const req = store.get(txId);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error || new Error('Ошибка чтения кэша аватарки'));
|
||||
});
|
||||
}
|
||||
|
||||
async function putRecord(record) {
|
||||
return withStore('readwrite', (store, tx, resolve, reject) => {
|
||||
store.put(record);
|
||||
tx.oncomplete = () => resolve(true);
|
||||
tx.onerror = () => reject(tx.error || new Error('Ошибка записи кэша аватарки'));
|
||||
});
|
||||
}
|
||||
|
||||
async function getAllRecords() {
|
||||
return withStore('readonly', (store, _tx, resolve, reject) => {
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
|
||||
req.onerror = () => reject(req.error || new Error('Ошибка чтения списка кэша аватарок'));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteRecords(keys) {
|
||||
if (!Array.isArray(keys) || !keys.length) return;
|
||||
await withStore('readwrite', (store, tx, resolve, reject) => {
|
||||
keys.forEach((key) => store.delete(key));
|
||||
tx.oncomplete = () => resolve(true);
|
||||
tx.onerror = () => reject(tx.error || new Error('Ошибка очистки кэша аватарок'));
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCacheLimits() {
|
||||
const records = await getAllRecords();
|
||||
if (!records.length) return;
|
||||
|
||||
let totalBytes = 0;
|
||||
records.forEach((row) => {
|
||||
totalBytes += Number(row?.sizeBytes || row?.blob?.size || 0);
|
||||
});
|
||||
if (records.length <= MAX_ITEMS && totalBytes <= MAX_BYTES) return;
|
||||
|
||||
const ordered = [...records].sort((a, b) => Number(a?.cachedAtMs || 0) - Number(b?.cachedAtMs || 0));
|
||||
const deleteKeys = [];
|
||||
let keepCount = records.length;
|
||||
let keepBytes = totalBytes;
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
if (keepCount <= MAX_ITEMS && keepBytes <= MAX_BYTES) break;
|
||||
const row = ordered[i];
|
||||
deleteKeys.push(String(row?.txId || ''));
|
||||
keepCount -= 1;
|
||||
keepBytes -= Number(row?.sizeBytes || row?.blob?.size || 0);
|
||||
}
|
||||
|
||||
await deleteRecords(deleteKeys.filter(Boolean));
|
||||
}
|
||||
|
||||
async function fetchAvatarBlob({ gateway, txId }) {
|
||||
const url = buildArweaveDataUrl({ gateway, txId });
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Не удалось загрузить аватар (${response.status} ${response.statusText})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const type = String(blob?.type || '').toLowerCase();
|
||||
if (!type.startsWith('image/')) {
|
||||
throw new Error('Arweave-файл не является изображением');
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function getBlobFromCacheOrGateway({ gateway, txId }) {
|
||||
try {
|
||||
const cached = await getRecord(txId);
|
||||
if (cached?.blob instanceof Blob) {
|
||||
return cached.blob;
|
||||
}
|
||||
} catch {
|
||||
// ignore IndexedDB errors and fallback to fetch
|
||||
}
|
||||
|
||||
const blob = await fetchAvatarBlob({ gateway, txId });
|
||||
const record = {
|
||||
txId,
|
||||
blob,
|
||||
contentType: String(blob.type || 'application/octet-stream'),
|
||||
sizeBytes: Number(blob.size || 0),
|
||||
cachedAtMs: Date.now(),
|
||||
};
|
||||
try {
|
||||
await putRecord(record);
|
||||
await ensureCacheLimits();
|
||||
} catch {
|
||||
// ignore cache write errors
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function getCachedAvatarObjectUrl({ gateway, txId }) {
|
||||
const cleanTxId = String(txId || '').trim();
|
||||
if (!validateArweaveTxId(cleanTxId)) {
|
||||
throw new Error('Некорректный Transaction ID Arweave');
|
||||
}
|
||||
const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
export async function clearAvatarCache() {
|
||||
await withStore('readwrite', (store, tx, resolve, reject) => {
|
||||
store.clear();
|
||||
tx.oncomplete = () => resolve(true);
|
||||
tx.onerror = () => reject(tx.error || new Error('Ошибка очистки кэша аватарок'));
|
||||
});
|
||||
}
|
||||
@ -249,14 +249,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
|
||||
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
|
||||
tx.addTag('App-Name', 'SHiNE');
|
||||
tx.addTag('SHiNE-Type', 'avatar');
|
||||
|
||||
const extraTags = Array.isArray(tags) ? tags : [];
|
||||
extraTags.forEach((item) => {
|
||||
const name = String(item?.name || '').trim();
|
||||
const value = String(item?.value || '').trim();
|
||||
if (!name || !value) return;
|
||||
tx.addTag(name, value);
|
||||
});
|
||||
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
|
||||
const profileLogin = String(profileLoginTag?.value || '').trim();
|
||||
if (!profileLogin) {
|
||||
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
|
||||
}
|
||||
tx.addTag('SHiNE-Profile-Login', profileLogin);
|
||||
|
||||
await arweave.transactions.sign(tx, jwk);
|
||||
const postResult = await arweave.transactions.post(tx);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from './user-profile-params.js';
|
||||
import { buildAvatarInitials as buildAvatarInitialsFromComponent } from '../components/avatar-image.js';
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim();
|
||||
@ -132,19 +133,7 @@ export function buildIdentityLines({ login, firstName, lastName }) {
|
||||
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 const buildAvatarInitials = buildAvatarInitialsFromComponent;
|
||||
|
||||
export async function loadCurrentRelations() {
|
||||
const login = normalizeLogin(state.session.login);
|
||||
@ -210,6 +199,7 @@ export async function loadUserProfileCard(login) {
|
||||
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
||||
official: Boolean(toggles.official),
|
||||
shine: Boolean(toggles.shine),
|
||||
avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId).trim() } : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -641,6 +641,29 @@
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.avatar-image img,
|
||||
.profile-avatar img,
|
||||
.node-dot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meta-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
|
||||
@ -21,8 +21,11 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
||||
@ -139,11 +142,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
su.login AS login,
|
||||
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
|
||||
MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value,
|
||||
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value
|
||||
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value,
|
||||
MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value
|
||||
FROM solana_users su
|
||||
LEFT JOIN users_params up
|
||||
ON up.login = su.login COLLATE NOCASE
|
||||
AND up.param IN ('gender', 'official', 'shine')
|
||||
AND up.param IN ('gender', 'official', 'shine', 'ava')
|
||||
WHERE su.login COLLATE NOCASE IN (%s)
|
||||
GROUP BY su.login
|
||||
ORDER BY su.login
|
||||
@ -163,6 +167,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
meta.gender = normalizeGender(rs.getString("gender_value"));
|
||||
meta.official = parseToggle(rs.getString("official_value"));
|
||||
meta.shine = parseToggle(rs.getString("shine_value"));
|
||||
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
|
||||
out.put(normKey(login), meta);
|
||||
}
|
||||
}
|
||||
@ -188,6 +193,22 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
return "не указан";
|
||||
}
|
||||
|
||||
private String extractArAvatarTxId(String rawValue) {
|
||||
String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
|
||||
if (!value.startsWith("AR:")) return null;
|
||||
String txId = value.substring(3).trim();
|
||||
if (!AR_TX_ID_PATTERN.matcher(txId).matches()) return null;
|
||||
return txId;
|
||||
}
|
||||
|
||||
private Net_GetUserConnectionsGraph_Response.AvatarItem makeAvatarItem(String avatarAr) {
|
||||
String txId = String.valueOf(avatarAr == null ? "" : avatarAr).trim();
|
||||
if (txId.isEmpty()) return null;
|
||||
Net_GetUserConnectionsGraph_Response.AvatarItem avatar = new Net_GetUserConnectionsGraph_Response.AvatarItem();
|
||||
avatar.setAr(txId);
|
||||
return avatar;
|
||||
}
|
||||
|
||||
private List<Net_GetUserConnectionsGraph_Response.RelativeItem> toRelativeItems(
|
||||
List<String> logins,
|
||||
Map<String, UserMeta> metaByLogin
|
||||
@ -204,6 +225,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
it.setLogin(clean);
|
||||
it.setGender(gender);
|
||||
it.setGenderLabel(genderLabel(gender));
|
||||
it.setAvatar(makeAvatarItem(meta == null ? null : meta.avatarAr));
|
||||
items.add(it);
|
||||
}
|
||||
return items;
|
||||
@ -230,6 +252,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
it.setShine(shine);
|
||||
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
||||
it.setShineLabel(shine ? "сияющий" : "несияющий");
|
||||
it.setAvatar(makeAvatarItem(meta == null ? null : meta.avatarAr));
|
||||
items.add(it);
|
||||
}
|
||||
return items;
|
||||
@ -239,5 +262,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
private String gender = "unknown";
|
||||
private boolean official = false;
|
||||
private boolean shine = false;
|
||||
private String avatarAr = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,10 +24,18 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private List<RelativeItem> siblings = new ArrayList<>();
|
||||
private List<UserMarkItem> allUsers = new ArrayList<>();
|
||||
|
||||
public static class AvatarItem {
|
||||
private String ar;
|
||||
|
||||
public String getAr() { return ar; }
|
||||
public void setAr(String ar) { this.ar = ar; }
|
||||
}
|
||||
|
||||
public static class RelativeItem {
|
||||
private String login;
|
||||
private String gender;
|
||||
private String genderLabel;
|
||||
private AvatarItem avatar;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
@ -35,6 +43,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setGender(String gender) { this.gender = gender; }
|
||||
public String getGenderLabel() { return genderLabel; }
|
||||
public void setGenderLabel(String genderLabel) { this.genderLabel = genderLabel; }
|
||||
public AvatarItem getAvatar() { return avatar; }
|
||||
public void setAvatar(AvatarItem avatar) { this.avatar = avatar; }
|
||||
}
|
||||
|
||||
public static class UserMarkItem {
|
||||
@ -43,6 +53,7 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private boolean shine;
|
||||
private String officialLabel;
|
||||
private String shineLabel;
|
||||
private AvatarItem avatar;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
@ -54,6 +65,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setOfficialLabel(String officialLabel) { this.officialLabel = officialLabel; }
|
||||
public String getShineLabel() { return shineLabel; }
|
||||
public void setShineLabel(String shineLabel) { this.shineLabel = shineLabel; }
|
||||
public AvatarItem getAvatar() { return avatar; }
|
||||
public void setAvatar(AvatarItem avatar) { this.avatar = avatar; }
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user