Аватары: общий компонент, кэш txId и avatar.ar в графе связей
This commit is contained in:
parent
667c5310bf
commit
4c1aeeeac8
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.5
|
client.version=1.2.6
|
||||||
server.version=1.2.5
|
server.version=1.2.6
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<title>Shine UI Demo</title>
|
<title>Shine UI Demo</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260426025000';
|
window.__SHINE_BUILD_HASH__ = '20260426102000';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.5';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.6';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(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,
|
jwk: walletCtx?.jwk,
|
||||||
file: optimized.file,
|
file: optimized.file,
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'SHiNE-Login', value: cleanLogin },
|
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
uploadedTxId = String(uploaded.id || '').trim();
|
uploadedTxId = String(uploaded.id || '').trim();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||||
|
|
||||||
@ -56,11 +57,20 @@ function getMarkByLogin(allUsers) {
|
|||||||
shine: Boolean(row?.shine),
|
shine: Boolean(row?.shine),
|
||||||
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
|
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
|
||||||
shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')),
|
shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')),
|
||||||
|
avatar: normalizeAvatar(row),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return map;
|
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) {
|
function applyRelativeGender(map, rows) {
|
||||||
(Array.isArray(rows) ? rows : []).forEach((row) => {
|
(Array.isArray(rows) ? rows : []).forEach((row) => {
|
||||||
const login = normalizeLogin(row?.login);
|
const login = normalizeLogin(row?.login);
|
||||||
@ -108,12 +118,23 @@ function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = nul
|
|||||||
const metaText = metaParts.join(', ');
|
const metaText = metaParts.join(', ');
|
||||||
node.title = metaText ? `${login}\n${metaText}` : login;
|
node.title = metaText ? `${login}\n${metaText}` : login;
|
||||||
|
|
||||||
const officialBadge = mark?.official ? '<span class="node-badge-official" aria-hidden="true">ОФ</span>' : '';
|
if (mark?.official) {
|
||||||
node.innerHTML = `
|
const officialBadge = document.createElement('span');
|
||||||
${officialBadge}
|
officialBadge.className = 'node-badge-official';
|
||||||
<span class="node-dot">${(login[0] || '?').toUpperCase()}</span>
|
officialBadge.setAttribute('aria-hidden', 'true');
|
||||||
<span class="node-label">${login}</span>
|
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;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import {
|
|||||||
saveProfileToggle,
|
saveProfileToggle,
|
||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.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 { openAvatarWizard } from '../components/avatar-wizard.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@ -105,10 +105,7 @@ export function render({ navigate }) {
|
|||||||
topRow.innerHTML = `
|
topRow.innerHTML = `
|
||||||
<div class="row" style="gap:12px; align-items:center;">
|
<div class="row" style="gap:12px; align-items:center;">
|
||||||
<div class="profile-avatar-block">
|
<div class="profile-avatar-block">
|
||||||
<div class="avatar large profile-avatar" data-profile-avatar="true">
|
<div data-profile-avatar-slot="true"></div>
|
||||||
<span data-avatar-fallback="true">${profile.avatarInitials}</span>
|
|
||||||
<img src="" alt="Аватар" data-avatar-image="true" hidden />
|
|
||||||
</div>
|
|
||||||
<button class="ghost-btn profile-avatar-change-btn" type="button" data-change-avatar="true">Сменить аватар</button>
|
<button class="ghost-btn profile-avatar-change-btn" type="button" data-change-avatar="true">Сменить аватар</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-identity-lines" data-profile-identity="true">
|
<div class="profile-identity-lines" data-profile-identity="true">
|
||||||
@ -154,8 +151,7 @@ export function render({ navigate }) {
|
|||||||
let currentGender = PROFILE_GENDER_UNKNOWN;
|
let currentGender = PROFILE_GENDER_UNKNOWN;
|
||||||
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||||
const avatarImageEl = topRow.querySelector('[data-avatar-image="true"]');
|
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
||||||
const avatarFallbackEl = topRow.querySelector('[data-avatar-fallback="true"]');
|
|
||||||
|
|
||||||
function openGenderPickerModal(initialGender) {
|
function openGenderPickerModal(initialGender) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@ -219,56 +215,19 @@ export function render({ navigate }) {
|
|||||||
)).join('');
|
)).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAvatarFallbackText() {
|
function updateAvatarUi() {
|
||||||
|
if (!(avatarSlotEl instanceof HTMLElement)) return;
|
||||||
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
|
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 lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
|
||||||
const fromNames = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
avatarSlotEl.innerHTML = '';
|
||||||
if (fromNames.trim()) return fromNames;
|
avatarSlotEl.append(renderUserAvatar({
|
||||||
|
login,
|
||||||
const loginFirst = String(login || '').trim().charAt(0).toUpperCase();
|
firstName,
|
||||||
if (loginFirst) return loginFirst;
|
lastName,
|
||||||
|
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
|
||||||
return String(profile.avatarInitials || '??').trim().slice(0, 2).toUpperCase();
|
size: 'large',
|
||||||
}
|
className: 'profile-avatar',
|
||||||
|
}));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateToggleButton(button, prefix, enabled) {
|
function updateToggleButton(button, prefix, enabled) {
|
||||||
@ -846,7 +805,7 @@ export function render({ navigate }) {
|
|||||||
card.append(topRow, badgesRow, status, listWrap, relativesCard);
|
card.append(topRow, badgesRow, status, listWrap, relativesCard);
|
||||||
screen.append(card);
|
screen.append(card);
|
||||||
|
|
||||||
showAvatarFallback();
|
updateAvatarUi();
|
||||||
refreshProfileSnapshot();
|
refreshProfileSnapshot();
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
buildAvatarInitials,
|
|
||||||
buildIdentityLines,
|
buildIdentityLines,
|
||||||
loadRelationsForPair,
|
loadRelationsForPair,
|
||||||
loadUserProfileCard,
|
loadUserProfileCard,
|
||||||
} from '../services/user-connections.js';
|
} from '../services/user-connections.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
|
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
|
||||||
|
|
||||||
@ -54,16 +54,31 @@ function renderIdentity(card) {
|
|||||||
lastName: card.lastName,
|
lastName: card.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `
|
const row = document.createElement('div');
|
||||||
<div class="row" style="gap:12px; align-items:center;">
|
row.className = 'row';
|
||||||
<div class="avatar large">${escapeHtml(buildAvatarInitials(card))}</div>
|
row.style.gap = '12px';
|
||||||
<div class="profile-identity-lines">
|
row.style.alignItems = 'center';
|
||||||
${lines.map((line, idx) => (
|
|
||||||
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
|
row.append(renderUserAvatar({
|
||||||
)).join('')}
|
login: card.login,
|
||||||
</div>
|
firstName: card.firstName,
|
||||||
</div>
|
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) {
|
function renderReadOnlyBadges(card) {
|
||||||
@ -174,9 +189,6 @@ export function render({ navigate, route }) {
|
|||||||
currentFlags = flags;
|
currentFlags = flags;
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="card stack">
|
|
||||||
${renderIdentity(card)}
|
|
||||||
</div>
|
|
||||||
${renderReadOnlyBadges(card)}
|
${renderReadOnlyBadges(card)}
|
||||||
${renderRelations(flags)}
|
${renderRelations(flags)}
|
||||||
${renderReadOnlyParams(card)}
|
${renderReadOnlyParams(card)}
|
||||||
@ -186,6 +198,10 @@ export function render({ navigate, route }) {
|
|||||||
<button class="ghost-btn" type="button" data-relation-action="contact"></button>
|
<button class="ghost-btn" type="button" data-relation-action="contact"></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
const identityCard = document.createElement('div');
|
||||||
|
identityCard.className = 'card stack';
|
||||||
|
identityCard.append(renderIdentity(card));
|
||||||
|
body.prepend(identityCard);
|
||||||
|
|
||||||
syncActionButtons();
|
syncActionButtons();
|
||||||
status.className = 'status-line is-available';
|
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('Content-Type', String(file.type || 'application/octet-stream'));
|
||||||
tx.addTag('App-Name', 'SHiNE');
|
tx.addTag('App-Name', 'SHiNE');
|
||||||
tx.addTag('SHiNE-Type', 'avatar');
|
tx.addTag('SHiNE-Type', 'avatar');
|
||||||
|
|
||||||
const extraTags = Array.isArray(tags) ? tags : [];
|
const extraTags = Array.isArray(tags) ? tags : [];
|
||||||
extraTags.forEach((item) => {
|
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
|
||||||
const name = String(item?.name || '').trim();
|
const profileLogin = String(profileLoginTag?.value || '').trim();
|
||||||
const value = String(item?.value || '').trim();
|
if (!profileLogin) {
|
||||||
if (!name || !value) return;
|
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
|
||||||
tx.addTag(name, value);
|
}
|
||||||
});
|
tx.addTag('SHiNE-Profile-Login', profileLogin);
|
||||||
|
|
||||||
await arweave.transactions.sign(tx, jwk);
|
await arweave.transactions.sign(tx, jwk);
|
||||||
const postResult = await arweave.transactions.post(tx);
|
const postResult = await arweave.transactions.post(tx);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { loadProfileSnapshot } from './user-profile-params.js';
|
import { loadProfileSnapshot } from './user-profile-params.js';
|
||||||
|
import { buildAvatarInitials as buildAvatarInitialsFromComponent } from '../components/avatar-image.js';
|
||||||
|
|
||||||
function normalizeLogin(value) {
|
function normalizeLogin(value) {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
@ -132,19 +133,7 @@ export function buildIdentityLines({ login, firstName, lastName }) {
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAvatarInitials({ login, firstName, lastName }) {
|
export const buildAvatarInitials = buildAvatarInitialsFromComponent;
|
||||||
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() {
|
export async function loadCurrentRelations() {
|
||||||
const login = normalizeLogin(state.session.login);
|
const login = normalizeLogin(state.session.login);
|
||||||
@ -210,6 +199,7 @@ export async function loadUserProfileCard(login) {
|
|||||||
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
|
||||||
official: Boolean(toggles.official),
|
official: Boolean(toggles.official),
|
||||||
shine: Boolean(toggles.shine),
|
shine: Boolean(toggles.shine),
|
||||||
|
avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId).trim() } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -641,6 +641,29 @@
|
|||||||
font-size: 26px;
|
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 {
|
.meta-muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@ -21,8 +21,11 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||||
|
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
||||||
@ -139,11 +142,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
su.login AS login,
|
su.login AS login,
|
||||||
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
|
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 = '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
|
FROM solana_users su
|
||||||
LEFT JOIN users_params up
|
LEFT JOIN users_params up
|
||||||
ON up.login = su.login COLLATE NOCASE
|
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)
|
WHERE su.login COLLATE NOCASE IN (%s)
|
||||||
GROUP BY su.login
|
GROUP BY su.login
|
||||||
ORDER 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.gender = normalizeGender(rs.getString("gender_value"));
|
||||||
meta.official = parseToggle(rs.getString("official_value"));
|
meta.official = parseToggle(rs.getString("official_value"));
|
||||||
meta.shine = parseToggle(rs.getString("shine_value"));
|
meta.shine = parseToggle(rs.getString("shine_value"));
|
||||||
|
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
|
||||||
out.put(normKey(login), meta);
|
out.put(normKey(login), meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,6 +193,22 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
return "не указан";
|
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(
|
private List<Net_GetUserConnectionsGraph_Response.RelativeItem> toRelativeItems(
|
||||||
List<String> logins,
|
List<String> logins,
|
||||||
Map<String, UserMeta> metaByLogin
|
Map<String, UserMeta> metaByLogin
|
||||||
@ -204,6 +225,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
it.setLogin(clean);
|
it.setLogin(clean);
|
||||||
it.setGender(gender);
|
it.setGender(gender);
|
||||||
it.setGenderLabel(genderLabel(gender));
|
it.setGenderLabel(genderLabel(gender));
|
||||||
|
it.setAvatar(makeAvatarItem(meta == null ? null : meta.avatarAr));
|
||||||
items.add(it);
|
items.add(it);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@ -230,6 +252,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
it.setShine(shine);
|
it.setShine(shine);
|
||||||
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
||||||
it.setShineLabel(shine ? "сияющий" : "несияющий");
|
it.setShineLabel(shine ? "сияющий" : "несияющий");
|
||||||
|
it.setAvatar(makeAvatarItem(meta == null ? null : meta.avatarAr));
|
||||||
items.add(it);
|
items.add(it);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@ -239,5 +262,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
private String gender = "unknown";
|
private String gender = "unknown";
|
||||||
private boolean official = false;
|
private boolean official = false;
|
||||||
private boolean shine = 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<RelativeItem> siblings = new ArrayList<>();
|
||||||
private List<UserMarkItem> allUsers = 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 {
|
public static class RelativeItem {
|
||||||
private String login;
|
private String login;
|
||||||
private String gender;
|
private String gender;
|
||||||
private String genderLabel;
|
private String genderLabel;
|
||||||
|
private AvatarItem avatar;
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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 void setGender(String gender) { this.gender = gender; }
|
||||||
public String getGenderLabel() { return genderLabel; }
|
public String getGenderLabel() { return genderLabel; }
|
||||||
public void setGenderLabel(String genderLabel) { this.genderLabel = 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 {
|
public static class UserMarkItem {
|
||||||
@ -43,6 +53,7 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
private boolean shine;
|
private boolean shine;
|
||||||
private String officialLabel;
|
private String officialLabel;
|
||||||
private String shineLabel;
|
private String shineLabel;
|
||||||
|
private AvatarItem avatar;
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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 void setOfficialLabel(String officialLabel) { this.officialLabel = officialLabel; }
|
||||||
public String getShineLabel() { return shineLabel; }
|
public String getShineLabel() { return shineLabel; }
|
||||||
public void setShineLabel(String shineLabel) { this.shineLabel = 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; }
|
public String getLogin() { return login; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user