diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26d7a606..95503e67 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,6 +47,7 @@ "react-smooth-image": "^1.1.0", "react-world-flags": "^1.6.0", "reconnecting-websocket": "^4.4.0", + "robo-identities-wasm": "^0.1.0", "simple-plist": "^1.3.1", "webln": "^0.3.2", "websocket": "^1.0.34" @@ -14282,6 +14283,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robo-identities-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/robo-identities-wasm/-/robo-identities-wasm-0.1.0.tgz", + "integrity": "sha512-q6+1Vgq+8d2F5k8Nqm39qwQJYe9uTC7TlR3NbBQ6k2ImBNccdAEoZgb0ikKjN59cK4MvqejlgBV1ybaLXoHbhA==" + }, "node_modules/run-applescript": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1fd49164..4e145048 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -86,6 +86,7 @@ "react-smooth-image": "^1.1.0", "react-world-flags": "^1.6.0", "reconnecting-websocket": "^4.4.0", + "robo-identities-wasm": "^0.1.0", "simple-plist": "^1.3.1", "webln": "^0.3.2", "websocket": "^1.0.34" diff --git a/frontend/src/components/RobotAvatar/RobohashGenerator.ts b/frontend/src/components/RobotAvatar/RobohashGenerator.ts new file mode 100644 index 00000000..2d25a89f --- /dev/null +++ b/frontend/src/components/RobotAvatar/RobohashGenerator.ts @@ -0,0 +1,73 @@ +class RoboGenerator { + private assetsCache: Record = {}; + private assetsPromises: Record> = {}; + private readonly workers: Worker[] = []; + + constructor() { + // limit to 8 workers + const numCores = Math.min(navigator.hardwareConcurrency || 1, 8); + + for (let i = 0; i < numCores; i++) { + const worker = new Worker(new URL('./robohash.worker.ts', import.meta.url)); + this.workers.push(worker); + } + } + + public generate: (hash: string, size: 'small' | 'large') => Promise = async ( + hash, + size, + ) => { + const cacheKey = `${size}px;${hash}`; + if (this.assetsCache[cacheKey]) { + return this.assetsCache[cacheKey]; + } else if (cacheKey in this.assetsPromises) { + return await this.assetsPromises[cacheKey]; + } + + const workerIndex = Object.keys(this.assetsPromises).length % this.workers.length; + const worker = this.workers[workerIndex]; + + this.assetsPromises[cacheKey] = new Promise((resolve, reject) => { + // const avatarB64 = async_generate_robohash(hash, size == 'small' ? 80 : 256).then((avatarB64)=> resolve(`data:image/png;base64,${avatarB64}`)); + // Create a message object with the necessary data + const message = { hash, size, cacheKey, workerIndex }; + + // Listen for messages from the worker + const handleMessage = (event: MessageEvent) => { + const { cacheKey, imageUrl } = event.data; + + // Update the cache and resolve the promise + this.assetsCache[cacheKey] = imageUrl; + delete this.assetsPromises[cacheKey]; + resolve(imageUrl); + }; + + // Add the event listener for messages + worker.addEventListener('message', handleMessage); + + // Send the message to the worker + worker.postMessage(message); + + // Clean up the event listener after receiving the result + const cleanup = () => { + worker.removeEventListener('message', handleMessage); + }; + + // Reject the promise if an error occurs + worker.addEventListener('error', (error) => { + cleanup(); + reject(error); + }); + + // Reject the promise if the worker times out + setTimeout(() => { + cleanup(); + reject(new Error('Generation timed out')); + }, 5000); // Adjust the timeout duration as needed + }); + + return await this.assetsPromises[cacheKey]; + }; +} + +export const robohash = new RoboGenerator(); diff --git a/frontend/src/components/RobotAvatar/index.tsx b/frontend/src/components/RobotAvatar/index.tsx index 7d4b89ae..98b74b7a 100644 --- a/frontend/src/components/RobotAvatar/index.tsx +++ b/frontend/src/components/RobotAvatar/index.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import SmoothImage from 'react-smooth-image'; import { Avatar, Badge, Tooltip } from '@mui/material'; import { SendReceiveIcon } from '../Icons'; import { apiClient } from '../../services/api'; import placeholder from './placeholder.json'; -import { type UseAppStoreType, AppContext } from '../../contexts/AppContext'; -import { type UseFederationStoreType, FederationContext } from '../../contexts/FederationContext'; +import { robohash } from './RobohashGenerator'; interface Props { nickname: string | undefined; @@ -59,21 +58,39 @@ const RobotAvatar: React.FC = ({ const className = placeholderType === 'loading' ? 'loadingAvatar' : 'generatingAvatar'; useEffect(() => { - if (nickname !== undefined) { + // TODO: HANDLE ANDROID AVATARS TOO (when window.NativeRobosats !== undefined) + if (nickname !== undefined && !coordinator) { + robohash + .generate(nickname, small ? 'small' : 'large') // TODO: should hash_id + .then((avatar) => { + setAvatarSrc(avatar); + }) + .catch(() => { + setAvatarSrc(''); + }); + setNicknameReady(true); + setActiveBackground(false); + } + + if (coordinator) { if (window.NativeRobosats === undefined) { - setAvatarSrc(`${baseUrl}${path}${nickname}${small ? '.small' : ''}.webp`); - setNicknameReady(true); - } else if (baseUrl != null && apiClient.fileImageUrl !== undefined) { - setNicknameReady(true); - void apiClient - .fileImageUrl(baseUrl, `${path}${nickname}${small ? '.small' : ''}.webp`) - .then(setAvatarSrc); + setAvatarSrc( + `${baseUrl}/static/federation/avatars/${nickname}${small ? '.small' : ''}.webp`, + ); + } else { + setAvatarSrc( + `file:///android_asset/Web.bundle/assets/federation/avatars/${nickname}${ + small ? ' .small' : '' + }.webp`, + ); } + setNicknameReady(true); + setActiveBackground(false); } else { setNicknameReady(false); setActiveBackground(true); } - }, [nickname]); + }, [nickname]); // TODO: should hash_id const statusBadge = (
diff --git a/frontend/src/components/RobotAvatar/robohash.worker.ts b/frontend/src/components/RobotAvatar/robohash.worker.ts new file mode 100644 index 00000000..a7549235 --- /dev/null +++ b/frontend/src/components/RobotAvatar/robohash.worker.ts @@ -0,0 +1,15 @@ +import { async_generate_robohash } from 'robo-identities-wasm'; + +// Listen for messages from the main thread +self.addEventListener('message', async (event) => { + const { hash, size, cacheKey, workerIndex } = event.data; + + // Generate the image using async_image_base + const t0 = performance.now(); + const avatarB64 = await async_generate_robohash(hash, size == 'small' ? 80 : 256); + const imageUrl = `data:image/png;base64,${avatarB64}`; + const t1 = performance.now(); + console.log(`Worker ${workerIndex} :: Time to generate avatar: ${t1 - t0} ms`); + // Send the result back to the main thread + self.postMessage({ cacheKey, imageUrl }); +}); diff --git a/frontend/src/contexts/FederationContext.ts b/frontend/src/contexts/FederationContext.ts index 7ce19769..d22e606a 100644 --- a/frontend/src/contexts/FederationContext.ts +++ b/frontend/src/contexts/FederationContext.ts @@ -151,11 +151,19 @@ export const useFederationStore = (): UseFederationStoreType => { const slot = garage.getSlot(); const robot = slot?.getRobot(); +<<<<<<< HEAD if (robot != null && garage.currentSlot != null) { if (open.profile && slot?.avatarLoaded === true && slot.token != null) { void federation.fetchRobot(garage, slot.token); // refresh/update existing robot } else if ( !(slot?.avatarLoaded === true) && +======= + if (robot != null && garage.currentSlot) { + if (open.profile && slot?.avatarLoaded && slot.token) { + void federation.fetchRobot(garage, slot.token); // refresh/update existing robot + } else if ( + !slot?.avatarLoaded && +>>>>>>> f861207a (Add robo-identity-wasm) robot.token !== undefined && robot.encPrivKey !== undefined && robot.pubKey !== undefined diff --git a/frontend/src/models/Book.model.ts b/frontend/src/models/Book.model.ts index 9f515962..8c4e8850 100644 --- a/frontend/src/models/Book.model.ts +++ b/frontend/src/models/Book.model.ts @@ -20,6 +20,7 @@ export interface PublicOrder { maker: number; escrow_duration: number; maker_nick: string; + maker_hash_id: string; price: number; maker_status: 'Active' | 'Seen recently' | 'Inactive'; coordinatorShortAlias?: string; diff --git a/frontend/src/models/Coordinator.model.ts b/frontend/src/models/Coordinator.model.ts index 8eab5521..8b3548fd 100644 --- a/frontend/src/models/Coordinator.model.ts +++ b/frontend/src/models/Coordinator.model.ts @@ -10,6 +10,7 @@ import { apiClient } from '../services/api'; import { validateTokenEntropy } from '../utils'; import { compareUpdateLimit } from './Limit.model'; import { defaultOrder } from './Order.model'; +import { robohash } from '../components/RobotAvatar/RobohashGenerator'; export interface Contact { nostr?: string | undefined; @@ -156,6 +157,12 @@ export class Coordinator { this.loadInfo(onDataLoad); }; + generateAllMakerAvatars = (data: [PublicOrder]) => { + for (const order of data) { + robohash.generate(order.maker_hash_id, 'small'); + } + }; + loadBook = (onDataLoad: () => void = () => {}): void => { if (this.enabled === false) return; if (this.loadingBook) return; @@ -170,6 +177,7 @@ export class Coordinator { order.coordinatorShortAlias = this.shortAlias; return order; }); + this.generateAllMakerAvatars(data); onDataLoad(); } }) diff --git a/frontend/src/models/Robot.model.ts b/frontend/src/models/Robot.model.ts index 86cb07e3..6f525f7e 100644 --- a/frontend/src/models/Robot.model.ts +++ b/frontend/src/models/Robot.model.ts @@ -1,5 +1,7 @@ import { sha256 } from 'js-sha256'; import { hexToBase91 } from '../utils'; +import { robohash } from '../components/RobotAvatar/RobohashGenerator'; +import { generate_roboname } from 'robo-identities-wasm'; interface AuthHeaders { tokenSHA256: string; @@ -13,6 +15,7 @@ class Robot { constructor(garageRobot?: Robot) { if (garageRobot != null) { this.token = garageRobot?.token ?? undefined; + this.hash_id = garageRobot?.hash_id ?? undefined; this.tokenSHA256 = garageRobot?.tokenSHA256 ?? (this.token != null ? hexToBase91(sha256(this.token)) : ''); this.pubKey = garageRobot?.pubKey ?? undefined; @@ -22,6 +25,7 @@ class Robot { public nickname?: string; public token?: string; + public hash_id?: string; public bitsEntropy?: number; public shannonEntropy?: number; public tokenSHA256: string = ''; @@ -41,6 +45,14 @@ class Robot { update = (attributes: Record): void => { Object.assign(this, attributes); + if (attributes.token != null) { + const hash_id = sha256(sha256(attributes.token)); + this.hash_id = hash_id; + this.nickname = generate_roboname(hash_id); + // trigger RoboHash avatar generation in webworker and store in RoboHash class cache. + robohash.generate(hash_id, 'small'); + robohash.generate(hash_id, 'large'); + } }; getAuthHeaders = (): AuthHeaders | null => { diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 76950736..51594f86 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -18,6 +18,7 @@ const config: Configuration = { }, ], }, + experiments: { asyncWebAssembly: true }, resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'], },