SHA256
Аватары: общий компонент, кэш txId и avatar.ar в графе связей
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user