Добавить Turbo-загрузку и превью для вложений

This commit is contained in:
AidarKC
2026-08-04 19:09:54 +04:00
parent 391b18a5ac
commit 75b75fd223
13 changed files with 1445 additions and 690 deletions
+32 -4
View File
@@ -32,15 +32,31 @@ function normalizeName(name) {
return clean || 'file';
}
function normalizePreview(input = {}) {
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
if (!previewTxId || !previewSha256Hex) return null;
if (!validateArweaveTxId(previewTxId)) return null;
if (!validateSha256Hex(previewSha256Hex)) return null;
return {
ar: previewTxId,
sha256: previewSha256Hex,
};
}
export function normalizeAttachment(input = {}) {
const txId = String(input.ar || input.txId || '').trim();
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
const size = Number(input.size || input.sizeBytes || 0);
const name = normalizeName(input.name || input.fileName || 'file');
const preview = normalizePreview(input.preview || {
previewAr: input.previewAr,
previewSha256: input.previewSha256,
});
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
return {
const out = {
v: 1,
name,
size,
@@ -48,12 +64,20 @@ export function normalizeAttachment(input = {}) {
ar: txId,
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
};
if (preview) {
out.v = 2;
out.preview = preview;
}
return out;
}
export function buildAttachmentBlock(attachment) {
const item = normalizeAttachment(attachment);
const encodedName = encodeURIComponent(item.name);
return `<SHiNE:attach;v=1;name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}>`;
const previewFields = item.preview
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
: '';
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
}
export function composeMessageWithAttachments(text, attachments = []) {
@@ -91,6 +115,8 @@ export function parseMessageAttachments(rawText) {
size: fields.size,
sha256: fields.sha256,
ar: fields.ar,
previewAr: fields.previewAr,
previewSha256: fields.previewSha256,
}));
} catch {
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
@@ -236,7 +262,7 @@ function createFileCard({ item, url, messageTimestampMs, slide }) {
return card;
}
function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
const frame = document.createElement('div');
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
frame.role = 'button';
@@ -264,6 +290,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
const video = document.createElement('video');
video.className = 'message-attachment-media';
video.src = url;
if (previewUrl) video.poster = previewUrl;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
@@ -320,11 +347,12 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
const item = items[index];
const kind = getAttachmentKind(item);
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
slide.dataset.unavailable = '0';
slide.replaceChildren();
slide.append(kind === 'file'
? createFileCard({ item, url, messageTimestampMs, slide })
: createMediaSlide({ item, url, kind, messageTimestampMs, slide }));
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1;
counter.textContent = `${index + 1} из ${items.length}`;
+105 -3
View File
@@ -17,6 +17,34 @@ const BASE58_MAP = (() => {
return out;
})();
function encodeBase58(bytesLike) {
const bytes = bytesLike instanceof Uint8Array ? bytesLike : Uint8Array.from(bytesLike || []);
if (!bytes.length) return '';
const digits = [0];
for (let i = 0; i < bytes.length; i += 1) {
let carry = bytes[i];
for (let j = 0; j < digits.length; j += 1) {
const value = (digits[j] << 8) + carry;
digits[j] = value % 58;
carry = (value / 58) | 0;
}
while (carry > 0) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
let out = '';
for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) {
out += BASE58_ALPHABET[0];
}
for (let i = digits.length - 1; i >= 0; i -= 1) {
out += BASE58_ALPHABET[digits[i]];
}
return out;
}
function normalizeEndpoint(url) {
const raw = String(url || '').trim();
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
@@ -65,13 +93,27 @@ function decodeBase58(input) {
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
return solana.Keypair.fromSeed(seed32);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
seed32.fill(0);
}
}
async function keypairFromStoredSecret(storedSecret) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(storedSecret);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
seed32.fill(0);
}
}
export async function createRandomSolanaWallet() {
const solana = await loadSolanaLib();
const keypair = solana.Keypair.generate();
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
const privateKey32Base58 = encodeBase58(keypair.secretKey.slice(0, 32));
return {
address: keypair.publicKey.toBase58(),
privateKey32Base58,
@@ -107,7 +149,7 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
if (!clientPrivate) {
throw new Error('На устройстве не найден client.key');
}
const keypair = await keypairFromPkcs8(clientPrivate);
const keypair = await keypairFromStoredSecret(clientPrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
@@ -115,6 +157,66 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
};
}
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к root key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const rootPrivate = String(secrets?.rootKey || '').trim();
if (!rootPrivate) {
throw new Error('На устройстве не найден root key');
}
const keypair = await keypairFromStoredSecret(rootPrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
rootPrivatePkcs8B64: rootPrivate,
};
}
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к ключам');
}
const choices = [];
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
choices.push({
id: 'client-key',
keySource: 'client',
label: 'client key',
address: clientWallet.address,
keypair: clientWallet.keypair,
});
if (includeRoot) {
try {
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
choices.push({
id: 'root-key',
keySource: 'root',
label: 'root key',
address: rootWallet.address,
keypair: rootWallet.keypair,
});
} catch {
// root key на устройстве может отсутствовать, это допустимо
}
}
return choices;
}
export async function encodeSolanaSecretKeyBase58(secretKey) {
const bytes = secretKey instanceof Uint8Array ? secretKey : Uint8Array.from(secretKey || []);
if (!bytes.length) throw new Error('Пустой Solana secret key');
return encodeBase58(bytes);
}
export async function getBalanceSol({ endpoint, address }) {
const solana = await loadSolanaLib();
const rpc = normalizeEndpoint(endpoint);
@@ -0,0 +1,213 @@
import { getBalanceSol, getStoredSolanaWalletChoices, encodeSolanaSecretKeyBase58, formatSol } from './solana-wallet-service.js';
import { state } from '../state.js';
const FREE_TURBO_UPLOAD_BYTES = 100 * 1024;
const WINC_PER_CREDIT = 1_000_000_000_000n;
let turboLibPromise = null;
function parseSolToLamports(amountSol) {
const raw = String(amountSol ?? '').trim().replace(',', '.');
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
if (!match) {
throw new Error('Сумма пополнения должна быть числом');
}
const intPart = BigInt(match[1] || '0');
const frac = String(match[2] || '');
if (frac.length > 9) {
throw new Error('Слишком много знаков после запятой для SOL');
}
const fracPadded = `${frac}${'0'.repeat(9 - frac.length)}`;
const lamports = (intPart * 1_000_000_000n) + BigInt(fracPadded || '0');
if (lamports <= 0n) {
throw new Error('Сумма пополнения должна быть больше 0');
}
return lamports.toString();
}
function normalizeKeySource(value) {
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
}
async function loadTurboLib() {
if (!turboLibPromise) {
turboLibPromise = import('../vendor/turbo-sdk.web.bundle.js');
}
return turboLibPromise;
}
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
const normalizedKeySource = normalizeKeySource(keySource);
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true });
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
if (!selected?.keypair) {
throw new Error(normalizedKeySource === 'root'
? 'На устройстве не найден root key для Turbo.'
: 'На устройстве не найден client key для Turbo.');
}
return selected;
}
async function createTurboClientFromWalletChoice(choice) {
const moduleRef = await loadTurboLib();
const TurboFactory = moduleRef?.TurboFactory;
if (!TurboFactory?.authenticated) {
throw new Error('Turbo SDK не удалось инициализировать.');
}
const privateKey = await encodeSolanaSecretKeyBase58(choice.keypair.secretKey);
return TurboFactory.authenticated({
privateKey,
token: 'solana',
});
}
function creditsFromWinc(winc) {
const value = BigInt(String(winc || '0'));
return Number(value) / Number(WINC_PER_CREDIT);
}
function shortAddress(value) {
const raw = String(value || '').trim();
if (raw.length <= 16) return raw;
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
}
export function isFreeTurboUploadSize(byteLength) {
const bytes = Number(byteLength || 0);
return Number.isFinite(bytes) && bytes > 0 && bytes <= FREE_TURBO_UPLOAD_BYTES;
}
export function getFreeTurboUploadBytesLimit() {
return FREE_TURBO_UPLOAD_BYTES;
}
export function formatTurboCredits(value, digits = 6) {
const n = Number(value);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: digits,
});
}
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
const turbo = await createTurboClientFromWalletChoice(choice);
return {
turbo,
address: choice.address,
keySource: choice.keySource,
label: choice.label,
keypair: choice.keypair,
shortAddress: shortAddress(choice.address),
};
}
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const balance = await ctx.turbo.getBalance(ctx.address);
const solanaBalance = await getBalanceSol({
endpoint: state.registrationPayment?.endpoint || state.entrySettings?.solanaRpc || undefined,
address: ctx.address,
});
return {
...ctx,
controlledWinc: String(balance.controlledWinc || '0'),
spendableWinc: String(balance.winc || '0'),
effectiveWinc: String(balance.effectiveBalance || '0'),
controlledCredits: creditsFromWinc(balance.controlledWinc || '0'),
spendableCredits: creditsFromWinc(balance.winc || '0'),
effectiveCredits: creditsFromWinc(balance.effectiveBalance || '0'),
solBalance: solanaBalance.sol,
solLamports: solanaBalance.lamports,
};
}
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) {
const bytes = Number(byteLength);
if (!Number.isInteger(bytes) || bytes <= 0) {
throw new Error('Некорректный размер файла для Turbo.');
}
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const rows = await ctx.turbo.getUploadCosts({ bytes: [bytes] });
const price = Array.isArray(rows) ? rows[0] : null;
if (!price) {
throw new Error('Turbo не вернул цену загрузки.');
}
const winc = String(price.winc || '0');
return {
...ctx,
byteLength: bytes,
isFree: isFreeTurboUploadSize(bytes),
winc,
credits: creditsFromWinc(winc),
};
}
export async function uploadFileWithTurbo({
login,
storagePwd,
keySource = 'client',
file,
tags = [],
shineType = 'attachment',
onProgress,
} = {}) {
if (!(file instanceof File)) {
throw new Error('Выберите файл для загрузки через Turbo.');
}
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const result = await ctx.turbo.uploadFile({
file,
dataItemOpts: {
tags: [
{ name: 'Content-Type', value: String(file.type || 'application/octet-stream') },
{ name: 'App-Name', value: 'SHiNE' },
{ name: 'SHiNE-Type', value: String(shineType || 'attachment') },
...((Array.isArray(tags) ? tags : []).filter((item) => item?.name && item?.value)),
],
},
events: typeof onProgress === 'function'
? {
onProgress: ({ totalBytes, processedBytes, step }) => {
onProgress({
totalBytes,
processedBytes,
step,
});
},
}
: undefined,
});
return {
...ctx,
id: String(result?.id || '').trim(),
owner: String(result?.owner || '').trim(),
winc: String(result?.winc || '0'),
credits: creditsFromWinc(result?.winc || '0'),
};
}
export async function topUpTurboWithSolana({
login,
storagePwd,
keySource = 'client',
amountSol,
turboCreditDestinationAddress = '',
} = {}) {
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
const tokenAmount = parseSolToLamports(amountSol);
const result = await ctx.turbo.topUpWithTokens({
tokenAmount,
turboCreditDestinationAddress: String(turboCreditDestinationAddress || '').trim() || undefined,
});
return {
...ctx,
amountSol: formatSol(amountSol, 9),
lamports: tokenAmount,
txId: String(result?.id || '').trim(),
target: String(result?.target || '').trim(),
rewardWinc: String(result?.reward || '0'),
rewardCredits: creditsFromWinc(result?.reward || '0'),
};
}